ウェブエンジニア問題集
第9章

StreamとBuffer入門 — 大きなデータとバイナリを少しずつ処理する仕組み

15
この章の目次開く

Node.jsでファイル、HTTP通信、画像、CSV、zip、ログのようなデータを扱っていると、StreamBuffer という言葉に必ず出会います。

どちらも「大きなデータ」や「文字列ではないデータ」を扱うための仕組みです。小さなJSONだけを扱っている間は意識しなくても済みますが、ファイルアップロード、画像処理、ログ出力、HTTPレスポンスの中身を扱い始めると避けて通れません。

Bufferはバイナリデータをメモリ上で扱う入れ物、Streamはデータを小分けに流して処理する仕組みです。この2つを分けて理解すると、Node.jsのI/O処理がかなり読みやすくなります。

学習者学習者

fs.readFile() でファイルを読んだら、文字列じゃなくて Buffer って表示されました。Streamもよく見るし、どっちも「何かデータっぽいもの」くらいにしか理解できていません。

先生先生

まずは「Bufferはデータそのもの」「Streamはデータの流し方」と分けて考えるといいよ。ペットボトルの水がBuffer、蛇口やホースがStream、くらいのイメージだね。

Bufferとは — バイナリデータを入れるメモリ領域

JavaScriptの文字列はテキストを扱うための型です。一方で、コンピュータが実際に扱うファイルや通信データは、最終的にはバイト列です。画像、PDF、zip、音声、暗号化されたデータなどは、そのままではJavaScriptの文字列として表せません。

Node.jsの Buffer は、このようなバイト列を扱うためのオブジェクトです。

const buffer = Buffer.from('Node.js', 'utf8');
 
console.log(buffer);
console.log(buffer.toString('utf8')); // Node.js
js

console.log(buffer) の出力は環境によって少し違いますが、<Buffer 4e 6f 64 65 2e 6a 73> のように16進数のバイト列として表示されます。4e6f は、人間が読む文字ではなく、コンピュータが扱う1バイトごとの値です。

データを確認する人
文字列の裏側には、実際にはバイト列としてのデータがある

Buffer.from — 文字列や配列からBufferを作る

Buffer.from() は、文字列、配列、ArrayBufferなどから新しいBufferを作ります。Node.jsでBufferを試すときに最もよく使う入口です。

構文: Buffer.from(value, encodingOrOffset?, length?)

引数説明
valueBufferに変換する値。文字列、数値配列、ArrayBuffer、既存のBufferなど
encodingOrOffset(省略可)value が文字列なら文字エンコーディング。代表例は 'utf8''base64''hex'
length(省略可)ArrayBufferから作る場合に、読み取るバイト数を指定する

戻り値: 新しい Buffer オブジェクト

const textBuffer = Buffer.from('こんにちは', 'utf8');
const byteBuffer = Buffer.from([0x4e, 0x6f, 0x64, 0x65]);
 
console.log(textBuffer.length); // バイト数
console.log(byteBuffer.toString('utf8')); // Node
js

ここで注意したいのは、length文字数ではなくバイト数を表すことです。日本語のようなマルチバイト文字では、文字数とバイト数は一致しません。

const buffer = Buffer.from('あ', 'utf8');
 
console.log('あ'.length); // 1
console.log(buffer.length); // 3
js

Bufferのサイズを考えるときは、文字数ではなくバイト数で考えます。アップロードサイズ制限、メモリ使用量、通信量を扱うときに重要な感覚です。

buf.toString — Bufferを文字列に戻す

buf.toString() は、Bufferの中身を指定した文字エンコーディングで文字列に変換します。

構文: buf.toString(encoding?, start?, end?)

引数説明
encoding(省略可)文字列へ変換するときのエンコーディング。省略時は 'utf8'
start(省略可)変換を開始するバイト位置。省略時は 0
end(省略可)変換を終了するバイト位置。省略時はBufferの末尾

戻り値: 変換された文字列

const buffer = Buffer.from('Node.js入門', 'utf8');
 
console.log(buffer.toString()); // Node.js入門
console.log(buffer.toString('utf8', 0, 7)); // Node.js
js

toString() は便利ですが、「このBufferは本当にテキストなのか」を意識してください。画像やzipのBufferを toString('utf8') しても、人間が読める文字列にはなりません。

Streamとは — データを少しずつ流す仕組み

小さなファイルなら、丸ごとメモリに読み込んでも大きな問題にはなりません。

import { readFile } from 'node:fs/promises';
 
const data = await readFile('small.txt', 'utf8');
console.log(data);
js

しかし、数GBのログファイルや動画ファイルを丸ごと読み込むと、処理が遅くなるだけでなく、メモリ不足でプロセスが落ちる可能性があります。そこで使うのがStreamです。

Streamは、データを**小さな塊(chunk)**として順番に処理します。ファイル全体が読み終わるのを待たず、届いた分から処理できます。

Streamの強みは、データ全体を一度にメモリへ載せず、届いた分から処理できることです。

Streamの4種類

Node.jsのStreamは、大きく4種類に分けられます。

種類役割代表例
Readable読み取れるStreamファイル読み込み、HTTPリクエストの本文
Writable書き込めるStreamファイル書き込み、HTTPレスポンス
Duplex読み書きできるStreamTCPソケット
Transform読みながら変換して書き出すStreamgzip圧縮、CSV変換、文字列変換

最初は、Readableから読んで、Writableへ書くという流れを押さえれば十分です。

Readable Stream  ---- chunk ---->  Writable Stream

Transform は、その途中でデータを加工するStreamです。たとえば「読み込んだテキストを大文字にしてから別ファイルへ書く」「ログを1行ずつ加工する」「gzipで圧縮する」といった処理に使います。

fs.createReadStream — ファイルを少しずつ読む

fs.createReadStream() は、ファイルをReadable Streamとして開くAPIです。大きなファイルを一気に読み込まず、chunk単位で処理できます。

構文: createReadStream(path, options?)

引数説明
path読み込むファイルのパス。文字列、Buffer、URLなどを指定できる
options(省略可)読み込み設定。encodingstartendhighWaterMark などを指定できる

戻り値: fs.ReadStream。Readable Streamの一種

import { createReadStream } from 'node:fs';
 
const stream = createReadStream('access.log', {
  encoding: 'utf8',
});
 
stream.on('data', (chunk) => {
  console.log('受け取ったchunk:', chunk.length);
});
 
stream.on('end', () => {
  console.log('読み込み完了');
});
 
stream.on('error', (error) => {
  console.error('読み込み失敗:', error);
});
js

encoding: 'utf8' を指定すると、data イベントで受け取る chunk は文字列になります。指定しない場合、chunk はBufferです。

学習者学習者

つまり、Streamで流れてくる chunk の正体がBufferになることが多いんですね。BufferとStreamがセットで出てくる理由が少し見えてきました。

その通りです。Streamは「流し方」、Bufferは「流れてくるデータの入れ物」です。テキストとして扱いたいときは encoding を指定するか、Bufferを toString() します。

import { createReadStream } from 'node:fs';
 
const stream = createReadStream('access.log');
 
stream.on('data', (chunk) => {
  // encodingを指定していないのでchunkはBuffer
  const text = chunk.toString('utf8');
  console.log(text);
});
js

pipe — 読み込みと書き込みをつなぐ

Readable Streamから読み取ったデータを、そのままWritable Streamへ流すなら pipe() が使えます。

import { createReadStream, createWriteStream } from 'node:fs';
 
const reader = createReadStream('input.txt');
const writer = createWriteStream('output.txt');
 
reader.pipe(writer);
js

pipe() は、Readableから届いたchunkをWritableへ順番に渡します。コピー処理のように「読んで、そのまま書く」場面では非常に簡潔です。

構文: readable.pipe(destination, options?)

引数説明
destination書き込み先のWritable Stream
options(省略可)end を指定できる。end: false にすると、Readableが終わってもWritableを閉じない

戻り値: 渡した destination。複数の pipe() をつなぐために使える

reader
  .pipe(transformStream)
  .pipe(writer);
js

ただし、実務では pipe() だけで済ませるより、次の pipeline() を使うことが多いです。理由はエラー処理です。

pipeline — Stream処理を安全につなぐ

Stream処理では、読み込み元、変換処理、書き込み先のどこでもエラーが起こり得ます。pipe() だけでつなぐと、各Streamに個別に error ハンドラーを書く必要があり、漏れが起きやすくなります。

stream/promisespipeline() を使うと、複数のStreamをつなぎ、完了や失敗をPromiseとして扱えます。非同期処理の基本で学んだ try/catch と相性がよい書き方です。

構文: pipeline(...streams)

引数説明
...streamsつなぎたいStreamまたはIterable。読み込み元、必要ならTransform、最後に書き込み先を順に渡す

戻り値: すべてのStream処理が完了すると解決し、途中で失敗すると拒否される Promise<void>

import { createReadStream, createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
 
try {
  await pipeline(
    createReadStream('input.txt'),
    createWriteStream('output.txt'),
  );
 
  console.log('コピー完了');
} catch (error) {
  console.error('コピー失敗:', error);
}
js

新しくStream処理を書くなら、まず pipeline() を候補にします。完了待ちとエラー処理をPromiseとして扱えるため、async/await ベースのコードに自然に組み込めます。

Transform Stream — 流れの途中で加工する

Streamの途中でデータを加工したい場合は、Transform Streamを使います。ここでは node:streamTransform を使って、入力を大文字に変換してみます。

import { Transform } from 'node:stream';
import { createReadStream, createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
 
const upperCase = new Transform({
  transform(chunk, encoding, callback) {
    const text = chunk.toString('utf8').toUpperCase();
    callback(null, text);
  },
});
 
await pipeline(
  createReadStream('input.txt'),
  upperCase,
  createWriteStream('output.txt'),
);
js

transform()chunk はBufferで届くことが多いため、ここでも chunk.toString('utf8') が登場します。

構文: new Transform(options)

引数説明
optionsTransform Streamの設定。代表的には transform(chunk, encoding, callback) メソッドを指定する

戻り値: 新しい Transform Stream

transform(chunk, encoding, callback) の主な引数は次の通りです。

引数説明
chunk入力されたデータの塊。Bufferまたは文字列など
encodingchunk が文字列の場合のエンコーディング
callback処理完了を知らせる関数。callback(error, transformedChunk) の形で呼ぶ

戻り値: transform() 自体は値を返しません。変換結果は callback の第2引数で渡します

バックプレッシャー — 書き込み先が追いつかない問題

Streamが重要な理由は、メモリを節約できることだけではありません。もう1つ大事なのがバックプレッシャーです。

読み込みが速すぎて、書き込み先の処理が追いつかないことがあります。たとえば、ディスクから高速に読めるけれど、ネットワークへの送信が遅い場合です。このとき、読み込んだデータを無制限にメモリへ積むと、プロセスが重くなったり落ちたりします。

Streamの pipe()pipeline() は、書き込み先の処理能力を見ながら読み込み側を調整します。これがバックプレッシャーです。

Streamを使う価値は「少しずつ処理する」だけでなく、「相手が受け取れる速度に合わせる」ことにもあります。

readFileとStreamの使い分け

すべてのファイル処理をStreamで書く必要はありません。小さな設定ファイルや短いJSONなら、readFile() の方が簡単で読みやすいです。

場面選び方
小さな設定ファイルを読むreadFile() でよい
小さなJSONを読み込んで一度に処理するreadFile() でよい
大きなログファイルを処理するStreamを使う
ファイルをコピーするpipeline(createReadStream(), createWriteStream()) が候補
HTTPレスポンスとして大きなファイルを返すStreamを使う
画像やzipなどバイナリを扱うBufferとして扱う場面が多い
先生先生

「全部Streamにすべき」ではないよ。小さいデータは丸ごと読んだ方が単純。大きいデータ、終わりが読めないデータ、少しずつ届くデータでStreamが効いてくる。

実務では、まず「データサイズ」と「処理の形」を見ます。丸ごとメモリに載せても問題ないならシンプルなAPIを選び、サイズが大きい・継続的に届く・途中で加工しながら渡したいならStreamを検討します。

この章のまとめ

BufferとStreamは、Node.jsのI/O処理を支える基本です。

  • Bufferは、バイト列をメモリ上で扱うためのオブジェクト
  • Buffer.from() でBufferを作り、buf.toString() で文字列に戻せる
  • Streamは、データをchunk単位で少しずつ処理する仕組み
  • fs.createReadStream() でファイルをReadable Streamとして読める
  • pipe() はStream同士をつなぐ
  • 実務では、エラー処理まで含めて pipeline() が扱いやすい
  • 大きなデータやバイナリ処理では、BufferとStreamをセットで理解する

Streamを読むときは、「何が流れているか」と「どこからどこへ流れているか」を分けて見てください。何が流れているかがBufferや文字列、どこからどこへ流れているかがReadableやWritableです。

次章では、Node.jsでコマンドラインツールを作るために、process.argv、標準入力、標準出力、終了コードを見ていきます。

参考リンク

Node.jsクイズに挑戦するこの章で学んだNode.jsの知識を、4択クイズでアウトプットして定着させよう