post

Buffers in Node.js: mit Binärdaten arbeiten

Warum JS-Strings für rohe Bytes nicht taugen und Buffer Nodes Werkzeug dafür ist: alloc gegen allocUnsafe, Encodings sauber lesen und schreiben, und die Speicherfalle hinter slice und subarray.

In meinen Node.js-Schulungen kommt regelmäßig der Moment, in dem jemand eine Datei einliest, das Ergebnis in die Konsole schreibt und stutzt: Statt lesbarem Text steht dort <Buffer 89 50 4e 47 0d 0a 1a 0a ...>. Die erste Reaktion ist fast immer dieselbe – irgendwas ist kaputt, das muss doch ein String sein. Ist es aber nicht, und das ist auch gut so. Wer mit Node ernsthaft arbeitet, stößt früher oder später auf rohe Bytes: beim Lesen von Dateien, beim Empfangen von Netzwerkpaketen, beim Hashen von Daten mit crypto. Für all das gibt es in Node den Buffer, und ich möchte in diesem Beitrag zeigen, warum es ihn gibt, wie man ihn richtig anlegt und wo die zwei Fallen liegen, in die in meinen Kursen fast jeder einmal tritt.

Warum Strings hier nicht reichen

Ein JavaScript-String ist eine Folge von Unicode-Zeichen, keine Folge von Bytes. Das klingt nach Haarspalterei, ist aber der Kern des Problems. Sobald ich einen String habe, hat die Laufzeit bereits eine Interpretation vorgenommen: Diese Bytes sind Text, und zwar in einer bestimmten Kodierung. Rohe Binärdaten – ein PNG-Header, ein komprimierter Block, ein kryptografischer Schlüssel – haben aber gar keine Textbedeutung. Zwinge ich sie durch einen String, verliere oder verfälsche ich Bytes, weil ungültige Byte-Folgen durch Ersatzzeichen ausgetauscht werden. Das fällt oft erst spät auf, etwa wenn eine hochgeladene Datei am anderen Ende beschädigt ankommt.

Node löst das mit Buffer: einem Bereich fester Byte-Länge, der genau die Bytes enthält, die man hineingelegt hat, ohne jede Umdeutung. Und hier kommt der Punkt, den ich immer betone, weil er vieles erklärt: Ein Buffer ist eine Unterklasse von Uint8Array, also selbst ein TypedArray. Alles, was man von Uint8Array kennt – Indexzugriff, length, for-Schleifen, die TypedArray-Methoden – funktioniert damit auch auf einem Buffer. Buffer fügt lediglich die für Node praktischen Extras hinzu, allen voran die Encodings.

const fs = require('node:fs');

const data = fs.readFileSync('image.png');

console.log(Buffer.isBuffer(data));        // true
console.log(data instanceof Uint8Array);   // true
console.log(data.length);                  // number of bytes
console.log(data.subarray(0, 8));          // <Buffer 89 50 4e 47 0d 0a 1a 0a>

Die ersten acht Bytes sind übrigens die feste Signatur einer PNG-Datei. Genau solche Header liest man in der Praxis, um Dateitypen zu erkennen, ohne sich auf die Endung zu verlassen.

Einen Buffer anlegen: alloc, allocUnsafe, from

Es gibt drei Wege, an einen Buffer zu kommen, und die Unterschiede sind wichtig – auch aus Sicherheitsgründen.

  • Buffer.alloc(size) reserviert size Bytes und setzt sie alle auf 0. Optional gibt man ein Füllbyte und ein Encoding mit. Das ist der sichere Standardweg, wenn man einen leeren Buffer bestimmter Größe braucht.
  • Buffer.allocUnsafe(size) reserviert ebenfalls size Bytes, initialisiert sie aber nicht. Der Inhalt ist alter Speicher – was vorher dort stand, steht weiter dort. Dafür ist das Anlegen messbar schneller, weil das Nullen entfällt und der Speicher gegebenenfalls aus einem internen Pool stammt.
  • Buffer.from(...) erzeugt einen Buffer aus vorhandenen Daten: aus einem String (mit Encoding), aus einem Array von Byte-Werten, aus einem anderen Buffer (kopiert) oder aus einem ArrayBuffer.

Der Unterschied zwischen den ersten beiden ist kein Detail. allocUnsafe gibt Speicher zurück, der Reste früherer Allokationen enthalten kann – möglicherweise Daten aus ganz anderen Teilen des Programms.

const zeroed = Buffer.alloc(8);
console.log(zeroed);        // <Buffer 00 00 00 00 00 00 00 00>

const uninitialized = Buffer.allocUnsafe(8);
console.log(uninitialized); // <Buffer ?? ?? ?? ?? ?? ?? ?? ??> old memory

Meine Faustregel im Kurs: Nimm alloc, es sei denn, du überschreibst den ganzen Buffer unmittelbar danach sowieso vollständig – dann und nur dann ist allocUnsafe der Gewinn wert. Wer allocUnsafe nutzt und den Speicher nur teilweise füllt, darf den Rest niemals ausgeben oder versenden, denn dort können sensible Daten stehen. Das ist die erste der beiden klassischen Fallen, und sie ist unangenehm, weil sie im lokalen Test nie auffällt – auf einem frisch gestarteten Prozess ist der Speicher oft zufällig genullt. Erst unter Last, wenn der Pool wiederverwendet wird, tauchen die Datenreste auf.

Am Rande, weil es in altem Code noch herumgeistert: Der frühere Aufruf new Buffer(...) ist seit Node 10 zur Laufzeit abgekündigt (DEP0005) und sollte nicht mehr verwendet werden. Und Buffer.from(10) erzeugt keinen Buffer der Größe 10, sondern wirft einen TypeError – eine Zahl ist für Buffer.from kein gültiges Argument. Wer eine Größe meint, nimmt alloc oder allocUnsafe.

Encodings: dieselben Bytes, andere Brille

Der zweite große Nutzen von Buffer gegenüber einem nackten Uint8Array sind die Encodings. Ein Encoding ist keine Umwandlung der Bytes, sondern eine Vorschrift, wie man dieselben Bytes als Text liest oder wie man Text in Bytes zerlegt. Das folgende Diagramm zeigt den Kreis, den ich an dieser Stelle gerne an die Wand male.

graph LR
  S["String<br/>(Unicode-Text)"] -->|"Buffer.from(str, enc)"| B["Buffer<br/>(rohe Bytes)"]
  B -->|"buf.toString(enc)"| S
  B -->|"buf.toString('hex')"| H["Hex-Text"]
  B -->|"buf.toString('base64')"| B64["Base64-Text"]

Beim Hineinschreiben mit Buffer.from(str, enc) sage ich: Zerlege diesen Text nach dieser Regel in Bytes. Beim Herauslesen mit buf.toString(enc) sage ich: Deute diese Bytes nach dieser Regel als Text. Wichtig ist, dass beide Seiten dasselbe Encoding meinen, sonst kommt Unsinn heraus.

Die gebräuchlichen Encodings lassen sich in zwei Gruppen teilen. Die einen beschreiben Text-zu-Bytes: utf8 (der Standard), utf16le, latin1 (auch binary genannt) und ascii. Die anderen sind Byte-zu-Text-Darstellungen, die beliebige Binärdaten druckbar machen: hex, base64 und base64url. Gerade hex und base64 braucht man ständig, etwa um einen Hash lesbar zu machen oder Binärdaten in JSON zu transportieren.

const buf = Buffer.from('Grüße', 'utf8');

console.log(buf.length);            // 7 bytes, not 5
console.log('Grüße'.length);        // 5 characters

console.log(buf.toString('hex'));     // 4772c3bcc39f65 byte view as hex
console.log(buf.toString('base64'));  // R3LDvMOfZQ== byte view as base64

const b64 = buf.toString('base64');
const back = Buffer.from(b64, 'base64').toString('utf8');
console.log(back);                    // 'Grüße' again

Der interessante Punkt steht in den ersten beiden Zeilen: Der String hat fünf Zeichen, der Buffer aber sieben Bytes. Das ü und das ß belegen in UTF-8 jeweils zwei Bytes. Bytes und Zeichen sind eben nicht dasselbe, und wer length verwechselt, berechnet Offsets falsch. Genau daraus entsteht in der Praxis ein verwandtes Problem: Bei Streams kann ein Multibyte-Zeichen an der Stelle zerschnitten werden, an der ein Datenblock endet und der nächste beginnt. Für sauberes Dekodieren über solche Blockgrenzen hinweg gibt es den string_decoder, der halbe Zeichen zwischenpuffert – aber das ist ein Thema für sich, das ich in meinen Node.js-Streams und Backpressure genauer ausführe.

Zahlen als Bytes: die Byte-Reihenfolge

Encodings kümmern sich um Text. Sobald man aber ein Binärformat auseinandernimmt – einen Dateikopf, ein Netzwerkprotokoll, einen Frame – geht es um Zahlen, die über mehrere Bytes verteilt sind. Dafür bringt Buffer eine Reihe von Lese- und Schreibmethoden mit, die eine feste Anzahl Bytes an einem Offset als Ganzzahl deuten: readUInt8, readUInt16BE, readUInt32LE, dazu die vorzeichenbehafteten Varianten und für 64 Bit die BigInt-Fassungen wie readBigUInt64BE. Das Gegenstück zum Schreiben heißt writeUInt32BE und so fort.

Das Kürzel am Ende ist der Punkt, an dem viele stolpern: BE steht für Big-Endian, LE für Little-Endian – also für die Frage, ob das höchstwertige Byte zuerst oder zuletzt steht. Wählt man beim Lesen die andere Reihenfolge als der Schreiber, bekommt man eine völlig andere Zahl heraus, ohne dass ein Fehler geworfen wird. Netzwerkprotokolle sind traditionell Big-Endian, viele Dateiformate legen ihre Reihenfolge selbst fest; man muss also im jeweiligen Format nachsehen und darf nicht raten.

const buf = Buffer.alloc(4);

buf.writeUInt32BE(0x89504e47, 0); // PNG signature bytes as one number
console.log(buf);                 // <Buffer 89 50 4e 47>

console.log(buf.readUInt32BE(0).toString(16)); // 89504e47
console.log(buf.readUInt32LE(0).toString(16)); // 474e5089, reversed bytes

Dieselben vier Bytes ergeben je nach gewählter Reihenfolge zwei verschiedene Zahlen. Der Buffer selbst weiß nicht, was richtig ist – die Bedeutung steckt im Format, nicht in den Bytes.

Die Speicherfalle: slice und subarray teilen den Speicher

Jetzt zur zweiten großen Falle, und das ist die, an der in meinen Kursen die meisten hängenbleiben – gerade weil sie von Arrays her ein anderes Verhalten gewohnt sind. Wenn man von einem gewöhnlichen JavaScript-Array slice aufruft, bekommt man eine Kopie. Bei einem Buffer ist das anders: buf.subarray(start, end) und das ältere buf.slice(start, end) erzeugen beide keine Kopie, sondern eine Sicht auf denselben zugrunde liegenden Speicher. Wer in die Sicht schreibt, verändert das Original mit.

graph TD
  M["Byte-Block im Speicher<br/>[1, 2, 3, 4, 5]"]
  O["original<br/>(ganzer Block)"] --> M
  V["view = original.subarray(1, 3)<br/>(Bytes 1 und 2)"] --> M

Beide Namen, original und view, zeigen auf denselben Speicher. Ein Schreibzugriff über den einen ist über den anderen sichtbar.

const original = Buffer.from([1, 2, 3, 4, 5]);

const view = original.subarray(1, 3); // shares memory, no copy
view[0] = 99;

console.log(view);      // <Buffer 63 03>
console.log(original);  // <Buffer 01 63 03 04 05> byte 1 changed too!

Das Byte an Position 1 im Original hat sich geändert, obwohl ich scheinbar nur in view geschrieben habe. In einer Funktion, die einen Teil-Buffer zurückgibt und der Aufrufer diesen dann verändert, führt das zu Fehlern, die schwer zu finden sind, weil die Ursache und die Wirkung an ganz verschiedenen Stellen des Codes stehen.

Wer eine unabhängige Kopie braucht, muss sie explizit anlegen – etwa mit Buffer.from über die Sicht oder mit buf.copy in einen frisch reservierten Ziel-Buffer.

const original = Buffer.from([1, 2, 3, 4, 5]);

const copy = Buffer.from(original.subarray(1, 3)); // independent copy
copy[0] = 99;

console.log(copy);      // <Buffer 63 03>
console.log(original);  // <Buffer 01 02 03 04 05> untouched

Zur Namensfrage, weil sie oft aufkommt: Die Node-Dokumentation empfiehlt inzwischen subarray. Der Name slice existiert bei Buffern nur noch aus Kompatibilitätsgründen und weicht bewusst vom Verhalten von TypedArray.prototype.slice ab, das eine Kopie liefert. Diese Inkonsistenz ist historisch gewachsen und eine häufige Verwechslungsquelle. Ich rate im Kurs dazu, bei Buffern konsequent subarray zu schreiben – dann weiß man beim Lesen sofort, dass hier Speicher geteilt wird und keine Kopie entsteht.

Wo Buffer im Alltag auftauchen

Man legt Buffer selten von Hand an; meist bekommt man sie von der Plattform gereicht. Das Modul fs gibt beim Lesen ohne Encoding-Angabe einen Buffer zurück. Streams liefern ihre Daten in Buffer-Blöcken, wenn man sie nicht als Text konfiguriert. Das Netzwerkmodul net reicht empfangene Pakete als Buffer weiter. Und crypto arbeitet fast durchgängig mit Buffern – ein Hash etwa entsteht als Buffer, den man anschließend mit toString('hex') lesbar macht.

const crypto = require('node:crypto');

const hash = crypto.createHash('sha256')
  .update('hello world')   // accepts string or Buffer
  .digest();               // returns a Buffer

console.log(Buffer.isBuffer(hash)); // true
console.log(hash.toString('hex'));  // b94d27b9934d3e08a52e52d7da7dabfa...

Hier sieht man die zwei Rollen des Buffers schön zusammen: update nimmt Text oder Bytes entgegen, digest gibt rohe Bytes zurück, und toString('hex') macht diese Bytes für Logs und Vergleiche lesbar. Wer verstehen will, in welchem Ausführungsmodell diese Ein- und Ausgaben laufen und warum das nicht blockiert, findet den Hintergrund in meinem Node.js-Training zur Event-Loop-Architektur.

Was ich im Kurs mitgebe

Wenn ich das Thema zusammenfasse, bleiben drei Dinge hängen, die ich hier gerne noch einmal zuspitze. Erstens: Ein Buffer ist ein Uint8Array mit Node-Extras – wer das im Kopf hat, versteht den Indexzugriff und die Länge sofort und erwartet keine String-Magie. Zweitens: alloc ist der sichere Standard, allocUnsafe ein bewusster Kompromiss für Fälle, in denen man den Speicher ohnehin komplett überschreibt; alles andere riskiert, alte Daten preiszugeben. Drittens: subarray und slice teilen den Speicher, kopieren nicht – wer eine unabhängige Fassung braucht, kopiert von Hand.

Die beiden Fallen – uninitialisierter Speicher aus allocUnsafe und die geteilte Sicht aus subarray – haben eines gemeinsam: Sie fallen im schnellen Test nicht auf und schlagen erst später oder unter Last zu. Genau deshalb lohnt es sich, das Verhalten einmal bewusst durchzuspielen, statt es dem Zufall zu überlassen. Rohe Bytes sind in Node kein Randthema, sondern die Grundlage von Dateizugriff, Netzwerk und Kryptografie. Wer den Buffer verstanden hat, liest die Byte-Ausgaben in der Konsole nicht mehr als Fehler, sondern als das, was sie sind: ehrliche Daten ohne Umdeutung.

Weiterführende Quellen

Kommentare

Kommentar schreiben