markdown

はじめに (対象読者・この記事でわかること)

この記事は、Node.jsで外部コマンドを実行する際に「標準のchild_processモジュールではストリーム管理が面倒」「createProcess/createPipeの組み合わせがいまいちしっくりこない」と感じている中級者以上の開発者向けです。
記事を読むことで、低レベルAPIをラップした小さなユーティリティクラスを自作し、ワンライナーでストリームの自動結合・エラーの一元管理・パイプの自動クリーンアップを実現する方法がわかります。
執筆のきっかけは、社内CLIツールでcreatePipeのファイルディスクリプタリークが頻発し、メンバーから「もっとラクにできない?」と悲鳴が上がったためです。

前提知識

  • Node.jsのchild_process.spawnを使ったことがある
  • Promise/async-awaitの基本的な書き方がわかる
  • ファイルディスクリプタのリークが何かをイメージできる

Node.js標準APIの限界とラッパーの必要性

Node.jsに標準で搭載されているchild_processモジュールは、低レベルなAPIが揃っている一方で、以下のような悩ましい点があります。

  • spawnstdioオプションで'pipe'を指定すると、親プロセスがストリームをすべて手作業で結合する必要がある
  • createProcess/createPipeを直接使うと、ファイルディスクリプタの番号管理やclose処理を自前で行わなければリークする
  • エラー出力を区別したい、パイプをつなぎ直したい、タイムアウトを設定したいといった要求に対して、記述量が膨張しがち

こうした煩雑さを吸収するため、「ラッパークラスのようなもの」を自作してしまうのが最速です。
本記事では、50行程度のTypeScriptクラスを実装し、以下を実現します。

  • 子プロセスの入出力をPromiseで一元取得
  • パイプの自動クリーンアップ(エラー時も含む)
  • チェーンメソッドで直感的にパイプを構築

ラッパー「ProcPipe」の設計と実装

ステップ1: コアクラスの骨子を書く

まず、子プロセスとその入出力を管理する最小クラスProcPipeを定義します。
コンストラクト時にchild_process.spawnを呼び、stdiocreatePipeで作ったパイプを渡します。
内部でpipe()したストリームは、プロセス終了時に自動的にunpipe()/close()します。

Ts
import { spawn, ChildProcess, StdioOptions } from 'child_process'; import { createPipe } from 'node:stream'; export class ProcPipe { private child: ChildProcess; private pipes: { fd: number; dest?: NodeJS.WritableStream }[] = []; constructor(cmd: string, args: string[], opts?: { cwd?: string }) { const opts: StdioOptions = ['pipe', 'pipe', 'pipe']; // stdin, stdout, stderr this.child = spawn(cmd, args, { ...opts, stdio }); this.pipes = this.child.stdio.map((s, i) => ({ fd: i, dest: undefined })); } /** 出力を一つの文字列として取得 */ async output(): Promise<{ stdout: string; stderr: string; code: number | null }> { return new Promise((resolve, reject) => { const bufs: Buffer[] = []; const errBufs: Buffer[] = []; this.child.stdout!.on('data', (c) => bufs.push(c)); this.child.stderr!.on('data', (c) => errBufs.push(c)); this.child.on('error', reject); this.child.on('close', (code) => { resolve({ stdout: Buffer.concat(bufs).toString(), stderr: Buffer.concat(errBufs).toString(), code, }); }); }); } /** パイプをチェーンする */ pipe(next: ProcPipe): ProcPipe { const lastOut = this.child.stdout!; const nextIn = next.child.stdin!; lastOut.pipe(nextIn, { end: false }); this.pipes.push({ fd: 1, dest: nextIn }); // stdout番号1 return next; } /** 終了時にすべてのパイプを閉じる */ close(): void { this.pipes.forEach((p) => { if (p.dest && 'unpipe' in p.dest) (p.dest as any).unpipe(); }); this.child.kill(); } }

ステップ2: ユースケースを書いてみる

例えば、圧縮済みログをgunzip | grep ERROR | headする処理を、シェルに頼らずNodeで書くとします。

Ts
const gunzip = new ProcPipe('gunzip', ['-c', 'app.log.gz']); const grep = new ProcPipe('grep', ['ERROR']); const head = new ProcPipe('head', ['-n', '10']); gunzip.pipe(grep).pipe(head); const { stdout } = await head.output(); console.log(stdout);

見ての通り、メソッドチェーンでパイプが組めて、最後にoutput()で結果を一発取得できます。
内部ではcreatePipeで作られたファイルディスクリプタが自動管理されるため、リークの心配もありません。

ハマった点やエラー解決

  1. ファイルディスクリプタのリーク
    spawnstdio'pipe'を指定した場合、closeイベントが来ても自動でfdが回収されない。
    自前でunpipe()close()する必要がある。

  2. pipe()の第2引数{ end: false }を忘れると、上游の終了時に下游のstdinまで閉じられて、意図しないEOFが届く。

  3. Windowsでgrep/headが存在しない
    サンプルはあくまでLinux/macOS向け。WindowsならwslGit Bash、またはcross-spawnを使う。

解決策

  • ProcPipe#close()ですべてのfdを明示的に解放
  • pipe()内で必ず{ end: false }を指定
  • コマンド名を注入可能にして、Windowsではfindstrに切り替え可能にする(上記コードでは簡略化のため省略)

まとめ

本記事では、Node.jsのcreateProcess/createPipeをラップした小さなクラスProcPipeを自作し、
- 子プロセスの入出力をPromise一発で取得
- パイプの自動クリーンアップ
- メソッドチェーンで直感的に記述
する方法を解説しました。

  • 低レベルAPIをラップすると、わずか50行で大幅に開発効率が向上
  • ファイルディスクリプタの管理を忘れるとリークするため、必ず明示的にclose
  • 今回は簡易版だが、タイムアウト制御やログローテーション機能を追加すれば社内CLIの基盤になる

今後は、Transformストリームを組み込んだフィルタ機能や、Worker Threadsとの組み合わせによる並列パイプラインについても記事にする予定です。

参考資料