NodeJS 速習チュートリアル

Node.js child_process モジュール`

1. child_process モジュールとは?

child_process モジュールは、Node.js に組み込まれている標準モジュールであり、子プロセス(Child Process)を作成・管理する機能を提供します。
これを使用することで、外部コマンドを実行したり、サブプロセス インスタンスと通信したりすることが可能になります。

この機能は、以下のようなタスクにおいて非常に重要です:

  • Node.js アプリケーションからシステムコマンドを実行する
  • CPU 負荷の高い(CPU-intensive)タスクを別プロセスで実行する
  • 複数のプロセスを並列(Parallel)に実行し、マルチコア CPU を有効活用する
  • 外部プログラムやスクリプトとインターフェースを取る

2. child_process モジュールのインポート

child_process モジュールは Node.js に標準で含まれています。
スクリプト内で require することで使用できます:

const childProcess = require('child_process');

// またはデストラクチャリングを使用して特定のメソッドにアクセスする
const { exec, spawn, fork } = require('child_process');

3. 子プロセスを作成するためのメソッド

child_process モジュールは、子プロセスを作成・管理するための主要な 4 つのメソッドを提供しています。それぞれ挙動やユースケースが異なります:

メソッド説明ユースケース
exec()シェルを起動してコマンドを実行し、出力をバッファリングするシェルコマンドを実行し、一度にすべての出力を取得したい場合
execFile()exec() に似ているがシェルを起動しないシェルの解釈を介さず、ファイルベースのコマンドを効率的に実行する場合
spawn()新しいプロセスを起動し、ストリーミング I/O を提供する(シェルは作成しない)長時間実行されるプロセスや、大量の出力を扱う場合
fork()Node.js プロセスを作成するための spawn() の特殊なケース別の Node.js モジュールを IPC 通信が可能な別プロセスとして実行する場合

4. exec() メソッド

exec() メソッドはシェルを作成し、そのシェル内でコマンドを実行します。
出力全体をバッファリングし、コマンド完了時にコールバックを介して結果を提供します。

const { exec } = require('child_process');

// 'ls -la' コマンドを実行(Windows の場合は 'dir')
const command = process.platform === 'win32' ? 'dir' : 'ls -la';

exec(command, (error, stdout, stderr) => {
  if (error) {
    console.error(`コマンド実行エラー: ${error.message}`);
    return;
  }

  if (stderr) {
    console.error(`コマンド標準エラー出力: ${stderr}`);
  }

  console.log(`コマンド出力:\n${stdout}`);
});

// オプションを指定する場合
exec('echo $HOME', {
  env: { HOME: '/custom/home/directory' }
}, (error, stdout, stderr) => {
  console.log(`カスタムホームディレクトリ: ${stdout.trim()}`);
});

       警告: サニタイズ(Sanitize)されていないユーザー入力を exec() に渡さないでください。シェル構文をフルに実行するため、コマンドインジェクション攻撃を受ける危険性があります。

5. Promise を使用した exec()

コールバックの代わりに Promise ラッパーを使用して処理する方法です:

const { exec } = require('child_process');
const util = require('util');

// exec を Promise ベースの関数に変換
const execPromise = util.promisify(exec);

async function executeCommand(command) {
  try {
    const { stdout, stderr } = await execPromise(command);

    if (stderr) {
      console.error(`コマンド標準エラー出力: ${stderr}`);
    }

    console.log(`コマンド出力:\n${stdout}`);
    return stdout;
  } catch (error) {
    console.error(`コマンド実行エラー: ${error.message}`);
    throw error;
  }
}

// Promise ベースの関数を使用
executeCommand('node --version')
  .then(version => console.log(`Node.js バージョン: ${version.trim()}`))
  .catch(err => console.error('Node.js バージョンの取得に失敗しました'));

6. execFile() メソッド

execFile() メソッドは exec() と似ていますが、シェルを起動しません。
外部バイナリを直接実行するため、より効率的です。

const { execFile } = require('child_process');

// 引数を指定して 'node' を実行
execFile('node', ['--version'], (error, stdout, stderr) => {
  if (error) {
    console.error(`ファイルの実行エラー: ${error.message}`);
    return;
  }

  console.log(`Node.js バージョン: ${stdout.trim()}`);
});

// Windows でバッチファイルを実行する場合
if (process.platform === 'win32') {
  execFile('C:\\Windows\\System32\\cmd.exe', ['/c', 'echo Hello from batch!'], (error, stdout, stderr) => {
    if (error) {
      console.error(`エラー: ${error.message}`);
      return;
    }

    console.log(`出力: ${stdout.trim()}`);
  });
}

注意: execFile() はシェルのメタ文字を処理しないため、ユーザー入力を含むコマンドを実行する際に exec() よりもセキュアです。

7. spawn() メソッド

spawn() メソッドは、指定されたコマンドで新しいプロセスを起動します。
exec() とは異なり、出力をバッファリングせず、stdout および stderr へのストリームベースのアクセスを提供します。

const { spawn } = require('child_process');

// ファイルリストを表示するプロセスを起動
const ls = process.platform === 'win32'
  ? spawn('cmd', ['/c', 'dir'])
  : spawn('ls', ['-la']);

// プロセスの出力ストリームをハンドル
ls.stdout.on('data', (data) => {
  console.log(`標準出力 (stdout): ${data}`);
});

ls.stderr.on('data', (data) => {
  console.error(`標準エラー出力 (stderr): ${data}`);
});

ls.on('close', (code) => {
  console.log(`子プロセスがコード ${code} で終了しました`);
});

// オプションを指定して起動
const grep = spawn('grep', ['hello', 'input.txt'], {
  cwd: '/tmp', // ワークディレクトリ
  env: { ...process.env, CUSTOM_ENV: 'value' },
  stdio: 'pipe', // stdio の設定
  detached: false, // プロセスグループの挙動
  shell: false // シェルで実行するかどうか
});

// エラーハンドリング
grep.on('error', (err) => {
  console.error(`サブプロセスの起動に失敗しました: ${err.message}`);
});

7.1 spawn() を使用すべきケース

spawn() は、特に以下のような場合に有用です:

  • 長時間実行されるプロセス(サーバープロセスやウォッチャーなど)
  • 大量のデータを出力するプロセス
  • 完了を待つのではなく、データが生成されるたびにリアルタイムで処理したい場合

8. stdin を使用した spawn()

const { spawn } = require('child_process');

// 標準入力 (stdin) から読み取るプロセスを起動
const process = spawn('wc', ['-w']); // 単語カウント (Word count)

// プロセスの標準入力にデータを送信
process.stdin.write('Hello world from Node.js!');
process.stdin.end(); // 入力の終了をシグナル

// 出力をキャプチャ
process.stdout.on('data', (data) => {
  console.log(`単語数: ${data}`);
});

9. fork() メソッド

fork() メソッドは、Node.js プロセスを作成するために特化した spawn() の一種です。親プロセスと子プロセスの間でメッセージを送受信できる IPC(Interprocess Communication:プロセス間通信)チャネルをセットアップします。

メインファイル (parent.js)

const { fork } = require('child_process');

// 子プロセスをフォーク
const child = fork('child.js');

// 子プロセスにメッセージを送信
child.send({ message: '親プロセスからの挨拶' });

// 子プロセスからのメッセージを受信
child.on('message', (message) => {
  console.log('子プロセスからのメッセージ:', message);
});

// 子プロセスの終了をハンドル
child.on('close', (code) => {
  console.log(`子プロセスがコード ${code} で終了しました`);
});

子プロセスファイル (child.js)

console.log('子プロセスが開始されました。PID:', process.pid);

// 親プロセスからのメッセージを待機
process.on('message', (message) => {
  console.log('親プロセスからのメッセージ:', message);

  // 親プロセスに返信を送信
  process.send({ response: '子プロセスからの挨拶' });

  // 3秒後にプロセスを終了
  setTimeout(() => {
    process.exit(0);
  }, 8080);
});

9.1 fork() のメリット

  • 各フォークされたプロセスは独自の V8 インスタンスとメモリを持ちます。
  • CPU 負荷の高いワークフローをメインのイベントループから分離できます。
  • メッセージを介してプロセス間通信が可能です。
  • マルチコア CPU のリソースを最大限に活用するのに役立ちます。

10. プロセス間通信 (IPC)

fork() で作成された子プロセスは、send() メソッドと message イベントを使用し、組み込みの IPC チャネルを通じて親プロセスと通信できます。

10.1 複雑なデータの送信

parent.js

const { fork } = require('child_process');
const child = fork('worker.js');

// 様々なタイプのデータを送信
child.send({
  command: 'compute',
  data: [1, 2, 3, 4, 5],
  options: {
    multiply: 2,
    subtract: 1
  }
});

// 結果を受信
child.on('message', (result) => {
  console.log('計算結果:', result);
  child.disconnect(); // IPC チャネルをクリーンアップ
});

worker.js

process.on('message', (msg) => {
   if (msg.command === 'compute') {
    const result = msg.data.map(num => num * msg.options.multiply - msg.options.subtract);

    // 結果を親プロセスに送信
    process.send({ result });
  }
});

注意: メッセージは JSON を使用してシリアル化(Serialization)されるため、JSON 互換のデータ(オブジェクト、配列、文字列、数値、真偽値、null)のみ送信可能です。

11. 子プロセスの管理

11.1 子プロセスの停止(キル)

const { spawn } = require('child_process');

// 長時間実行されるプロセスを起動
const child = spawn('node', ['-e', `
  setInterval(() => {
    console.log('稼働中...', Date.now());
  }, 1000);
`]);

// プロセスの出力を表示
child.stdout.on('data', (data) => {
  console.log(`stdout: ${data}`);
});

// 5秒後にプロセスを停止
setTimeout(() => {
  console.log('子プロセスを停止します...');

  // SIGTERM シグナルを送信
  child.kill('SIGTERM');

  // 代替案: child.kill() - デフォルトで SIGTERM を使用
}, 5000);

// exit イベントをハンドル
child.on('exit', (code, signal) => {
  console.log(`子プロセスがコード ${code}、シグナル ${signal} で終了しました`);
});

11.2 デタッチされたプロセス (Detached Processes)

親プロセスから独立して実行され続ける「デタッチ(分離)された子プロセス」を作成することも可能です:

const { spawn } = require('child_process');
const fs = require('fs');

// デタッチされたプロセスを作成
const child = spawn('node', ['long_running_script.js'], {
  detached: true,
  stdio: ['ignore',
    fs.openSync('output.log', 'w'),
    fs.openSync('error.log', 'w')
  ]
});

// 子プロセスをアンレフ (unref) して、親プロセスが独立して終了できるようにする
child.unref();

console.log(`デタッチされたプロセスを起動しました。PID: ${child.pid}`);
console.log('親プロセスは終了しますが、子プロセスは実行を継続します。');

12. 実践的な例

12.1 シンプルなタスクキューの作成

tasks.js (親)

const { fork } = require('child_process');
const numCPUs = require('os').cpus().length;

class TaskQueue {
  constructor() {
    this.tasks = [];
    this.workers = [];
    this.maxWorkers = numCPUs;
  }

  addTask(task) {
    this.tasks.push(task);
    this.runNext();
  }

  runNext() {
    if (this.tasks.length > 0 && this.workers.length < this.maxWorkers) {
      const task = this.tasks.shift();
      const worker = fork('worker.js');

      console.log(`タスク ${task.id} のためのワーカーを開始します`);
      this.workers.push(worker);

      worker.send(task);

      worker.on('message', (result) => {
        console.log(`タスク ${task.id} が完了しました。結果:`, result);

        // リストからワーカーを削除
        this.workers = this.workers.filter(w => w !== worker);

        // 次のタスクがあれば実行
        this.runNext();
      });

      worker.on('error', (err) => {
        console.error(`タスク ${task.id} のワーカーでエラーが発生しました:`, err);
        this.workers = this.workers.filter(w => w !== worker);
        this.runNext();
      });

      worker.on('exit', (code) => {
        if (code !== 0) {
          console.error(`タスク ${task.id} のワーカーがコード ${code} で終了しました`);
        }
      });
    }
  }
}

// 使用例
const queue = new TaskQueue();

for (let i = 1; i <= 10; i++) {
  queue.addTask({
    id: i,
    type: 'calculation',
    data: Array.from({ length: 1000000 }, () => Math.random())
  });
}

worker.js

process.on('message', (task) => {
  console.log(`ワーカー ${process.pid} がタスク ${task.id} を受信しました`);

  let result;
  if (task.type === 'calculation') {
    // 例:合計と平均を算出
    const sum = task.data.reduce((acc, val) => acc + val, 0);
    const avg = sum / task.data.length;
    result = { sum, avg };
  }

  // 親プロセスに結果を送信
  process.send({ taskId: task.id, result });

  // ワーカーを終了
  process.exit(0);
});

12.2 外部アプリケーションの実行(ビデオ変換)

const { spawn } = require('child_process');
const fs = require('fs');

function convertVideo(inputFile, outputFile, options = {}) {
  return new Promise((resolve, reject) => {
    if (!fs.existsSync(inputFile)) {
      return reject(new Error(`入力ファイル ${inputFile} が存在しません`));
    }

    // ffmpeg の引数を準備
    const args = ['-i', inputFile];
    if (options.scale) args.push('-vf', `scale=${options.scale}`);
    if (options.format) args.push('-f', options.format);
    args.push(outputFile);

    // ffmpeg プロセスを起動
    const ffmpeg = spawn('ffmpeg', args);

    let stdoutData = '';
    let stderrData = '';

    ffmpeg.stdout.on('data', (data) => { stdoutData += data; });
    ffmpeg.stderr.on('data', (data) => { stderrData += data; });

    ffmpeg.on('close', (code) => {
      if (code === 0) {
        resolve({ inputFile, outputFile, stdout: stdoutData, stderr: stderrData });
      } else {
        reject(new Error(`ffmpeg がコード ${code} で終了しました\n${stderrData}`));
      }
    });

    ffmpeg.on('error', reject);
  });
}

13. ベストプラクティス

  • 入力のサニタイズ: コマンドインジェクションを防ぐため、ユーザー入力は必ずサニタイズしてください。特に exec() の使用時には細心の注意が必要です。
  • リソース管理: 子プロセスが使用するリソース(メモリ、ファイルデスクリプタ)を監視・管理してください。
  • エラーハンドリング: 子プロセスには常に適切なエラー処理を実装してください。
  • 適切なメソッドの選択:
    • 出力が限定的な単純なコマンドには exec()
    • 長時間実行や大量の出力には spawn()
    • CPU 負荷の高い Node.js の操作には fork()
  • クリーンアップ: 不要になった子プロセスは適切にキル(停止)してください。
  • 同時実行数の制限: システムのオーバーロードを避けるため、同時に起動する子プロセスの数を制御してください。

       警告: あまりに多くの子プロセスを実行すると、システムリソースを急速に枯渇させる可能性があります。必ずレート制限(Rate Limiting)と同時実行制御を実装しましょう。

14. セキュリティに関する考慮事項

  • コマンドインジェクション: サニタイズされていないユーザー入力を exec()spawn() に直接渡さないでください。
  • 環境変数: 子プロセスに渡す環境変数の取り扱いには注意してください。
  • ファイルアクセス: 子プロセスが持つ権限を正しく把握してください。
  • リソース制限: 子プロセスに対してタイムアウトやメモリ制限の実装を検討してください。