Node.jsのExpressおよびFastifyを使用したファイル操作の効率化
Takashi Yamamoto
Infrastructure Engineer · Leapcell

はじめに
現代のWebアプリケーションでは、ファイルアップロードとダウンロードの処理は一般的な要件です。ユーザーがプロフィール写真、ドキュメントをアップロードする場合でも、管理者が大規模なデータセットを配布する場合でも、これらの操作の効率はユーザーエクスペリエンスとサーバーのパフォーマンスに大きく影響します。従来の通常、大きなファイルを扱う場合にメモリ不足のエラーにつながる可能性、非効率的になる可能性の、処理する前にファイル全体をメモリにロードすることを含みます。ここでNode.js Streamの力が役立ちます。ストリームを活用することで、ファイルをチャンクごとに処理できるため、メモリフットプリントが大幅に削減され、応答性が向上します。この記事では、堅牢でスケーラブルなファイル処理のために、ExpressやFastifyのような人気のあるNode.jsフレームワーク内でストリームを効果的に使用する方法を掘り下げます。
コアコンセプト
実践的な実装に飛び込む前に、このトピックに関連するコアコンセプトを明確に理解しましょう。
- ストリーム: Node.jsでは、ストリームはストリーミングデータを扱うための抽象インターフェースです。これらは
EventEmitterのインスタンスであり、すべてのデータを一度にメモリにロードするのではなく、小さく管理しやすいチャンクでデータを処理する方法を提供します。これは、大きなファイルまたは連続したデータフローを処理するために重要です。 - Readable Stream: データが読み取れるストリームの一種です。例としては、ファイルを読み取るための
fs.createReadStream()や、HTTPサーバー(リクエストオブジェクト)のhttp.IncomingMessageが挙げられます。 - Writable Stream: データが書き込めるストリームの一種です。例としては、ファイルに書き込むための
fs.createWriteStream()や、HTTPサーバー(レスポンスオブジェクト)のhttp.ServerResponseが挙げられます。 - Duplex Stream: ReadableでもWritableでもあるストリームです。ソケットが良い例です。
- Transform Stream: 書き込まれたデータを読み取る際に、そのデータを変換または変更できるDuplex Streamの一種です。例としては、圧縮/解凍のためのzlibストリームが挙げられます。
- Piping: Readable Streamの出力をWritable Streamの入力に接続する、基本的なストリームの概念です。これにより、データは中間でバッファリングすることなく、効率的に1つのストリームから別のストリームに直接流れることができます。
source.pipe(destination)が一般的な構文です。 - Bussboy (Fastify): Fastify用に特別に設計された、非常にパフォーマンスの高いmultipart/form-dataパーサーで、ファイルアップロードの処理によく使用されます。
- Multer (Express): Express.js用のミドルウェアで、
multipart/form-dataを処理するために使用され、主にファイルアップロードに使用されます。Multerはストリームを処理できますが、デフォルトの動作では多くの場合、ファイルをディスクに完全にバッファリングするため、非常に大きなファイルに対して純粋にストリームベースのアプローチよりも効率が悪い場合があります。
効率的なファイルアップロード
Multerのようなミドルウェアを使用したファイルアップロードの従来の処理方法は、多くの場合、まずファイル全体を一時的なディスクの場所に保存するか、メモリにバッファリングすることさえ含みます。小さいファイルには便利ですが、大きいファイルにとってはボトルネックになる可能性があります。ストリームベースのアップロードにより、到着したときにファイルチャンクごとに処理または保存できます。
Express.jsでのストリームを使用したアップロード
Expressの場合、カスタムミドルウェアとbusboy(Node.jsのネイティブmultipart/form-dataパーサー。Fastifyのbusboyライブラリではありません)のようなライブラリを組み合わせるか、受信リクエストストリームを直接処理できます。より構造化されたアプローチのためにbusboyを使用した例を見てみましょう。
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'); // アップロードディレクトリが存在することを確認 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'); });
このExpressの例では、req.pipe(busboy)が鍵となります。受信HTTPリクエスト(Readable Streamです)は、busboyに直接パイプされます。busboyがマルチパートデータを解析すると、アップロードされたファイルのfileストリームを提供するfileイベントが発行されます。このfileストリームは、ファイル全体をメモリにバッファリングすることなくディスクに保存するために、直接fs.createWriteStreamにパイプされます。
FastifyでのStreamを使用したアップロード
Fastifyはストリームをネイティブにサポートしており、そのエコシステムはパフォーマンスに重点を置いています。fastify-multipartプラグインは、ファイルアップロードを効率的に処理するために、内部でbusboy(Fastify固有のもの)を使用しています。
const fastify = require('fastify'); const fs = require('fs'); const path = require('path'); const pump = require('pump'); // エラーハンドリング付きストリームパイプのユーティリティ const app = fastify({ logger: true }); const uploadDir = path.join(__dirname, 'uploads'); // アップロードディレクトリが存在することを確認 if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir); } app.register(require('@fastify/multipart'), { limits: { fileSize: 10 * 1024 * 1024 // 例として10MBの制限 } }); app.post('/upload', async (request, reply) => { const data = await request.file(); // ファイルストリームを取得 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}`); });
Fastifyの例では、request.file()は非同期的にファイルデータを取得します。このデータ自体にはReadable fileストリームが含まれています。次にpumpを使用して、この受信ファイルストリームをfs.createWriteStreamに安全にパイプします。pumpはストリームのクローズとエラーの伝播を正しく処理するため、ストリームパイプをより堅牢にし、特に役立ちます。このアプローチにより、ファイルは増分的にディスクに処理および書き込まれます。
効率的なファイルダウンロード
大きなファイルをダウンロード用に提供することも、ストリームから多大な恩恵を受けます。ファイル全体をサーバーメモリにロードしてから送信するのではなく、ファイルからReadable Streamを作成し、それをHTTPレスポンスに直接パイプできます。
Express.jsでのストリームを使用したダウンロード
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'); // テストダウンロード用にダミーの大きなファイルを作成 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のファイル 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.'); } // ダウンロードに適切なヘッダーを設定 res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); const fileStream = fs.createReadStream(filePath); // ストリームでのエラーハンドリング fileStream.on('error', (err) => { console.error('Error reading file for download:', err); res.status(500).send('Could not retrieve file.'); }); fileStream.pipe(res); // ファイルストリームをレスポンスに直接パイプ }); app.listen(3000, () => { console.log('Express Download Server listening on port 3000'); });
ここでは、fs.createReadStream(filePath)でファイルからのReadable Streamが作成されます。このストリームは、HTTPレスポンスオブジェクト(Writable Streamです)であるresに直接パイプされます。これは、ファイルチャンクがディスクから読み取られるにつれて、ファイル全体をサーバーのメモリにバッファリングすることなく、クライアントに即座に送信されることを意味します。これは大きなファイルに非常に効率的であり、クライアント側のプログレスインジケーターとうまく連携します。
Fastifyでのストリームを使用したダウンロード
FastifyのreplyオブジェクトもWritable Streamとして機能するため、ストリームベースのダウンロードは簡単です。
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'); // テストダウンロード用にダミーの大きなファイルを作成 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のファイル 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.'); } // ダウンロードに適切なヘッダーを設定 reply.header('Content-Type', 'application/octet-stream'); reply.header('Content-Disposition', `attachment; filename="${filename}"`); const fileStream = fs.createReadStream(filePath); // 堅牢なパイプとエラーハンドリングのためにpumpを使用 pump(fileStream, reply.raw, (err) => { if (err) { request.log.error('Error during file download:', err); // ヘッダーが既に送信されている場合、エラー状態を送信するには遅すぎる可能性があります // エラーをログに記録し、接続を閉じることを検討してください。 } 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}`); });
Expressと同様に、fs.createReadStream(filePath)でReadable Streamが作成されます。Fastifyのreply.rawは、基盤となるNode.js http.ServerResponseオブジェクト(Writable Streamです)へのアクセスを提供します。次にpumpを使用してファイルストリームをreply.rawにパイプし、効率的なデータ転送と堅牢なエラーハンドリングを保証します。
結論
ExpressおよびFastifyでNode.js Streamをファイルアップロードとダウンロードに活用することは、特に大きなファイルを扱う際に、非常に効率的でスケーラブルなソリューションを提供します。ファイル全体をメモリにバッファリングするのではなくチャンクでデータを処理することにより、アプリケーションはメモリフットプリントを大幅に削減し、パフォーマンスを向上させ、ユーザーエクスペリエンスを強化できます。ストリームベースのアプローチを採用することは、Node.js Webアプリケーションでパフォーマンスが高く回復力のあるファイル処理機能を作成するための重要なステップです。このエレガントな配管により、リソース効率の高いデータフローが可能になり、アプリケーションがより堅牢でスケーラブルになります。

