Erkunden der Interna von Node.js

Veröffentlicht: 2022-03-10
Kurze Zusammenfassung ↬ Node.js ist ein interessantes Tool für Webentwickler. Mit seinem hohen Grad an Parallelität ist es zu einem führenden Kandidaten für Menschen geworden, die sich für Tools zur Verwendung in der Webentwicklung entscheiden. In diesem Artikel erfahren wir, was Node.js ausmacht, geben ihm eine aussagekräftige Definition, verstehen, wie die Interna von Node.js miteinander interagieren, und erkunden das Projekt-Repository für Node.js auf GitHub.

Seit der Einführung von Node.js durch Ryan Dahl auf der europäischen JSConf am 8. November 2009 hat es eine breite Nutzung in der gesamten Technologiebranche erfahren. Unternehmen wie Netflix, Uber und LinkedIn geben der Behauptung Glaubwürdigkeit, dass Node.js einem hohen Verkehrsaufkommen und Parallelität standhalten kann.

Ausgestattet mit Grundkenntnissen kämpfen Einsteiger und Fortgeschrittene in Node.js mit vielen Dingen: „It’s just a runtime!“ „Es hat Ereignisschleifen!“ „Node.js ist Single-Threaded wie JavaScript!“

Während einige dieser Behauptungen wahr sind, werden wir tiefer in die Node.js-Laufzeitumgebung eintauchen, verstehen, wie sie JavaScript ausführt, sehen, ob sie tatsächlich Single-Threaded ist, und schließlich die Verbindung zwischen ihren Kernabhängigkeiten, V8 und libuv, besser verstehen .

Voraussetzungen

  • Grundkenntnisse in JavaScript
  • Vertrautheit mit Node.js-Semantik ( require , fs )

Was ist Node.js?

Es mag verlockend sein anzunehmen, was viele Leute über Node.js geglaubt haben, wobei die häufigste Definition davon ist, dass es sich um eine Laufzeitumgebung für die JavaScript-Sprache handelt. Um dies zu berücksichtigen, sollten wir verstehen, was zu dieser Schlussfolgerung geführt hat.

Node.js wird oft als Kombination aus C++ und JavaScript beschrieben. Der C++-Teil besteht aus Bindings, die Low-Level-Code ausführen und den Zugriff auf Hardware ermöglichen, die mit dem Computer verbunden ist. Der JavaScript-Teil nimmt JavaScript als Quellcode und führt ihn in einem beliebten Interpreter der Sprache namens V8-Engine aus.

Mit diesem Verständnis könnten wir Node.js als ein einzigartiges Tool beschreiben, das JavaScript und C++ kombiniert, um Programme außerhalb der Browserumgebung auszuführen.

Aber könnten wir es eigentlich eine Laufzeit nennen? Um das festzustellen, definieren wir, was eine Laufzeit ist.

In einer seiner Antworten auf StackOverflow definiert DJNA eine Laufzeitumgebung als „alles, was Sie brauchen, um ein Programm auszuführen, aber keine Tools, um es zu ändern“. Gemäß dieser Definition können wir getrost sagen, dass alles, was passiert, während wir unseren Code (in welcher Sprache auch immer) ausführen, in einer Laufzeitumgebung ausgeführt wird.

Andere Sprachen haben ihre eigene Laufzeitumgebung. Für Java ist es die Java Runtime Environment (JRE). Für .NET ist es die Common Language Runtime (CLR). Für Erlang ist es BEAM.

Einige dieser Laufzeiten haben jedoch andere Sprachen, die von ihnen abhängen. Java hat beispielsweise Kotlin, eine Programmiersprache, die zu Code kompiliert wird, den eine JRE verstehen kann. Erlang hat Elixier. Und wir wissen, dass es viele Varianten für die .NET-Entwicklung gibt, die alle in der CLR, bekannt als .NET Framework, ausgeführt werden.

Jetzt verstehen wir, dass eine Laufzeitumgebung eine Umgebung ist, die für die erfolgreiche Ausführung eines Programms bereitgestellt wird, und wir wissen, dass V8 und eine Vielzahl von C++-Bibliotheken die Ausführung einer Node.js-Anwendung ermöglichen. Node.js selbst ist die eigentliche Laufzeitumgebung, die alles zusammenbindet, um diese Bibliotheken zu einer Entität zu machen, und sie versteht nur eine Sprache – JavaScript – unabhängig davon, womit Node.js erstellt wurde.

Mehr nach dem Sprung! Lesen Sie unten weiter ↓

Interne Struktur von Node.js

Wenn wir versuchen, ein Node.js-Programm (z. B. index.js ) über unsere Befehlszeile mit dem Befehl node index.js , rufen wir die Node.js-Laufzeit auf. Diese Laufzeitumgebung besteht, wie erwähnt, aus zwei unabhängigen Abhängigkeiten, V8 und libuv.

Core Node.js-Abhängigkeiten
Core Node.js-Abhängigkeiten (große Vorschau)

V8 ist ein Projekt, das von Google erstellt und verwaltet wird. Es nimmt JavaScript-Quellcode und führt ihn außerhalb der Browserumgebung aus. Wenn wir ein Programm über einen node Befehl ausführen, wird der Quellcode von der Node.js-Laufzeit zur Ausführung an V8 übergeben.

Die libuv-Bibliothek enthält C++-Code, der Low-Level-Zugriff auf das Betriebssystem ermöglicht. Funktionen wie Netzwerk, Schreiben in das Dateisystem und Parallelität werden nicht standardmäßig in V8 ausgeliefert, das der Teil von Node.js ist, der unseren JavaScript-Code ausführt. Mit seinen Bibliotheken bietet libuv diese Dienstprogramme und mehr in einer Node.js-Umgebung.

Node.js ist der Klebstoff, der die beiden Bibliotheken zusammenhält und dadurch zu einer einzigartigen Lösung wird. Während der Ausführung eines Skripts versteht Node.js, an welches Projekt wann die Kontrolle übergeben werden soll.

Interessante APIs für serverseitige Programme

Wenn wir uns ein wenig mit der Geschichte von JavaScript befassen, wissen wir, dass es dazu gedacht ist, einer Seite im Browser einige Funktionen und Interaktionen hinzuzufügen. Und im Browser würden wir mit den Elementen des Dokumentobjektmodells (DOM) interagieren, aus denen die Seite besteht. Dafür gibt es eine Reihe von APIs, die zusammenfassend als DOM-API bezeichnet werden.

Das DOM existiert nur im Browser; Es wird analysiert, um eine Seite zu rendern, und es ist im Grunde in der als HTML bekannten Auszeichnungssprache geschrieben. Außerdem existiert der Browser in einem Fenster, daher das window , das als Stamm für alle Objekte auf der Seite in einem JavaScript-Kontext fungiert. Diese Umgebung wird als Browserumgebung bezeichnet und ist eine Laufzeitumgebung für JavaScript.

Node.js-APIs rufen libuv für einige Funktionen auf
Node.js-APIs interagieren mit libuv (große Vorschau)

In einer Node.js-Umgebung haben wir weder eine Seite noch einen Browser – dies macht unser Wissen über das globale Fensterobjekt zunichte. Was wir haben, ist eine Reihe von APIs, die mit dem Betriebssystem interagieren, um einem JavaScript-Programm zusätzliche Funktionen bereitzustellen. Diese APIs für Node.js ( fs , path , buffer , events , HTTP usw.), wie wir sie haben, existieren nur für Node.js, und sie werden von Node.js (selbst eine Laufzeitumgebung) bereitgestellt, damit wir kann Programme ausführen, die für Node.js geschrieben wurden.

Experiment: Wie fs.writeFile eine neue Datei erstellt

Wenn V8 erstellt wurde, um JavaScript außerhalb des Browsers auszuführen, und wenn eine Node.js-Umgebung nicht denselben Kontext oder dieselbe Umgebung wie ein Browser hat, wie würden wir dann so etwas wie den Zugriff auf das Dateisystem oder die Erstellung eines HTTP-Servers tun?

Nehmen wir als Beispiel eine einfache Node.js-Anwendung, die eine Datei in das Dateisystem im aktuellen Verzeichnis schreibt:

 const fs = require("fs") fs.writeFile("./test.txt", "text");

Wie gezeigt, versuchen wir, eine neue Datei in das Dateisystem zu schreiben. Diese Funktion ist in der JavaScript-Sprache nicht verfügbar; es ist nur in einer Node.js-Umgebung verfügbar. Wie wird das ausgeführt?

Um dies zu verstehen, machen wir einen Rundgang durch die Codebasis von Node.js.

Auf dem Weg zum GitHub-Repository für Node.js sehen wir zwei Hauptordner, src und lib . Der lib -Ordner enthält den JavaScript-Code, der die netten Module bereitstellt, die standardmäßig in jeder Node.js-Installation enthalten sind. Der Ordner src enthält die C++-Bibliotheken für libuv.

Wenn wir in den lib -Ordner schauen und die fs.js -Datei durchgehen, werden wir sehen, dass sie voll von beeindruckendem JavaScript-Code ist. In Zeile 1880 sehen wir eine exports . Diese Anweisung exportiert alles, auf das wir zugreifen können, indem wir das fs -Modul importieren, und wir können sehen, dass es eine Funktion namens writeFile .

Die Suche nach der function writeFile( (wo die Funktion definiert ist) führt uns zu Zeile 1303, wo wir sehen, dass die Funktion mit vier Parametern definiert ist:

 function writeFile(path, data, options, callback) { callback = maybeCallback(callback || options); options = getOptions(options, { encoding: 'utf8', mode: 0o666, flag: 'w' }); const flag = options.flag || 'w'; if (!isArrayBufferView(data)) { validateStringAfterArrayBufferView(data, 'data'); data = Buffer.from(data, options.encoding || 'utf8'); } if (isFd(path)) { const isUserFd = true; writeAll(path, isUserFd, data, 0, data.byteLength, callback); return; } fs.open(path, flag, options.mode, (openErr, fd) => { if (openErr) { callback(openErr); } else { const isUserFd = false; writeAll(fd, isUserFd, data, 0, data.byteLength, callback); } }); }

In den Zeilen 1315 und 1324 sehen wir, dass eine einzelne Funktion, writeAll , nach einigen Validierungsprüfungen aufgerufen wird. Wir finden diese Funktion in Zeile 1278 in derselben fs.js -Datei.

 function writeAll(fd, isUserFd, buffer, offset, length, callback) { // write(fd, buffer, offset, length, position, callback) fs.write(fd, buffer, offset, length, null, (writeErr, written) => { if (writeErr) { if (isUserFd) { callback(writeErr); } else { fs.close(fd, function close() { callback(writeErr); }); } } else if (written === length) { if (isUserFd) { callback(null); } else { fs.close(fd, callback); } } else { offset += written; length -= written; writeAll(fd, isUserFd, buffer, offset, length, callback); } }); }

Es ist auch interessant festzustellen, dass dieses Modul versucht, sich selbst aufzurufen. Wir sehen dies in Zeile 1280, wo es fs.write . Auf der Suche nach der write werden wir ein paar Informationen entdecken.

Die write beginnt in Zeile 571 und läuft ungefähr 42 Zeilen lang. Wir sehen ein wiederkehrendes Muster in dieser Funktion: die Art und Weise, wie sie eine Funktion im binding aufruft, wie in den Zeilen 594 und 612 zu sehen ist. Eine Funktion im binding wird nicht nur in dieser Funktion aufgerufen, sondern in praktisch jeder exportierten Funktion in der Datei fs.js Irgendetwas muss daran ganz besonders sein.

Die binding wird in Zeile 58 ganz oben in der Datei deklariert, und ein Klick auf diesen Funktionsaufruf zeigt mithilfe von GitHub einige Informationen an.

Deklaration der Bindungsvariablen
Deklaration der Bindungsvariablen (Große Vorschau)

Diese internalBinding Funktion befindet sich im Modul namens loaders. Die Hauptfunktion des Loaders-Moduls besteht darin, alle libuv-Bibliotheken zu laden und sie über das V8-Projekt mit Node.js zu verbinden. Wie es das macht, ist ziemlich magisch, aber um mehr zu erfahren, können wir uns die Funktion writeBuffer genauer ansehen, die vom fs -Modul aufgerufen wird.

Wir sollten uns ansehen, wo dies mit libuv zusammenhängt und wo V8 ins Spiel kommt. Oben im Lademodul steht eine gute Dokumentation:

 // This file is compiled and run by node.cc before bootstrap/node.js // was called, therefore the loaders are bootstraped before we start to // actually bootstrap Node.js. It creates the following objects: // // C++ binding loaders: // - process.binding(): the legacy C++ binding loader, accessible from user land // because it is an object attached to the global process object. // These C++ bindings are created using NODE_BUILTIN_MODULE_CONTEXT_AWARE() // and have their nm_flags set to NM_F_BUILTIN. We do not make any guarantees // about the stability of these bindings, but still have to take care of // compatibility issues caused by them from time to time. // - process._linkedBinding(): intended to be used by embedders to add // additional C++ bindings in their applications. These C++ bindings // can be created using NODE_MODULE_CONTEXT_AWARE_CPP() with the flag // NM_F_LINKED. // - internalBinding(): the private internal C++ binding loader, inaccessible // from user land unless through `require('internal/test/binding')`. // These C++ bindings are created using NODE_MODULE_CONTEXT_AWARE_INTERNAL() // and have their nm_flags set to NM_F_INTERNAL. // // Internal JavaScript module loader: // - NativeModule: a minimal module system used to load the JavaScript core // modules found in lib/**/*.js and deps/**/*.js. All core modules are // compiled into the node binary via node_javascript.cc generated by js2c.py, // so they can be loaded faster without the cost of I/O. This class makes the // lib/internal/*, deps/internal/* modules and internalBinding() available by // default to core modules, and lets the core modules require itself via // require('internal/bootstrap/loaders') even when this file is not written in // CommonJS style.

Was wir hier lernen, ist, dass es für jedes Modul, das vom binding im JavaScript-Abschnitt des Node.js-Projekts aufgerufen wird, ein Äquivalent davon im C++-Abschnitt im Ordner src gibt.

Aus unserer fs -Tour sehen wir, dass sich das Modul, das dies tut, in node_file.cc . Jede Funktion, auf die über das Modul zugegriffen werden kann, ist in der Datei definiert; zum Beispiel haben wir den writeBuffer in Zeile 2258. Die eigentliche Definition dieser Methode in der C++-Datei befindet sich in Zeile 1785. Auch der Aufruf des Teils von libuv, der das eigentliche Schreiben in die Datei durchführt, kann in den Zeilen 1809 und gefunden werden 1815, wo die libuv-Funktion uv_fs_write asynchron aufgerufen wird.

Was gewinnen wir aus diesem Verständnis?

Genau wie viele andere interpretierte Sprachlaufzeiten kann die Laufzeit von Node.js gehackt werden. Mit größerem Verständnis könnten wir Dinge tun, die mit der Standarddistribution unmöglich sind, indem wir einfach die Quelle durchsehen. Wir könnten Bibliotheken hinzufügen, um Änderungen an der Art und Weise vorzunehmen, wie einige Funktionen aufgerufen werden. Aber vor allem ist dieses Verständnis eine Grundlage für weitere Erkundungen.

Ist Node.js Single-Threaded?

Node.js sitzt auf libuv und V8 und hat Zugriff auf einige zusätzliche Funktionen, die eine typische JavaScript-Engine, die im Browser läuft, nicht hat.

Jedes JavaScript, das in einem Browser ausgeführt wird, wird in einem einzigen Thread ausgeführt. Ein Thread in der Ausführung eines Programms ist wie eine Blackbox, die auf der CPU sitzt, in der das Programm ausgeführt wird. In einem Node.js-Kontext könnte Code in so vielen Threads ausgeführt werden, wie unsere Maschinen tragen können.

Um diese spezielle Behauptung zu überprüfen, sehen wir uns ein einfaches Code-Snippet an.

 const fs = require("fs"); // A little benchmarking const startTime = Date.now() fs.writeFile("./test.txt", "test", (err) => { If (error) { console.log(err) } console.log("1 Done: ", Date.now() — startTime) });

Im obigen Ausschnitt versuchen wir, eine neue Datei auf der Festplatte im aktuellen Verzeichnis zu erstellen. Um zu sehen, wie lange dies dauern könnte, haben wir einen kleinen Benchmark hinzugefügt, um die Startzeit des Skripts zu überwachen, der uns die Dauer in Millisekunden des Skripts gibt, das die Datei erstellt.

Wenn wir den obigen Code ausführen, erhalten wir ein Ergebnis wie dieses:

Ergebnis der Zeit, die zum Erstellen einer einzelnen Datei in Node.js benötigt wird
Zum Erstellen einer einzelnen Datei in Node.js benötigte Zeit (große Vorschau)
 $ node ./test.js -> 1 Done: 0.003s

Das ist sehr beeindruckend: nur 0,003 Sekunden.

Aber lasst uns etwas wirklich Interessantes machen. Lassen Sie uns zuerst den Code duplizieren, der die neue Datei generiert, und die Nummer in der Protokollanweisung aktualisieren, um ihre Positionen widerzuspiegeln:

 const fs = require("fs"); // A little benchmarking const startTime = Date.now() fs.writeFile("./test1.txt", "test", function (err) { if (err) { console.log(err) } console.log("1 Done: %ss", (Date.now() — startTime) / 1000) }); fs.writeFile("./test2.txt", "test", function (err) { if (err) { console.log(err) } console.log("2 Done: %ss", (Date.now() — startTime) / 1000) }); fs.writeFile("./test3.txt", "test", function (err) { if (err) { console.log(err) } console.log("3 Done: %ss", (Date.now() — startTime) / 1000) }); fs.writeFile("./test4.txt", "test", function (err) { if (err) { console.log(err) } console.log("4 Done: %ss", (Date.now() — startTime) / 1000) });

Wenn wir versuchen, diesen Code auszuführen, erhalten wir etwas, das uns umhaut. Hier ist mein Ergebnis:

Ergebnis der Zeit, die zum Erstellen mehrerer Dateien benötigt wird
Mehrere Dateien gleichzeitig erstellen (Große Vorschau)

Zunächst werden wir feststellen, dass die Ergebnisse nicht konsistent sind. Zweitens sehen wir, dass die Zeit zugenommen hat. Was ist los?

Low-Level-Aufgaben werden delegiert

Node.js ist Single-Threaded, wie wir jetzt wissen. Teile von Node.js sind in JavaScript geschrieben, andere in C++. Node.js verwendet dieselben Konzepte der Ereignisschleife und des Aufrufstapels, die wir aus der Browserumgebung kennen, was bedeutet, dass die JavaScript-Teile von Node.js Single-Threaded sind. Aber die Low-Level-Aufgabe, die das Sprechen mit einem Betriebssystem erfordert, ist nicht Single-Threaded.

Low-Level-Aufgaben werden über libuv an das Betriebssystem delegiert
Node.js Low-Level-Aufgabendelegierung (große Vorschau)

Wenn ein Aufruf von Node.js als für libuv bestimmt erkannt wird, delegiert es diese Aufgabe an libuv. In seinem Betrieb benötigt libuv Threads für einige seiner Bibliotheken, daher die Verwendung des Thread-Pools bei der Ausführung von Node.js-Programmen, wenn sie benötigt werden.

Standardmäßig enthält der von libuv bereitgestellte Node.js-Thread-Pool vier Threads. Wir könnten diesen Thread-Pool vergrößern oder verkleinern, indem wir ganz oben in unserem Skript process.env.UV_THREADPOOL_SIZE aufrufen.

 // script.js process.env.UV_THREADPOOL_SIZE = 6; // … // …

Was passiert mit unserem Dateierstellungsprogramm

Es scheint, dass Node.js, sobald wir den Code zum Erstellen unserer Datei aufrufen, auf den libuv-Teil seines Codes trifft, der einen Thread für diese Aufgabe reserviert. Dieser Abschnitt in libuv erhält einige statistische Informationen über die Festplatte, bevor an der Datei gearbeitet wird.

Diese statistische Überprüfung kann eine Weile dauern; daher wird der Thread für einige andere Aufgaben freigegeben, bis die statistische Überprüfung abgeschlossen ist. Wenn die Prüfung abgeschlossen ist, belegt die libuv-Sektion jeden verfügbaren Thread oder wartet, bis ein Thread dafür verfügbar wird.

Wir haben nur vier Aufrufe und vier Threads, also sind genug Threads vorhanden. Die einzige Frage ist, wie schnell jeder Thread seine Aufgabe bearbeitet. Wir werden feststellen, dass der erste Code, der es in den Thread-Pool schafft, zuerst sein Ergebnis zurückgibt und alle anderen Threads blockiert, während er seinen Code ausführt.

Fazit

Wir verstehen jetzt, was Node.js ist. Wir wissen, dass es eine Laufzeit ist. Wir haben definiert, was eine Laufzeit ist. Und wir haben uns eingehend damit befasst, was die von Node.js bereitgestellte Laufzeit ausmacht.

Wir sind von weit hergekommen. Und von unserer kleinen Tour durch das Node.js-Repository auf GitHub können wir jede API erkunden, an der wir interessiert sein könnten, indem wir dem gleichen Prozess folgen, den wir hier gemacht haben. Node.js ist Open Source, also können wir sicherlich in die Quelle eintauchen, oder?

Auch wenn wir einige der unteren Ebenen dessen angesprochen haben, was in der Node.js-Laufzeit passiert, dürfen wir nicht davon ausgehen, dass wir alles wissen. Die folgenden Ressourcen weisen auf einige Informationen hin, auf denen wir unser Wissen aufbauen können:

  • Einführung in Node.js
    Als offizielle Website erklärt Node.dev, was Node.js ist, sowie seine Paketmanager und listet darauf aufbauende Web-Frameworks auf.
  • „JavaScript & Node.js“, Das Node-Anfängerbuch
    Dieses Buch von Manuel Kiessling erklärt Node.js hervorragend, nachdem er gewarnt hat, dass JavaScript im Browser nicht dasselbe ist wie in Node.js, obwohl beide in derselben Sprache geschrieben sind.
  • Starten von Node.js
    Dieses Einsteigerbuch geht über eine Erläuterung der Laufzeit hinaus. Es lehrt über Pakete und Streams und das Erstellen eines Webservers mit dem Express-Framework.
  • LibUV
    Dies ist die offizielle Dokumentation des unterstützenden C++-Codes der Node.js-Laufzeit.
  • V8
    Dies ist die offizielle Dokumentation der JavaScript-Engine, die es ermöglicht, Node.js mit JavaScript zu schreiben.