Um die serverseitige JavaScript-Plattform Node.js ist es in letzter Zeit ruhiger geworden. Die bedeutendste Entwicklung in diesem Bereich war das Erscheinen von Deno, einem Konkurrenzprojekt zu Node.js. Doch die Ruhe täuscht. Im Inneren entwickelt sich die Plattform kontinuierlich weiter. Das wird zum einen durch die zahlreichen neuen experimentellen Module wie beispielsweise Corepack, Diagnostics Channel oder Policies deutlich. Zum anderen versuchen die Entwickler der Plattform, an einigen zentralen Stellen näher an den JavaScript- beziehungsweise Web-Standard heranzurücken.
In diesem Artikel werfen wir einen Blick auf sieben Node.js-Features, die zwar momentan noch nicht unmittelbar im Rampenlicht stehen, aber dennoch zeigen, in welche Richtung sich die Plattform entwickelt. Den Anfang macht das Modulsystem und mit ihm die wohl größte Veränderung in Node.js seit Beginn des Projekts.
1. ECMAScript-Module
Zu Beginn der Entwicklung von Node.js gab es noch kein standardisiertes Modulsystem in JavaScript, das das Exportieren von Schnittstellen einer Datei und den Import dieser Schnittstellen regelt. Ryan Dahl, der Erfinder von Node.js, setzte deshalb auf das CommonJS-Modulsystem, das mit dem module-Objekt und seiner exports-Eigenschaft für das Exportieren von Strukturen und die require-Funktion für das Einbinden exportierter Elemente arbeitet.
Mittlerweile wurde der ECMAScript-Standard um ein Modulsystem erweitert. Dieses ist jedoch nicht kompatibel mit einem der bestehenden Modulsysteme, die im Frontend oder Backend in JavaScript verwendet werden. Die Entwickler von Node.js haben sich schon vor einigen Jahren dazu entschieden, auf das ECMAScript-Modulsystem umzustellen. Dieser Prozess dauert jedoch mittlerweile schon mehrere Jahre. Der Grund hierfür ist, dass Node.js versucht, sogenannte “Breaking Changes” zu vermeiden. Ein plötzlicher Wechsel des Modulsystems bedeutet, dass kein Paket und keine Applikation, die nicht im vornherein darauf vorbereitet ist, weiterhin funktioniert. Aus diesem Grund erfolgt die Umstellung in kleinen Schritten, sodass sich die Entwickler von Applikationen und Bibliotheken darauf einstellen können. Mit der aktuellen Version 17 von Node.js ist das CommonJS-Modulsystem immer noch der Standard, und das ECMAScript-Modulsystem muss zunächst aktiviert werden.
Neue Applikationen können bereits bedenkenlos mit dem neuen Modulsystem umgesetzt werden, die Eigenschaft “type”: “module” in der package.json-Datei der Applikation aktiviert das ECMAScript-Modulsystem in der gesamten Applikation. Alternativ dazu hat der Start der Applikation mit dem Kommandozeilen-Flag –input-type=module dieselbe Wirkung.
Bei bestehenden Applikationen, die noch auf dem CommonJS-Modulsystem basieren, stellt sich die Frage, wie die Applikation am besten migriert werden kann. Hierfür bieten sich generell zwei Strategien an: Die erste Variante beruht auf einer leichtgewichtigen ECMAScript-Basisapplikation, in die die bestehende CommonJS-Applikation eingebunden wird und dann die Module Schritt für Schritt auf das neue Modulsystem migriert werden. Die zweite Variante baut auf einer Basisapplikation in CommonJS auf und bindet ECMAScript-Module ein. Auch hier werden die einzelnen Module nach und nach auf das neue Modulsystem umgestellt und schließlich auch die Basisapplikation. Beide Strategien werden möglich, da Node.js eine Interoperabilität zwischen den beiden Modulsystemen vorsieht.
Das CommonJS-Modulsystem erlaubt den direkten Import von ECMAScript-Modulen über die require-Funktion nicht. Stattdessen kommen dynamische Importe mit der import-Funktion zum Einsatz. Dieser Schritt ist erforderlich, da ECMAScript-Module asynchron ausgeführt werden.
Die Kombination aus einer Applikation, die auf ECMAScript-Modulen basiert und CommonJS-Module einbindet, ist problemlos möglich. Der Wert, den das Modul über module.exports zur Verfügung stellt, kann im ECMAScript-Modulsystem wie ein “default export” behandelt werden.
2. Promises
Eines der Kernfeatures von Node.js ist der nichtblockierende Umgang mit Ein- und Ausgaben, also das “nonblocking I/O”. Daraus ergibt sich, dass die meisten Schnittstellen der Plattform asynchron umgesetzt sind. Das wiederum führt dazu, dass in vielen Fällen auf “multithreading” und die damit einhergehende Komplexität verzichtet werden kann.
Asynchronität in JavaScript wurde lange Zeit mithilfe von Callbacks umgesetzt. Das gilt auch für die Kernmodule von Node.js. Viele Features sind eventbasiert umgesetzt, wie beispielsweise die HTTP-Module. Andere wiederum, wie Teile des Filesystem-Moduls, rufen die übergebene Callback-Funktion lediglich einmal auf. In modernem JavaScript ist das eine Paradedisziplin für Promises. Und so gibt es für die Dateisystem-Schnittstelle insgesamt drei Implementierungen: eine synchrone und damit blockierende Variante, eine asynchrone und callbackbasierte Variante und schließlich eine weitere asynchrone Schnittstelle, die Promises nutzt. Letztere ist noch vergleichsweise jung, trägt aber der Tatsache Rechnung, dass immer mehr asynchroner Quellcode in der JavaScript-Welt Promises nutzt.
Das Entwicklerteam von Node.js aktualisiert mit nahezu jedem Release auch die verwendete V8-Engine, also die JavaScript-Engine der Plattform. Das bedeutet, dass Node.js immer die aktuellen JavaScript-Features unterstützt, und dazu zählt unter anderem das async/await-Feature, das synchronen Codefluss auch für asynchrone Operationen erlaubt. Seit Version 14.8 ist auch das top-level await-Feature in Node.js ohne zusätzliches Flag verfügbar. Mit einer Kombination aus dem fs/promises-Modul und async/await beziehungsweise im Fall des folgenden Beispiels top-level await kann eine Applikation eine Datei auslesen und zwar ohne den gesamten Prozess zu blockieren und ohne, dass eine Callback-Funktion verwendet wird. Die Fehlerbehandlung wird wie bei synchronem Code über ein try-catch-Statement umgesetzt.
3. Buffer
Node.js nutzt Buffer-Objekte, um mit Binärdaten umzugehen. Viele Schnittstellen der Plattform nutzen diese Objektklasse zum Datenaustausch. Ein populärer Vertreter ist das Dateisystem-Modul. Die readfile-Funktion liest den Inhalt einer Datei aus. Wird beim Aufruf der Funktion kein Encoding wie beispielsweise utf-8 angegeben, liegt der Inhalt der Datei als Buffer-Objekt vor.
Der ECMAScript-Standard sieht für den Anwendungsfall von Buffer-Objekten mittlerweile einen eigenen nativen Datentyp vor: die TypedArrays. Die Buffer-Klasse leitet von Uint8Array ab, also von den nativen TypedArrays von JavaScript. Allerdings sind Buffer und TypedArrays nicht vollständig kompatibel. Das liegt vor allem daran, dass Node.js an diesen Stellen rückwärtskompatibel gehalten werden soll.
Ein konkretes Beispiel für den Umgang mit Buffern und TypedArrays liefert die readFile-Funktion aus dem fs-Modul. Erhält sie beim Aufruf keine Angabe des Encodings der ausgelesenen Datei, gibt sie statt dem Inhalt der Datei ein Buffer-Objekt zurück. Die Überprüfung, ob das Objekt tatsächlich ein TypedArray ist, kann auf zwei Arten erfolgen: entweder mit dem instanceof-Operator oder mit der Hilfsfunktion isTypedArray aus dem util-Modul.
Bemerkenswert ist hierbei, dass sich Node hier auch wieder dem ECMAScript-Standard annähert und nicht weiter auf eine Eigenimplementierung setzt.
Ein weiterer Einsatzzweck von TypedArrays ist der Datenaustausch bei der Verwendung von Worker Threads. Diese können im Gegensatz zu Kindprozessen auf gemeinsam genutzten Speicher zugreifen. Der Datenaustausch funktioniert hier entweder über ArrayBuffer oder SharedArrayBuffer, die wiederum mit TypedArrays arbeiten.
4. URL
Die Ursprünge von Node.js liegen in der Entwicklung von Web-Backends. Ein nicht zu unterschätzender Aspekt hierbei ist der Umgang mit URLs. Zu diesem Zweck sieht die Node.js-Plattform das URL-Modul vor, mit dem URLs aufgelöst und geparst werden können.
Lange Zeit setzte Node.js auf ein eigenes Modul. Die WHATWG, eine Arbeitsgruppe, die sich mit der Definition von Standards beschäftigt, entwickelte währenddessen einen Standard für den Umgang mit URLs. Das Node.js-Entwicklungsteam hat sich dann dazu entschlossen, auf den WHATWG-Standard zu wechseln und damit mit der clientseitigen Implementierung kompatibel zu werden. Der Strategiewechsel beim URL-Modul ist ein schönes Beispiel für den Stabilitätsindex der Node.js-Module. Dieser Index verfügt über insgesamt vier Stufen, die die Stabilität des jeweiligen Moduls angeben:
- Stabilität 0: Deprecated: Das Feature ist veraltet und sollte nicht weiterverwendet werden.
- Stabilität 1: Experimental: Hierbei handelt es sich um ein neues Feature, das noch nicht für den Produktiveinsatz geeignet ist. Die API kann sich im Verlauf der nächsten Versionen noch ändern.
- Stabilität 2: Stable: Die Schnittstelle ist stabil und kann produktiv verwendet werden.
- Stabilität 3: Legacy: Die Schnittstelle sollte nicht weiterverwendet werden.
Die Funktionen des bisherigen URL-Moduls sind als Legacy gekennzeichnet. Es besteht also kein direkter Handlungsbedarf für eine Migration. Die neue URL-Schnittstelle bietet die beiden Klassen URL und URLSearchParams. Beide Klassen sind global in Node.js verfügbar und müssen nicht erst über das Modulsystem eingebunden werden. Die wichtigsten Operationen im Zusammenhang mit dieser Schnittstelle sind die Erzeugung eines URL-Objekts aus einer Zeichenkette und die Umwandlung eines URL-Objekts in eine Zeichenkette.
Der Weg von der Zeichenkette zum Objekt verläuft über den Konstruktor der URL-Klasse, die eine URL-Zeichenkette akzeptiert. Für den Zugriff auf die korrekt formatierte String-Repräsentation der URL sieht die URL-Klasse zwei Möglichkeiten vor: die toString-Methode und die href-Eigenschaft. Neben diesen beiden Schnittstellen sieht die URL-Klasse noch zahlreiche weitere Eigenschaften vor, mit denen EntwicklerInnen auf die einzelnen Aspekte einer URL wie beispielsweise Host oder Pfad zugreifen können.
5. Crypto
Ähnlich wie beim URL-Modul verhält es sich auch beim Crypto-Modul. Hier setzt Node.js bisher auch auf eine eigene Implementierung, bietet mittlerweile jedoch zusätzlich eine Schnittstelle, die mit der Web Crypto API aus dem Browser kompatibel ist. Diese Schnittstelle befindet sich jedoch aktuell noch im experimentellen Stadium und sollte deshalb nicht oder nur mit größter Vorsicht produktiv eingesetzt werden.
Node.js stellt das neue Modul unter der webcrypto-Eigenschaft zur Verfügung. Die Kernfunktionen des Web Crypto-Moduls sind:
- Arbeit mit Schlüsseln: Die Web Crypto API kann sowohl Schlüssel erzeugen als auch bestehende Schlüssel importieren. Schlüssel können außerdem exportiert werden.
- Verschlüsselung und Entschlüsselung: Die Schnittstelle bietet Methoden, um Inhalte zu verschlüsseln und sie so vor unberechtigtem Zugriff zu schützen. Verschlüsselte Inhalte können nur mit einem passenden Schlüssel wieder entschlüsselt werden.
- Signierung und Verifizierung: Die sign-Methode erzeugt eine cryptografische Signatur einer Struktur, die zu einem späteren Zeitpunkt überprüft werden kann. Damit lassen sich beispielsweise unerlaubte Änderungen erkennen.
Die Web Crypto API arbeitet mit TypedArrays. So müssen die Datenstrukturen, die verschlüsselt oder signiert werden sollen, zunächst in ein TypedArray umgewandelt werden. Das folgende Beispiel verschlüsselt die Zeichenkette “Hallo Welt” mit der encrypt-Methode und entschlüsselt sie anschließend mit der decrypt-Methode wieder.
6. Web Streams
Node.js unterstützt an vielen Stellen Datenströme. Für den Umgang mit diesen Datenströmen sieht die Plattform das Stream-Modul vor. Dieses Modul hat eine recht bewegte Geschichte, da die EntwicklerInnen die API und auch die Art des Umgangs mit den Datenströmen über die Zeit angepasst haben. So ist es möglich, einen Datenstrom im flowing– oder im paused-Mode zu betreiben. Im flowing-Mode werden die Daten des Stroms so schnell gelesen wie sie ankommen. Beim paused-Mode werden die Daten aktiv über die read-Methode konsumiert, das lesende Ende des Stroms hat damit deutlich mehr Kontrolle.
Aber auch im Bereich der Datenströme hat sich ein übergreifender Standard herausgebildet, der von der WHATWG als WHATWG Streams Standard verwaltet wird. Dieser verfolgt eine ähnliche Idee wie die bisherigen Node.js-Streams, die Schnittstelle weist jedoch einige Unterschiede auf. Die Web Streams API ist über das stream/web-Modul verfügbar, befindet sich jedoch aktuell noch im experimentellen Stadium. Die Schnittstelle implementiert, ähnlich wie das bisherige Stream-Modul, die Klassen ReadableStream, WritableStream und TransformStream.
- ReadableStream: Diese Klasse repräsentiert einen Datenstrom, aus dem Daten gelesen werden können.
- WritableStream: Mit der WritableStream-Klasse können Daten in einen Stream geschrieben werden.
- TransformStream: Der TransformStream bildet das Verbindungsstück zwischen lesendem und schreibendem Ende des Datenstroms und wird generell dazu verwendet, mit den gelesenen Informationen zu arbeiten, bevor sie geschrieben werden.
Die Anwendungsgebiete für Streams sind sehr vielfältig. Typischerweise werden Streams überall da eingesetzt, wo Ereignisse mehrfach auftreten. Der Empfang oder Versand von Paketen über eine Netzwerkverbindung ist ein gutes Beispiel. Aber auch das Lesen von Informationen aus einer Datenbank zählt zu den Fällen, bei denen EntwicklerInnen auf Streams zurückgreifen können.
Ein etwas einfacherer Fall zeigt im folgenden Beispiel, wie die drei Stream-Klassen miteinander verbunden werden können, um eine Datei auszulesen, den Inhalt zu modifizieren und in eine neue Datei zu schreiben.
Für die Erstellung des ReadableStreams sieht Node.js beispielsweise die readableWebStream-Methode auf dem Filehandle-Objekt vor. WritableStream und TransformStream werden als Instanz der jeweiligen Klasse erzeugt. Der Konstruktor akzeptiert jeweils ein Objekt mit einer write– beziehungsweise transform-Methode. In beiden Fällen ist wichtig zu beachten, dass nicht direkt mit der Zeichenkette, sondern mit TypedArrays gearbeitet wird. Die Verbindung der drei Streams erfolgt mit der pipeThrough– und pipeTo-Methode.
7. Internationalization
Internationalisierung betrifft nicht nur den Client, der Informationen für die BenutzerInnen darstellt, sondern auch den Server, also Node.js. Hier geht es vor allem um den korrekten Umgang mit Zahlen, Datums- und Zeitwerten sowie die korrekte Bildung von Singular und Plural. Node.js verfügt unter anderem durch den JavaScript-Standard über eine Reihe von Schnittstellen wie beispielsweise String.prototype.toLocaleUpperCase oder String.prototype.localeCompare, die Internationalisierung unterstützen.
Wird Node.js aus dem Quellcode kompiliert, ist es möglich, den Umfang der Unterstützung der Internationalisierung über eine Option zu konfigurieren. Hier stehen insgesamt vier Optionen zur Verfügung:
- None: Keine Internationalisierung ist vorgesehen.
- System-icu: Der System-Standard definiert die teilweise oder vollständige Unterstützung bestimmter Features.
- Small-icu: Diese Option hilft, die Größe des resultierenden Pakets zu verringern, indem nur gewisse Internationalisierungsaspekte (meist englisch) aktiv sind.
- Full-icu: Diese Option ist der Standardwert und sorgt für eine vollständige Unterstützung der Internationalisierung. Das bedeutet allerdings auch, dass die Größe der Node.js-Plattform umfangreicher wird.
Ein Aspekt dieser Internationalisierungs-Konfiguration ist, dass beispielsweise bei der none-Option das Intl-Objekt nicht verfügbar ist. Das folgende Beispiel zeigt, wie EntwicklerInnen das Intl-Objekt, das in allen großen Browsern ebenfalls unterstützt wird, für die Formatierung von Zahlen verwenden können.
7 Neuerungen in Note.js – Fazit
Die Macher von Node.js sorgen dafür, dass die Plattform den Ansprüchen der Community gerecht wird, indem der Kern von Node.js kontinuierlich durch neue Features erweitert wird. Der JavaScript-Standard deckt zwar einen großen Teil des Sprachumfangs ab, einige Schnittstellen wie beispielsweise Internationalisierung, Cryptographie oder Streams sind jedoch nicht Bestandteil des Sprachstandards. Diese werden von anderen Gremien für bestimmte Umgebungen wie beispielsweise den Browser definiert.
Sobald sich einer dieser Standards etabliert, prüfen auch die EntwicklerInnen von Node.js, ob eine Integration in Node.js sinnvoll ist. Durch die Strategie der Plattform, Breaking Changes möglichst zu vermeiden, führt die Aufnahme eines neuen Standards immer durch einen mehrstufigen Prozess, an dessen Anfang ein Feature zunächst hinter einem Feature-Flag versteckt wird, es dann später als experimentelles Feature generell verfügbar ist und schließlich, sobald es stabil genug für den Produktiveinsatz ist, es als stabiles und damit reguläres Feature ausgeliefert wird.
Durch diese Vorgehensweise nähert sich Node.js in vielen Aspekten den vor allem in den Browsern verwendeten Schnittstellen an, sodass ein Wechsel zwischen client- und serverseitigem JavaScript zunehmend leichter fällt. Außerdem steigert sich dadurch die Wiederverwendbarkeit von Quellcode zwischen beiden Welten.