はじめに
Node.jsで数ギガバイトにもなるような大容量ファイルを扱う際、最も一般的な課題はメモリ不足です。fs.readFile() のような便利なメソッドは、ファイル全体を一度にメモリに読み込もうとするため、ファイルサイズが大きくなると簡単にアプリケーションがクラッシュしてしまいます。
この問題を解決するのが「Streams(ストリーム)」です。ストリームは、データを一度にすべて読み込むのではなく、小さなチャンク(塊)に分割して少しずつ処理するための仕組みです。これにより、使用するメモリ量を一定に保ちながら、どれだけ大きなデータでも安定して処理できます。
この記事では、Node.js Streamsの基本的な概念から、実践的なファイル操作、データ加工までを具体的なコード例と共に解説していきます。ストリームを使いこなし、パフォーマンスの高いアプリケーション開発を目指しましょう。
Node.js Streamsとは?4つの基本タイプ
ストリームを理解する最も簡単な方法は、水の流れをイメージすることです。蛇口から出た水がパイプを通り、最終的にバケツに溜まるように、データも一連の流れとして扱うことができます。Node.jsには、このデータの流れを制御するための4つの基本的なストリームタイプが存在します。
- Readable Streams (読み込み可能ストリーム): データを読み込むためのストリームです。ファイルやHTTPリクエストなどがデータソースとなります。蛇口に相当します。
- Writable Streams (書き込み可能ストリーム): データを受け取って書き込むためのストリームです。ファイルやHTTPレスポンスなどが書き込み先となります。バケツに相当します。
- Duplex Streams (デュプレックスストリーム): 読み込みと書き込みの両方が可能なストリームです。TCPソケットなどが代表例です。
- Transform Streams (変換ストリーム): データを読み込み、加工・変換してから次のストリームに渡す特殊なDuplexストリームです。データの圧縮(zlib)や暗号化(crypto)などがこれにあたります。パイプの途中にある浄水器のようなものです。
これらのストリームを組み合わせることで、複雑なデータ処理パイプラインを効率的に構築できます。
実践!Readable Streamでファイルを読み込む
まずは最も基本的なReadable Streamを使って、ファイルの内容を少しずつ読み込んでみましょう。Node.jsのfsモジュールには、ストリームを生成するためのcreateReadStream()メソッドが用意されています。
以下のコードは、large-file.txtというファイルをチャンク単位で読み込み、コンソールに出力する例です。ファイル全体をメモリに保持するのではなく、小さなデータチャンクが利用可能になるたびに'data'イベントが発火します。すべてのデータを読み込み終えると'end'イベントが、エラーが発生した場合は'error'イベントが発火します。
const fs = require('fs');
const readableStream = fs.createReadStream('large-file.txt', { encoding: 'utf8' });
// データチャンクが読み込まれるたびに実行
readableStream.on('data', (chunk) => {
console.log('--- New Chunk ---');
console.log(chunk.substring(0, 100)); // チャンクの先頭100文字だけ表示
});
// ファイルの読み込みが完了したときに実行
readableStream.on('end', () => {
console.log('Finished reading file.');
});
// エラーが発生したときに実行
readableStream.on('error', (error) => {
console.error(`Error: ${error.message}`);
});この方法なら、たとえファイルが10GBあっても、アプリケーションが使用するメモリはごくわずかで済みます。これがストリームの最大の利点です。
パイプ(pipe)でストリームを繋ぐ
ストリームの真価は、複数のストリームを連結することで発揮されます。そのための強力なメソッドが.pipe()です。readableStream.pipe(writableStream)のように記述することで、読み込みストリームからの出力を自動的に書き込みストリームの入力へと流し込むことができます。
以下の例では、input.txtを読み込むReadable Streamを作成し、その内容をoutput.txtに書き込むWritable Streamに.pipe()で接続しています。これにより、効率的なファイルコピーが実現できます。
const fs = require('fs');
const readableStream = fs.createReadStream('input.txt');
const writableStream = fs.createWriteStream('output.txt');
// 読み込みストリームを書き込みストリームにパイプする
readableStream.pipe(writableStream);
writableStream.on('finish', () => {
console.log('File copied successfully.');
});
readableStream.on('error', (error) => {
console.error(`Error reading file: ${error.message}`);
});
writableStream.on('error', (error) => {
console.error(`Error writing file: ${error.message}`);
});.pipe()メソッドは、データの流量を自動的に調整する「背圧(backpressure)」という仕組みも内蔵しています。書き込みが追いつかない場合、読み込み速度を自動的に落としてくれるため、開発者は複雑な流量制御を意識する必要がなく、非常にシンプルで信頼性の高いコードを書くことができます。
応用例: Transform StreamでCSVデータを加工する
Transform Streamを使えば、データの流れの途中で加工処理を挟むことができます。例えば、大容量のCSVファイルから特定の列だけを抽出する、といった処理に最適です。
streamモジュールのTransformクラスを継承し、_transform()メソッドを実装することで、独自の変換ストリームを作成できます。このメソッドは、上流のストリームからチャンクを受け取り、加工した結果をthis.push()で下流に流します。
以下の例では、CSVの各行をパースし、2列目のデータだけを抽出する簡単なTransform Streamを作成しています。これを読み込みストリームと書き込みストリームの間に.pipe()で挟むことで、データ加工パイプラインを構築します。
const fs = require('fs');
const { Transform } = require('stream');
// 特定の列を抽出するTransform Stream
const columnExtractor = new Transform({
transform(chunk, encoding, callback) {
const lines = chunk.toString().split('\n');
for (const line of lines) {
const columns = line.split(',');
// 2列目(インデックス1)が存在すればプッシュする
if (columns.length > 1) {
this.push(columns[1] + '\n');
}
}
callback();
}
});
fs.createReadStream('large-data.csv')
.pipe(columnExtractor)
.pipe(fs.createWriteStream('extracted-column.txt'));
console.log('CSV processing started...');注意: この例は簡略化のため、チャンクの境界でCSVの行が分断されるケースを考慮していません。実用的なCSVパーサーでは、そうした境界ケースを適切に処理する必要があります。
まとめ
この記事では、Node.js Streamsの基本から応用までを解説しました。ストリームを使えば、メモリ使用量を抑えながら大容量データを効率的に処理できることをご理解いただけたかと思います。
重要なポイントを振り返りましょう。
- メモリ効率: ファイル全体をメモリに読み込まず、小さなチャンクで処理するため、メモリ消費を最小限に抑えられます。
- コンポーザビリティ:
.pipe()メソッドを使うことで、読み込み、変換、書き込みといった処理を柔軟に組み合わせ、宣言的なデータ処理パイプラインを構築できます。 - パフォーマンス: 流量を自動制御する背圧の仕組みにより、安定したデータ転送が可能です。
ファイル処理だけでなく、HTTPリクエスト/レスポンス、データの圧縮・解凍(zlib)、暗号化(crypto)など、Node.jsの多くのコアモジュールがストリームベースで設計されています。ストリームをマスターすることは、より高度でパフォーマンスの高いNode.jsアプリケーションを開発するための重要なステップです。