Node.js 的 shell 腳本
你可以購買這本書的離線版本(HTML、PDF、EPUB、MOBI),並支援免費的線上版本。
(廣告,請不要封鎖。)

12 在子程序中執行 shell 命令



在本章節中,我們將探討如何透過模組 'node:child_process' 從 Node.js 執行 shell 指令。

12.1 本章節概觀

模組 'node:child_process' 有兩個版本的函式用於執行 shell 指令(在已衍生的子程序中)

我們將先探討 spawn(),再探討 spawnSync()。最後,我們將檢視以下基於它們且相當類似的函式

12.1.1 Windows 與 Unix

本章節中顯示的程式碼會在 Unix 上執行,但我也在 Windows 上測試過,大部分程式碼只需做些微更動(例如將結尾換行符號改為 '\r\n' 而不是 '\n')即可執行。

12.1.2 範例中我們常使用的功能

以下功能常出現在範例中。因此,在此一次說明

12.2 非同步衍生程序:spawn()

12.2.1 spawn() 的運作方式

spawn(
  command: string,
  args?: Array<string>,
  options?: Object
): ChildProcess

spawn() 會在新的程序中非同步執行指令:此程序會與 Node 的主要 JavaScript 程序並行執行,我們可以使用各種方式(通常透過串流)與它通訊。

接下來,說明 spawn() 的參數和結果。如果您偏好透過範例學習,可以跳過該內容,繼續閱讀以下小節。

12.2.1.1 參數:command

command 是包含 shell 指令的字串。使用此參數有兩種模式

兩種模式都會在 本章稍後 進行示範。

12.2.1.2 參數:options

以下 options 最有趣

12.2.1.3 options.stdio

子程序的每個標準 I/O 串流都有數字 ID,稱為檔案描述符

檔案描述符可以更多,但這很少見。

options.stdio 設定子程序的串流是否以及如何傳遞到父程序的串流。它可以是陣列,其中每個元素設定等於其索引的檔案描述符。下列值可用作陣列元素

我們也可以縮寫,而不是透過陣列指定 options.stdio

12.2.1.4 結果:ChildProcess 執行個體

spawn() 傳回 ChildProcess 的執行個體。

有趣的資料屬性

有趣的函式

有趣的事件

我們稍後會看到 如何將事件轉換成可以等待的 Promise

12.2.2 何時執行 shell 指令?

使用非同步 spawn() 時,指令的子程序會非同步啟動。下列程式碼示範了這一點

import {spawn} from 'node:child_process';

spawn(
  'echo', ['Command starts'],
  {
    stdio: 'inherit',
    shell: true,
  }
);
console.log('After spawn()');

輸出結果為

After spawn()
Command starts

12.2.3 僅指令模式和 args 模式

在本節中,我們使用兩種方式指定相同的指令呼叫

12.2.3.1 僅指令模式
import {Readable} from 'node:stream';
import {spawn} from 'node:child_process';

const childProcess = spawn(
  'echo "Hello, how are you?"',
  {
    shell: true, // (A)
    stdio: ['ignore', 'pipe', 'inherit'], // (B)
  }
);
const stdout = Readable.toWeb(
  childProcess.stdout.setEncoding('utf-8'));

// Result on Unix
assert.equal(
  await readableStreamToString(stdout),
  'Hello, how are you?\n' // (C)
);

// Result on Windows: '"Hello, how are you?"\r\n'

每個帶有參數的僅指令產生都需要 .shelltrue (A 行),即使它像這個範例一樣簡單。

在 B 行,我們告訴 spawn() 如何處理標準 I/O

在這種情況下,我們只對子程序的輸出感興趣。因此,一旦我們處理完輸出,我們就完成了。在其他情況下,我們可能必須等到子程序退出。稍後會說明如何執行此操作。

在僅命令模式下,我們看到更多 shell 的特殊性 - 例如,Windows 命令 shell 輸出包含雙引號(最後一行)。

12.2.3.2 Args 模式
import {Readable} from 'node:stream';
import {spawn} from 'node:child_process';

const childProcess = spawn(
  'echo', ['Hello, how are you?'],
  {
    shell: true,
    stdio: ['ignore', 'pipe', 'inherit'],
  }
);
const stdout = Readable.toWeb(
  childProcess.stdout.setEncoding('utf-8'));

// Result on Unix
assert.equal(
  await readableStreamToString(stdout),
  'Hello, how are you?\n'
);
// Result on Windows: 'Hello, how are you?\r\n'
12.2.3.3 args 中的元字元

讓我們探討一下如果 args 中有元字元會發生什麼情況

import {Readable} from 'node:stream';
import {spawn} from 'node:child_process';

async function echoUser({shell, args}) {
  const childProcess = spawn(
    `echo`, args,
    {
      stdio: ['ignore', 'pipe', 'inherit'],
      shell,
    }
  );
  const stdout = Readable.toWeb(
    childProcess.stdout.setEncoding('utf-8'));
  return readableStreamToString(stdout);
}

// Results on Unix
assert.equal(
  await echoUser({shell: false, args: ['$USER']}), // (A)
  '$USER\n'
);
assert.equal(
  await echoUser({shell: true, args: ['$USER']}), // (B)
  'rauschma\n'
);
assert.equal(
  await echoUser({shell: true, args: [String.raw`\$USER`]}), // (C)
  '$USER\n'
);

其他元字元,例如星號 (*),也會產生類似的效果。

這些是 Unix shell 元字元的兩個範例。Windows shell 有自己的元字元和跳脫方式。

12.2.3.4 更複雜的 shell 命令

讓我們使用更多 shell 功能(需要僅命令模式)

import {Readable} from 'node:stream';
import {spawn} from 'node:child_process';
import {EOL} from 'node:os';

const childProcess = spawn(
  `(echo cherry && echo apple && echo banana) | sort`,
  {
    stdio: ['ignore', 'pipe', 'inherit'],
    shell: true,
  }
);
const stdout = Readable.toWeb(
  childProcess.stdout.setEncoding('utf-8'));
assert.equal(
  await readableStreamToString(stdout),
  'apple\nbanana\ncherry\n'
);

12.2.4 將資料傳送至子程序的 stdin

到目前為止,我們只讀取子程序的標準輸出。但我們也可以將資料傳送至標準輸入

import {Readable, Writable} from 'node:stream';
import {spawn} from 'node:child_process';

const childProcess = spawn(
  `sort`, // (A)
  {
    stdio: ['pipe', 'pipe', 'inherit'],
  }
);
const stdin = Writable.toWeb(childProcess.stdin); // (B)
const writer = stdin.getWriter(); // (C)
try {
  await writer.write('Cherry\n');
  await writer.write('Apple\n');
  await writer.write('Banana\n');
} finally {
  writer.close();
}

const stdout = Readable.toWeb(
  childProcess.stdout.setEncoding('utf-8'));
assert.equal(
  await readableStreamToString(stdout),
  'Apple\nBanana\nCherry\n'
);

我們使用 shell 命令 sort(A 行)為我們整理文字列。

在 B 行中,我們使用 Writable.toWeb() 將原生 Node.js 串流轉換為 Web 串流(有關更多資訊,請參閱 §10「在 Node.js 上使用 Web 串流」)。

如何透過撰寫器寫入 WritableStream(C 行)也說明在 Web 串流章節 中。

12.2.5 手動管道傳輸

我們先前讓 shell 執行下列命令

(echo cherry && echo apple && echo banana) | sort

在以下範例中,我們手動執行管道傳輸,從回音(A 行)到排序(B 行)

import {Readable, Writable} from 'node:stream';
import {spawn} from 'node:child_process';

const echo = spawn( // (A)
  `echo cherry && echo apple && echo banana`,
  {
    stdio: ['ignore', 'pipe', 'inherit'],
    shell: true,
  }
);
const sort = spawn( // (B)
  `sort`,
  {
    stdio: ['pipe', 'pipe', 'inherit'],
    shell: true,
  }
);

//==== Transferring chunks from echo.stdout to sort.stdin ====

const echoOut = Readable.toWeb(
  echo.stdout.setEncoding('utf-8'));
const sortIn = Writable.toWeb(sort.stdin);

const sortInWriter = sortIn.getWriter();
try {
  for await (const chunk of echoOut) { // (C)
    await sortInWriter.write(chunk);
  }
} finally {
  sortInWriter.close();
}

//==== Reading sort.stdout ====

const sortOut = Readable.toWeb(
  sort.stdout.setEncoding('utf-8'));
assert.equal(
  await readableStreamToString(sortOut),
  'apple\nbanana\ncherry\n'
);

echoOut 等 ReadableStreams 是非同步可迭代的。這就是為什麼我們可以使用 for-await-of 迴圈來讀取它們的區塊(串流資料的片段)。有關更多資訊,請參閱 §10「在 Node.js 上使用 Web 串流」

12.2.6 處理不成功的退出(包括錯誤)

有三大類不成功的退出

12.2.6.1 無法產生子程序

以下程式碼示範如果無法產生子程序會發生什麼事。在這個案例中,原因是 shell 的路徑沒有指向一個可執行檔 (A 行)。

import {spawn} from 'node:child_process';

const childProcess = spawn(
  'echo hello',
  {
    stdio: ['inherit', 'inherit', 'pipe'],
    shell: '/bin/does-not-exist', // (A)
  }
);
childProcess.on('error', (err) => { // (B)
  assert.equal(
    err.toString(),
    'Error: spawn /bin/does-not-exist ENOENT'
  );
});

這是我們第一次使用事件來處理子程序。在 B 行,我們註冊一個事件監聽器來監聽 'error' 事件。子程序在目前的程式碼片段執行完後才會啟動。這有助於防止競爭條件:當我們開始監聽時,我們可以確定事件尚未發出。

12.2.6.2 shell 中發生錯誤

如果 shell 程式碼包含錯誤,我們不會收到 'error' 事件 (B 行),我們會收到一個退出碼非零的 'exit' 事件 (A 行)

import {Readable} from 'node:stream';
import {spawn} from 'node:child_process';

const childProcess = spawn(
  'does-not-exist',
  {
    stdio: ['inherit', 'inherit', 'pipe'],
    shell: true,
  }
);
childProcess.on('exit',
  async (exitCode, signalCode) => { // (A)
    assert.equal(exitCode, 127);
    assert.equal(signalCode, null);
    const stderr = Readable.toWeb(
      childProcess.stderr.setEncoding('utf-8'));
    assert.equal(
      await readableStreamToString(stderr),
      '/bin/sh: does-not-exist: command not found\n'
    );
  }
);
childProcess.on('error', (err) => { // (B)
  console.error('We never get here!');
});
12.2.6.3 一個程序被終止

如果一個程序在 Unix 上被終止,退出碼會是 null (C 行),而訊號碼會是一個字串 (D 行)

import {Readable} from 'node:stream';
import {spawn} from 'node:child_process';

const childProcess = spawn(
  'kill $$', // (A)
  {
    stdio: ['inherit', 'inherit', 'pipe'],
    shell: true,
  }
);
console.log(childProcess.pid); // (B)
childProcess.on('exit', async (exitCode, signalCode) => {
  assert.equal(exitCode, null); // (C)
  assert.equal(signalCode, 'SIGTERM'); // (D)
  const stderr = Readable.toWeb(
    childProcess.stderr.setEncoding('utf-8'));
  assert.equal(
    await readableStreamToString(stderr),
    '' // (E)
  );
});

請注意,沒有錯誤輸出 (E 行)。

除了子程序自行終止 (A 行) 之外,我們也可以暫停它一段較長的時間,並透過我們在 B 行記錄的程序 ID 手動終止它。

如果我們在 Windows 上終止一個子程序會發生什麼事?

12.2.7 等待子程序退出

有時候我們只想要等到一個指令執行完畢。這可以使用事件或 Promise 來達成。

12.2.7.1 透過事件等待
import * as fs from 'node:fs';
import {spawn} from 'node:child_process';

const childProcess = spawn(
  `(echo first && echo second) > tmp-file.txt`,
  {
    shell: true,
    stdio: 'inherit',
  }
);
childProcess.on('exit', (exitCode, signalCode) => { // (A)
  assert.equal(exitCode, 0);
  assert.equal(signalCode, null);
  assert.equal(
    fs.readFileSync('tmp-file.txt', {encoding: 'utf-8'}),
    'first\nsecond\n'
  );
});

我們使用標準的 Node.js 事件模式,並註冊一個 'exit' 事件的監聽器 (A 行)。

12.2.7.2 透過 Promise 等待
import * as fs from 'node:fs';
import {spawn} from 'node:child_process';

const childProcess = spawn(
  `(echo first && echo second) > tmp-file.txt`,
  {
    shell: true,
    stdio: 'inherit',
  }
);

const {exitCode, signalCode} = await onExit(childProcess); // (A)

assert.equal(exitCode, 0);
assert.equal(signalCode, null);
assert.equal(
  fs.readFileSync('tmp-file.txt', {encoding: 'utf-8'}),
  'first\nsecond\n'
);

我們在 A 行使用的輔助函式 onExit() 會傳回一個 Promise,如果發出 'exit' 事件,這個 Promise 就會完成

export function onExit(eventEmitter) {
  return new Promise((resolve, reject) => {
    eventEmitter.once('exit', (exitCode, signalCode) => {
      if (exitCode === 0) { // (B)
        resolve({exitCode, signalCode});
      } else {
        reject(new Error(
          `Non-zero exit: code ${exitCode}, signal ${signalCode}`));
      }
    });
    eventEmitter.once('error', (err) => { // (C)
      reject(err);
    });
  });
}

如果 eventEmitter 失敗,傳回的 Promise 會被拒絕,而 await 會在 A 行擲出一個例外。onExit() 處理兩種失敗

12.2.8 終止子程序

12.2.8.1 透過 AbortController 終止子程序

在這個範例中,我們使用 AbortController 來終止一個 shell 指令

import {spawn} from 'node:child_process';

const abortController = new AbortController(); // (A)

const childProcess = spawn(
  `echo Hello`,
  {
    stdio: 'inherit',
    shell: true,
    signal: abortController.signal, // (B)
  }
);
childProcess.on('error', (err) => {
  assert.equal(
    err.toString(),
    'AbortError: The operation was aborted'
  );
});
abortController.abort(); // (C)

我們建立一個 AbortController (第 A 行),將其訊號傳遞給 spawn() (第 B 行),並透過 AbortController 終止 shell 指令 (第 C 行)。

子處理程序會非同步啟動 (在執行目前的程式碼片段之後)。這就是為什麼我們可以在處理程序啟動之前中止,以及為什麼我們在此案例中看不到任何輸出的原因。

12.2.8.2 透過 .kill() 終止子處理程序

在以下範例中,我們透過 .kill() 方法終止子處理程序 (最後一行)

import {spawn} from 'node:child_process';

const childProcess = spawn(
  `echo Hello`,
  {
    stdio: 'inherit',
    shell: true,
  }
);
childProcess.on('exit', (exitCode, signalCode) => {
  assert.equal(exitCode, null);
  assert.equal(signalCode, 'SIGTERM');
});
childProcess.kill(); // default argument value: 'SIGTERM'

我們再次在子處理程序啟動之前 (非同步地!) 將其終止,且沒有任何輸出。

12.3 同步產生處理程序:spawnSync()

spawnSync(
  command: string,
  args?: Array<string>,
  options?: Object
): Object

spawnSync()spawn() 的同步版本,它會等到子處理程序結束後才同步地傳回一個物件。

參數大部分與 spawn() 的參數 相同。options 有幾個額外的屬性,例如

函式會傳回一個物件。其最有趣的屬性是

使用非同步的 spawn() 時,子處理程序會並行執行,而且我們可以透過串流讀取標準 I/O。相反地,同步的 spawnSync() 會收集串流的內容並同步地傳回給我們 (請參閱下一個小節)。

12.3.1 什麼時候執行 shell 指令?

使用同步的 spawnSync() 時,指令的子處理程序會同步啟動。以下程式碼示範了這一點

import {spawnSync} from 'node:child_process';

spawnSync(
  'echo', ['Command starts'],
  {
    stdio: 'inherit',
    shell: true,
  }
);
console.log('After spawnSync()');

輸出結果為

Command starts
After spawnSync()

12.3.2 從 stdout 讀取

以下程式碼示範如何讀取標準輸出

import {spawnSync} from 'node:child_process';

const result = spawnSync(
  `echo rock && echo paper && echo scissors`,
  {
    stdio: ['ignore', 'pipe', 'inherit'], // (A)
    encoding: 'utf-8', // (B)
    shell: true,
  }
);
console.log(result);
assert.equal(
  result.stdout, // (C)
  'rock\npaper\nscissors\n'
);
assert.equal(result.stderr, null); // (D)

在 A 行,我們使用 options.stdio 告訴 spawnSync() 我們只對標準輸出有興趣。我們忽略標準輸入,並將標準錯誤輸出導向父程序。

因此,我們只取得標準輸出的結果屬性 (C 行),而標準錯誤輸出的屬性為 null (D 行)。

由於我們無法存取 spawnSync() 內部用來處理子程序標準 I/O 的串流,因此我們透過 options.encoding (B 行) 告訴它要使用哪種編碼。

12.3.3 將資料傳送至子程序的 stdin

我們可以透過選項屬性 .input (A 行) 將資料傳送至子程序的標準輸入串流

import {spawnSync} from 'node:child_process';

const result = spawnSync(
  `sort`,
  {
    stdio: ['pipe', 'pipe', 'inherit'],
    encoding: 'utf-8',
    input: 'Cherry\nApple\nBanana\n', // (A)
  }
);
assert.equal(
  result.stdout,
  'Apple\nBanana\nCherry\n'
);

12.3.4 處理失敗的退出 (包括錯誤)

有三大類失敗的退出 (當退出碼不為零時)

12.3.4.1 無法產生子程序

如果產生失敗,spawn() 會發出 'error' 事件。相反地,spawnSync() 會將 result.error 設為錯誤物件

import {spawnSync} from 'node:child_process';

const result = spawnSync(
  'echo hello',
  {
    stdio: ['ignore', 'inherit', 'pipe'],
    encoding: 'utf-8',
    shell: '/bin/does-not-exist',
  }
);
assert.equal(
  result.error.toString(),
  'Error: spawnSync /bin/does-not-exist ENOENT'
);
12.3.4.2 殼層中發生錯誤

如果殼層中發生錯誤,退出碼 result.status 會大於零,而 result.signalnull

import {spawnSync} from 'node:child_process';

const result = spawnSync(
  'does-not-exist',
  {
    stdio: ['ignore', 'inherit', 'pipe'],
    encoding: 'utf-8',
    shell: true,
  }
);
assert.equal(result.status, 127);
assert.equal(result.signal, null);
assert.equal(
  result.stderr, '/bin/sh: does-not-exist: command not found\n'
);
12.3.4.3 程序被終止

如果子程序在 Unix 上被終止,result.signal 會包含訊號名稱,而 result.statusnull

import {spawnSync} from 'node:child_process';

const result = spawnSync(
  'kill $$',
  {
    stdio: ['ignore', 'inherit', 'pipe'],
    encoding: 'utf-8',
    shell: true,
  }
);

assert.equal(result.status, null);
assert.equal(result.signal, 'SIGTERM');
assert.equal(result.stderr, ''); // (A)

請注意,沒有輸出傳送至標準錯誤串流 (A 行)。

如果我們在 Windows 上終止子程序

12.4 基於 spawn() 的非同步輔助函數

在本節中,我們將探討模組 node:child_process 中兩個基於 spawn() 的非同步函數

我們在本章節中忽略 fork()。引用 Node.js 文件

fork() 產生新的 Node.js 程序,並呼叫指定的模組,並建立 IPC 通訊管道,允許在父代和子代之間傳送訊息。

12.4.1 exec()

exec(
  command: string,
  options?: Object,
  callback?: (error, stdout, stderr) => void
): ChildProcess

exec() 在新產生的 shell 中執行命令。與 spawn() 的主要差異在於

import {exec} from 'node:child_process';

const childProcess = exec(
  'echo Hello',
  (error, stdout, stderr) => {
    if (error) {
      console.error('error: ' + error.toString());
      return;
    }
    console.log('stdout: ' + stdout); // 'stdout: Hello\n'
    console.error('stderr: ' + stderr); // 'stderr: '
  }
);

exec() 可以透過 util.promisify() 轉換為基於 Promise 的函式

import * as util from 'node:util';
import * as child_process from 'node:child_process';

const execAsync = util.promisify(child_process.exec);

try {
  const resultPromise = execAsync('echo Hello');
  const {childProcess} = resultPromise;
  const obj = await resultPromise;
  console.log(obj); // { stdout: 'Hello\n', stderr: '' }
} catch (err) {
  console.error(err);
}

12.4.2 execFile()

execFile(file, args?, options?, callback?): ChildProcess

運作方式類似於 exec(),但有以下差異

exec() 一樣,execFile() 可以透過 util.promisify() 轉換為基於 Promise 的函式。

12.5 基於 spawnAsync() 的同步輔助函式

12.5.1 execSync()

execSync(
  command: string,
  options?: Object
): Buffer | string

execSync() 在新的子程序中執行命令,並同步等待該程序結束。與 spawnSync() 的主要差異在於

import {execSync} from 'node:child_process';

try {
  const stdout = execSync('echo Hello');
  console.log('stdout: ' + stdout); // 'stdout: Hello\n'
} catch (err) {
  console.error('Error: ' + err.toString());
}

12.5.2 execFileSync()

execFileSync(file, args?, options?): Buffer | string

作用類似於 execSync(),但有以下差異

12.6 有用的函式庫

12.6.1 tinysh:用於產生 shell 命令的輔助程式

tinysh 由 Anton Medvedev 製作,是一個有助於產生 shell 命令的小型函式庫,例如

import sh from 'tinysh';

console.log(sh.ls('-l'));
console.log(sh.cat('README.md'));

我們可以使用 .call() 傳遞物件作為 this 來覆寫預設選項

sh.tee.call({input: 'Hello, world!'}, 'file.txt');

我們可以使用任何屬性名稱,而 tinysh 會執行具有該名稱的 shell 命令。它透過 代理 來達成此壯舉。這是實際函式庫的略微修改版本

import {execFileSync} from 'node:child_process';
const sh = new Proxy({}, {
  get: (_, bin) => function (...args) { // (A)
    return execFileSync(bin, args,
      {
        encoding: 'utf-8',
        shell: true,
        ...this // (B)
      }
    );
  },
});

在 A 行中,我們可以看到,如果我們從 sh 取得名稱為 bin 的屬性,則會傳回一個函式,該函式會呼叫 execFileSync() 並使用 bin 作為第一個引數。

在 B 行中散佈 this 使我們能夠透過 .call() 指定選項。預設值會先出現,以便它們可以透過 this 來覆寫。

12.6.2 node-powershell:透過 Node.js 執行 Windows PowerShell 命令

在 Windows 上使用 函式庫 node-powershell 如下所示

import { PowerShell } from 'node-powershell';
PowerShell.$`echo "hello from PowerShell"`;

12.7 選擇模組 'node:child_process' 的函式

一般限制

非同步函式 - 在 spawn()exec()execFile() 之間進行選擇

同步函式 - 在 spawnSync()execSync()execFileSync() 之間進行選擇

exec()execFile() 之間做選擇(在 execSync()execFileSync() 之間做選擇時套用相同的論點)