Optimierung von Dateioperationen in Node.js mit Express und Fastify
Takashi Yamamoto
Infrastructure Engineer · Leapcell

Einleitung
In modernen Webanwendungen ist die Handhabung von Datei-Uploads und -Downloads eine übliche Anforderung. Ob Benutzer Profilbilder oder Dokumente hochladen oder Administratoren große Datensätze verteilen, die Effizienz dieser Operationen wirkt sich erheblich auf das Benutzererlebnis und die Serverleistung aus. Herkömmliche Ansätze beinhalten oft das Laden ganzer Dateien in den Speicher, bevor sie verarbeitet werden, was ineffizient sein kann und bei der Verarbeitung großer Dateien sogar zu Fehlern wegen Speichermangels führen kann. Hier kommt die Leistung von Node.js Streams ins Spiel. Durch die Nutzung von Streams können wir Dateien Stück für Stück verarbeiten, was den Speicherbedarf drastisch reduziert und die Reaktionsfähigkeit verbessert. Dieser Artikel befasst sich damit, wie Streams innerhalb beliebter Node.js-Frameworks wie Express und Fastify effektiv für eine robuste und skalierbare Datei-Handhabung eingesetzt werden können.
Kernkonzepte
Bevor wir uns den praktischen Implementierungen zuwenden, wollen wir ein klares Verständnis der Kernkonzepte zu diesem Thema aufbauen:
- Streams: In Node.js sind Streams eine abstrakte Schnittstelle zur Arbeit mit Streaming-Daten. Sie sind Instanzen von
EventEmitterund bieten eine Möglichkeit, Daten in kleineren, handhabbaren Stücken zu verarbeiten, anstatt sie alle auf einmal in den Speicher zu laden. Dies ist entscheidend für die Verarbeitung großer Dateien oder kontinuierlicher Datenströme. - Readable Stream: Ein Stream-Typ, von dem Daten gelesen werden können. Beispiele hierfür sind
fs.createReadStream()zum Lesen von Dateien oderhttp.IncomingMessage(Request-Objekt in HTTP-Servern). - Writable Stream: Ein Stream-Typ, in den Daten geschrieben werden können. Beispiele hierfür sind
fs.createWriteStream()zum Schreiben in Dateien oderhttp.ServerResponse(Response-Objekt in HTTP-Servern). - Duplex Stream: Ein Stream, der sowohl lesbar als auch beschreibbar ist. Sockets sind ein gutes Beispiel.
- Transform Stream: Ein Typ eines Duplex-Streams, der Daten während des Schreibens und Lesens modifizieren oder transformieren kann. Beispiele hierfür sind zlib-Streams für Komprimierung/Dekomprimierung.
- Piping: Ein grundlegendes Stream-Konzept, bei dem der Output eines Readable Streams mit dem Input eines Writable Streams verbunden wird. Dies ermöglicht den direkten Datenfluss von einem Stream zum anderen, effizient, ohne die gesamten Daten dazwischen zu puffern.
source.pipe(destination)ist die gebräuchliche Syntax. - Bussboy (Fastify): Ein hochperformanter multipart/form-data-Parser, der speziell für Fastify entwickelt wurde und oft zum Verarbeiten von File-Uploads verwendet wird.
- Multer (Express): Ein Middleware für Express.js, das
multipart/form-dataverarbeitet und hauptsächlich zum Hochladen von Dateien verwendet wird. Während Multer Streams verarbeiten kann, beinhaltet sein Standardverhalten oft das Puffern von Dateien auf der Festplatte, was für sehr große Dateien weniger effizient sein kann als ein rein stream-basierter Ansatz.
Effiziente Datei-Uploads
Die traditionelle Art der Handhabung von Datei-Uploads, insbesondere mit Middleware wie Multer, beinhaltet oft das erstmalige Speichern der gesamten Datei an einem temporären Speicherort auf der Festplatte oder sogar das Puffern im Speicher. Während dies für kleinere Dateien praktisch ist, kann es bei größeren Dateien zu einem Engpass werden. Stream-basierte Uploads ermöglichen es uns, die Datei Stück für Stück zu verarbeiten oder zu speichern, sobald sie ankommt.
Express.js mit Streams für Uploads
Für Express können wir eine benutzerdefinierte Middleware mit einer Bibliothek wie busboy (dem nativen multipart/form-data-Parser von Node.js, nicht der busboy-Bibliothek von Fastify) kombinieren oder den eingehenden Request-Stream direkt verarbeiten. Sehen wir uns ein Beispiel mit busboy für einen strukturierteren Ansatz an:
const express = require('express'); const Busboy = require('busboy'); const fs = require('fs'); const path = require('path'); const app = express(); const uploadDir = path.join(__dirname, 'uploads'); // Sicherstellen, dass das Upload-Verzeichnis existiert if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir); } app.post('/upload', (req, res) => { const busboy = Busboy({ headers: req.headers }); let fileName = ''; busboy.on('file', (fieldname, file, filename, encoding, mimetype) => { fileName = filename; const saveTo = path.join(uploadDir, path.basename(filename)); console.log(`Uploading: ${saveTo}`); file.pipe(fs.createWriteStream(saveTo)); }); busboy.on('field', (fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype) => { console.log(`Field [${fieldname}]: value: %j`, val); }); busboy.on('finish', () => { console.log('Upload complete'); res.status(200).send(`File '${fileName}' uploaded successfully.`); }); busboy.on('error', (err) => { console.error('Busboy error:', err); res.status(500).send('File upload failed.'); }); req.pipe(busboy); }); app.listen(3000, () => { console.log('Express Upload Server listening on port 3000'); });
In diesem Express-Beispiel ist req.pipe(busboy) der Schlüssel. Die eingehende HTTP-Anfrage (die ein Readable Stream ist) wird direkt in busboy gepiped. Während busboy die Multipart-Daten parst, löst es ein file-Ereignis aus und stellt einen file-Stream für die hochgeladene Datei bereit. Dieser file-Stream wird dann direkt an eine fs.createWriteStream gepiped, wodurch die Datei Stück für Stück auf die Festplatte gespeichert wird, ohne die gesamte Datei im Speicher zu puffern.
Fastify mit Stream für Uploads
Fastify hat eine ausgezeichnete native Unterstützung für Streams und sein Ökosystem lebt von Leistung. Das Plugin fastify-multipart verwendet intern busboy (die Fastify-spezifische Version), um Datei-Uploads effizient zu handhaben.
const fastify = require('fastify'); const fs = require('fs'); const path = require('path'); const pump = require('pump'); // Ein Dienstprogramm zum Piped-Streaming von Streams mit Fehlerbehandlung const app = fastify({ logger: true }); const uploadDir = path.join(__dirname, 'uploads'); // Sicherstellen, dass das Upload-Verzeichnis existiert if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir); } app.register(require('@fastify/multipart'), { limits: { fileSize: 10 * 1024 * 1024 // 10 MB Limit als Beispiel } }); app.post('/upload', async (request, reply) => { const data = await request.file(); // Den File-Stream abrufen if (!data) { return reply.code(400).send('No file uploaded.'); } const { filename, mimetype, encoding, file } = data; const saveTo = path.join(uploadDir, filename); try { await pump(file, fs.createWriteStream(saveTo)); reply.code(200).send(`File '${filename}' uploaded successfully.`); } catch (err) { request.log.error('File upload error:', err); reply.code(500).send('File upload failed.'); } }); app.listen({ port: 3000 }, (err) => { if (err) { app.log.error(err); process.exit(1); } app.log.info(`Fastify Upload Server listening on ${app.server.address().port}`); });
Im Fastify-Beispiel ruft request.file() asynchron die Dateidaten ab, die selbst einen lesbaren file-Stream enthalten. pump wird dann verwendet, um diesen eingehenden File-Stream sicher an eine fs.createWriteStream zu pipen. pump ist besonders nützlich, da es das Schließen von Streams und die Fehlerweiterleitung korrekt handhabt und so das Stream-Piping robuster macht. Dieser Ansatz stellt sicher, dass die Datei inkrementell auf die Festplatte verarbeitet und geschrieben wird.
Effiziente Datei-Downloads
Auch das Ausliefern großer Dateien zum Download profitiert enorm von Streams. Anstatt die gesamte Datei in den Speicher des Servers zu laden und sie dann zu senden, können wir einen lesbaren Stream aus der Datei erstellen und ihn direkt an die HTTP-Antwort pipen.
Express.js mit Streams für Downloads
const express = require('express'); const fs = require('fs'); const path = require('path'); const app = express(); const downloadsDir = path.join(__dirname, 'downloads'); const sampleFilePath = path.join(downloadsDir, 'sample-large-file.txt'); // Eine Dummy-Großdatei zum Testen von Downloads erstellen if (!fs.existsSync(downloadsDir)) { fs.mkdirSync(downloadsDir); } if (!fs.existsSync(sampleFilePath)) { const dummyContent = 'This is a sample line for a large file.\n'.repeat(100000); // ~5MB Datei fs.writeFileSync(sampleFilePath, dummyContent); console.log('Created a sample large file:', sampleFilePath); } app.get('/download/:filename', (req, res) => { const filename = req.params.filename; const filePath = path.join(downloadsDir, filename); if (!fs.existsSync(filePath)) { return res.status(404).send('File not found.'); } // Geeignete Header für den Download setzen res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); const fileStream = fs.createReadStream(filePath); // Fehlerbehandlung beim Stream fileStream.on('error', (err) => { console.error('Error reading file for download:', err); res.status(500).send('Could not retrieve file.'); }); fileStream.pipe(res); // Den File-Stream direkt an die Antwort pipen }); app.listen(3000, () => { console.log('Express Download Server listening on port 3000'); });
Hier erstellt fs.createReadStream(filePath) einen lesbaren Stream aus der Datei auf der Festplatte. Dieser Stream wird dann direkt an res (das HTTP-Response-Objekt, das ein Writable Stream ist) gepiped. Das bedeutet, dass, sobald Teile der Datei von der Festplatte gelesen werden, sie sofort an den Client gesendet werden, ohne die gesamte Datei im Speicher des Servers zu puffern. Dies ist sehr effizient für große Dateien und funktioniert gut mit Fortschrittsanzeigen auf der Client-Seite.
Fastify mit Streams für Downloads
Das reply-Objekt von Fastify verhält sich ebenfalls wie ein beschreibbarer Stream, sodass Stream-basierte Downloads unkompliziert sind.
const fastify = require('fastify'); const fs = require('fs'); const path = require('path'); const pump = require('pump'); const app = fastify({ logger: true }); const downloadsDir = path.join(__dirname, 'downloads'); const sampleFilePath = path.join(downloadsDir, 'sample-large-file.txt'); // Eine Dummy-Großdatei zum Testen von Downloads erstellen if (!fs.existsSync(downloadsDir)) { fs.mkdirSync(downloadsDir); } if (!fs.existsSync(sampleFilePath)) { const dummyContent = 'This is a sample line for a large file.\n'.repeat(100000); // ~5MB Datei fs.writeFileSync(sampleFilePath, dummyContent); app.log.info('Created a sample large file:', sampleFilePath); } app.get('/download/:filename', (request, reply) => { const filename = request.params.filename; const filePath = path.join(downloadsDir, filename); if (!fs.existsSync(filePath)) { return reply.code(404).send('File not found.'); } // Geeignete Header für den Download setzen reply.header('Content-Type', 'application/octet-stream'); reply.header('Content-Disposition', `attachment; filename="${filename}"`); const fileStream = fs.createReadStream(filePath); // Pump für robustes Piping und Fehlerbehandlung verwenden pump(fileStream, reply.raw, (err) => { if (err) { request.log.error('Error during file download:', err); // Es könnte zu spät sein, einen Fehlerstatus zu senden, wenn bereits Header gesendet wurden. // Erwägen Sie, den Fehler zu protokollieren und die Verbindung schließen zu lassen. } else { request.log.info(`File '${filename}' sent successfully.`); } }); }); app.listen({ port: 3000 }, (err) => { if (err) { app.log.error(err); process.exit(1); } app.log.info(`Fastify Download Server listening on ${app.server.address().port}`); });
Ähnlich wie bei Express erstellt fs.createReadStream(filePath) einen lesbaren Stream. reply.raw von Fastify bietet Zugriff auf das zugrunde liegende Node.js http.ServerResponse-Objekt, das ein beschreibbarer Stream ist. Wir verwenden dann pump, um den File-Stream an reply.raw zu pipen und so eine effiziente Datenübertragung und eine robuste Fehlerbehandlung zu gewährleisten.
Fazit
Die Nutzung von Node.js Streams für Datei-Uploads und -Downloads in Express und Fastify bietet eine hoch effiziente und skalierbare Lösung, insbesondere für die Handhabung großer Dateien. Durch die Verarbeitung von Daten in Stücken anstatt ganzer Dateien im Speicher können Anwendungen ihren Speicherbedarf erheblich reduzieren, die Leistung verbessern und das Benutzererlebnis steigern. Die Übernahme von Stream-basierten Ansätzen ist ein entscheidender Schritt zum Aufbau performanter und resilienter Datei-Handhabungsfähigkeiten in Ihren Node.js-Webanwendungen. Dieses elegante Plumbing ermöglicht einen ressourcenschonenden Datenfluss und macht Ihre Anwendungen robuster und skalierbarer.

