mitmario.dev

Was ist ein Stream?

Node.js Sandbox 3 Min Lesezeit 3 BeispieleLektion 4 von 7

readFile ist ein Eimer. Du hältst ihn hin, er wird gefüllt, und danach hast du alles.

Bei einer Zwei-Gigabyte-Datei heißt das zwei Gigabyte Arbeitsspeicher, und die hat dein Server vielleicht nicht. Bei zehn gleichzeitigen Anfragen auf dieselbe Datei sind es zwanzig.

Ein Stream ist ein Gartenschlauch. Es kommt ein Stück, du verarbeitest es und lässt es los, dann kommt das nächste. Im Speicher liegt immer nur ein Stück, egal wie groß die Datei ist.

Vier Arten, je ein Satz

Lesbar liefert Daten: eine Datei, eine Netzwerkantwort, die Tastatureingabe.

Schreibbar nimmt Daten entgegen: eine Datei, eine HTTP-Antwort, die Standardausgabe.

Duplex kann beides gleichzeitig, ohne dass das eine mit dem anderen zu tun hätte: ein Netzwerk-Socket.

Transform ist ein Duplex, bei dem das Ausgehende aus dem Eingehenden entsteht: komprimieren, verschlüsseln, Groß- und Kleinschreibung ändern.

Einen Stream lesen

Die moderne und lesbare Form ist eine Schleife.

Eine Datei in Stücken lesen
import { writeFileSync, createReadStream } from "node:fs";

writeFileSync("daten.txt", "x".repeat(5000));

let stuecke = 0;
let zeichen = 0;

for await (const stueck of createReadStream("daten.txt", { encoding: "utf8", highWaterMark: 1024 })) {
  stuecke += 1;
  zeichen += stueck.length;
}

console.log(`${stuecke} Stuecke, ${zeichen} Zeichen`);

for await ... of über einen lesbaren Stream gibt dir Stück für Stück, und wenn die Datei zu Ende ist, ist die Schleife zu Ende. Wie groß ein Stück ist, steuert highWaterMark. Der Standard bei Dateien sind 64 KiB, im Beispiel steht 1024, damit man überhaupt mehr als ein Stück sieht.

Fünftausend Zeichen, Stücke zu je 1024, macht fünf Stücke, das letzte kürzer. Das ist die ganze Mechanik.

Was in einem Stück wirklich steckt

Ohne weitere Angabe liefert ein Datei-Stream keine Zeichenketten, sondern einen Buffer. Ein Buffer ist ein Stück Rohbytes, sonst nichts. Er weiß nicht, ob darin Text steht, und wenn ja, in welcher Kodierung.

Text wird daraus erst beim Umwandeln, und genau da liegt die Falle: Ein Stück endet dort, wo die eingestellte Größe erreicht ist, nicht dort, wo ein Zeichen aufhört. In UTF-8 braucht ein ä zwei Bytes, ein Emoji vier. Fällt die Grenze mittendurch, hat das eine Stück ein halbes Zeichen am Ende und das nächste die andere Hälfte am Anfang.

Jedes Stück einzeln umgewandelt
import { writeFileSync, createReadStream } from "node:fs";

writeFileSync("umlaute.txt", "ä".repeat(3000));

let zeichen = 0;
let kaputte = 0;

for await (const stueck of createReadStream("umlaute.txt", { highWaterMark: 1023 })) {
  const text = stueck.toString("utf8");
  zeichen += text.length;
  if (text.includes("�")) kaputte += 1;
}

console.log(`${zeichen} Zeichen statt 3000`);
console.log(`${kaputte} Stuecke mit Buchstabensalat`);

Dreitausend ä, und heraus kommen dreitausenddrei Zeichen, davon einige Fragezeichen im Kasten. Bei sechs Stücken ist die Grenze mitten durch einen Umlaut gegangen, und toString hat aus jeder Hälfte brav ein Ersatzzeichen gemacht.

Der Fehler daran ist nicht der Buffer, sondern die Stelle des Umwandelns. Es gibt kein „dieses Stück ist Text”, es gibt nur „dieser Strom ist Text”.

Denselben Job dem Stream überlassen
import { writeFileSync, createReadStream } from "node:fs";

writeFileSync("umlaute.txt", "ä".repeat(3000));

let zeichen = 0;
let kaputte = 0;

for await (const stueck of createReadStream("umlaute.txt", { encoding: "utf8", highWaterMark: 1023 })) {
  zeichen += stueck.length;
  if (stueck.includes("�")) kaputte += 1;
}

console.log(`${zeichen} Zeichen statt 3000`);
console.log(`${kaputte} Stuecke mit Buchstabensalat`);

Ein encoding: "utf8" an createReadStream, und du bekommst Zeichenketten statt Buffer. Der Stream hält angebrochene Zeichen selbst zurück und hängt sie vorn an das nächste Stück. Dreitausend Zeichen, kein Salat.

Merk dir die Regel: Kodierung gehört an den Stream, nicht an das einzelne Stück.

Und die Grenze bleibt trotzdem

Ein encoding löst das Problem für Zeichen. Für alles, was länger ist als ein Zeichen, gilt es weiter.

Ein Wort kann auf einer Stückgrenze zerfallen. Eine Zeile sowieso. Ein JSON-Objekt erst recht. Wer Stück für Stück nach einem Wort sucht, findet die nicht, die genau auf der Naht liegen.

Der Ausweg ist immer derselbe: Du behältst einen Rest vom vorigen Stück und hängst ihn vorn an das nächste. Wie lang der Rest sein muss, hängt davon ab, wonach du suchst. Bei einem Wort aus fünf Buchstaben reichen vier Zeichen, denn ein vollständiger Treffer hätte sich sonst schon im vorigen Stück gefunden.

Genau das baust du in der Challenge. Es sind drei Zeilen, aber es sind die drei Zeilen, an denen sich entscheidet, ob deine Zählung stimmt.

Zum Mitnehmen

readFile holt einen Eimer auf einmal, ein Stream ist ein Gartenschlauch. Und jedes Stück endet dort, wo es endet, nicht dort, wo es dir passt.

Jetzt du

Basis Konto, kostenlos

Zu dieser Lektion gehört eine Aufgabe. Du schreibst den Code selbst, und nach jedem Lauf sagt dir eine Prüfliste, was schon stimmt.

Dafür brauchst du das Basis Konto. Es kostet nichts, und ein Passwort gibt es auch nicht.

In diesem Kurs läuft dein Code auf einem Server. Dafür hat das Basis Konto 1 Stunde im Monat, mehr Zeit gibt es mit dem Premium Konto.

Was in dieser Lektion steckt

  • Artikel mit 3 Beispielen zum Ausprobieren

    Steht hier, ohne Konto lesbar.

  • Aufgabe, dein Code läuft auf einem Server

    Öffnet sich mit dem Basis Konto.