Pourquoi les streams sont-ils importants ?
Charger un fichier volumineux en mémoire fonctionne jusqu’à ce que le fichier devienne plus grand que la mémoire disponible. Les streams résolvent ce problème en traitant les données par morceaux (chunks) : vous lisez une partie, vous la traitez, puis vous passez à la suivante, ce qui permet à la consommation mémoire de rester approximativement constante, quelle que soit la taille de l’entrée.
Les streams sont omniprésents dans Node.js. Les corps des requêtes et réponses HTTP, la lecture et l’écriture de fichiers, la compression, le chiffrement et de nombreux analyseurs (parsers) sont tous des streams. Les comprendre est essentiel pour bâtir des serveurs et des outils capables de gérer des données illimitées sans planter.
Les quatre types de streams
Chaque stream appartient à l’une des quatre catégories suivantes :
- Readable — une source depuis laquelle on lit des données. Fichiers, corps de requêtes HTTP, sockets et
process.stdin. - Writable — une destination vers laquelle on écrit des données. Fichiers, réponses HTTP, sockets et
process.stdout. - Duplex — à la fois readable et writable, comme un socket TCP.
- Transform — un stream duplex qui modifie les données lors de leur passage, comme
zlib.createGzip()ou un parseur CSV.
Les streams readable et writable se connectent via des pipelines, et les transforms se placent au milieu.
Lecture et écriture
Le moyen le plus simple de consommer un readable stream est l’itération asynchrone.
// read.js
import { createReadStream } from "node:fs";
const stream = createReadStream("access.log", { encoding: "utf8" });
for await (const chunk of stream) {
process.stdout.write(chunk);
}
Pour les writable streams, appelez write() et signalez la fin avec end().
// write.js
import { createWriteStream } from "node:fs";
const out = createWriteStream("out.txt");
out.write("first line\n");
out.write("second line\n");
out.end();
Le stream gère la mise en mémoire tampon des écritures en interne et les vide efficacement, vous n’avez donc pas à gérer les chunks vous-même.
Piping et pipeline
Un pipeline connecte un flux lisible (readable), zéro ou plusieurs transformations (transforms), et un flux scriptible (writable).
// compress.js
import { createReadStream, createWriteStream } from "node:fs";
import { pipeline } from "node:stream/promises";
import { createGzip } from "node:zlib";
await pipeline(
createReadStream("access.log"),
createGzip(),
createWriteStream("access.log.gz"),
);
Privilégiez toujours pipeline au chaînage manuel de .pipe(). pipeline transmet les erreurs de chaque étape, détruit les flux en cas d’échec et retourne une promesse, ce qui simplifie grandement la gestion des erreurs. Le .pipe() manuel ne propage pas les erreurs, donc un échec en amont peut laisser le pipeline en suspens et la destination ouverte.
Backpressure
Les streams possèdent des buffers internes. Lorsque vous écrivez des données plus rapidement que la destination ne peut les consommer, le buffer se remplit et write() retourne false.
// backpressure.js
function writeAll(stream, chunks) {
return new Promise((resolve, reject) => {
let i = 0;
const next = () => {
while (i < chunks.length) {
const ok = stream.write(chunks[i++]);
if (!ok) {
stream.once("drain", next);
return;
}
}
stream.end(resolve);
};
stream.on("error", reject);
next();
});
}
Attendre drain avant d’écrire davantage est la manière dont un producteur respecte un consommateur lent. pipeline et pipe gèrent cela automatiquement, ce qui est une raison supplémentaire de les utiliser.
Streams de transformation (Transform streams)
Un transform stream applique une fonction à chaque chunk. Vous pouvez créer le vôtre.
// upper.js
import { Transform } from "node:stream";
const upper = new Transform({
transform(chunk, encoding, callback) {
callback(null, chunk.toString().toUpperCase());
},
});
process.stdin.pipe(upper).pipe(process.stdout);
Node propose des transformations utiles dans node:zlib (gzip, deflate), node:crypto (chiffrements et hachages) ainsi que dans de nombreuses bibliothèques, comme les parseurs CSV et JSON.
Mode objet
Par défaut, les streams transportent des octets. Le mode objet leur permet de transporter des objets JavaScript à la place, ce qui est idéal pour les pipelines structurés.
// object-mode.js
import { Readable, Transform } from "node:stream";
Readable.from([{ id: 1 }, { id: 2 }])
.pipe(
new Transform({
objectMode: true,
transform(record, _encoding, callback) {
callback(null, { ...record, seen: true });
},
}),
)
.on("data", console.log);
En mode objet, highWaterMark compte les objets plutôt que les octets. C’est ainsi que sont conçus les streams de lignes de base de données, les processeurs de logs et les pipelines ETL.
Patterns courants
- Copier un fichier :
pipeline(createReadStream(src), createWriteStream(dest)). - Compresser ou chiffrer : insérer
createGzip()ou une transformation de chiffrement. - Diffuser une réponse HTTP : rediriger (pipe) un fichier ou le résultat d’une requête directement vers
res. - Analyser des données délimitées par ligne : utiliser une transformation qui fragmente le flux aux sauts de ligne.
- Progression d’un upload : compter les octets dans une transformation au fur et à mesure de leur passage.
Bonnes pratiques
- Privilégiez
pipelineà.pipe()pour la gestion des erreurs et le nettoyage. - Utilisez des flux (streams) pour les fichiers et les réponses volumineux au lieu de les mettre en mémoire tampon (buffering).
- Respectez la contre-pression (backpressure) ; n’ignorez jamais le fait que
write()retourne false. - N’ajustez
highWaterMarkqu’après avoir effectué des mesures. - Utilisez le mode objet pour les données structurées.
- Gérez
errorsur chaque flux que vous créez. - Détruisez les flux en cas d’échec afin de libérer les descripteurs de fichiers.
Erreurs courantes
- Utiliser
readFilepour des données volumineuses ou non bornées. - Chaîner des
.pipe()et perdre ainsi les erreurs. - Ignorer la backpressure et mettre en tampon des données non bornées.
- Oublier qu’un callback de transformation doit être appelé exactement une fois.
- Mélanger les encodages, ce qui produit une sortie corrompue.
- Laisser des streams ouverts après une erreur.
Et après ?
Les streams sont le moyen utilisé par Node.js pour gérer les données à grande échelle. Mettez-les en pratique avec le guide sur le système de fichiers et comprenez le fonctionnement de l’ordonnancement dans le guide sur l’Event Loop. Ensuite, réécrivez un appel readFile sous forme de stream et observez votre consommation mémoire se stabiliser.