使用 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 仅命令模式与参数模式

在本节中,我们以两种方式指定相同的命令调用

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'

每个仅使用参数生成命令的命令都需要将 .shell 设置为 true(A 行)– 即使它像这个命令一样简单。

在 B 行,我们告诉 spawn() 如何处理标准 I/O

在这种情况下,我们只对子进程的输出感兴趣。因此,一旦我们处理完输出,我们就完成了。在其他情况下,我们可能必须等到子进程退出。稍后将演示如何做到这一点。

在仅命令模式下,我们会看到更多 shell 的特性 - 例如,Windows 命令 shell 输出包含双引号(最后一行)。

12.2.3.2 参数模式
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 将数据发送到子进程的标准输入

到目前为止,我们只读取了子进程的标准输出。但我们也可以将数据发送到标准输入

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'
);

ReadableStreams(例如 echoOut)是异步可迭代的。这就是为什么我们可以使用 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 从标准输出读取

以下代码演示了如何读取标准输出

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 将数据发送到子进程的标准输入

我们可以通过选项属性 .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 shell 中发生错误

如果 shell 中发生错误,则退出代码 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 命令的辅助工具

Anton Medvedev 开发的 tinysh 是一个小型库,可以帮助生成 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() 之间进行选择)