Bash 管道、重定向与退出状态

本章解决的问题

工作原理

Shell 负责先连接文件描述符,再启动程序。普通管道只连接前一程序的 stdout 与后一程序的 stdin:

command-a stdout ──管道──> command-b stdin
command-a stderr ─────────> 原来的 stderr(通常仍是终端)

>2> 是 Shell 语法,不是 Node.js API:

>    重定向 fd 1(stdout),覆盖文件
>>   重定向 fd 1,追加文件
2>   重定向 fd 2(stderr),覆盖文件
<    用文件作为 fd 0(stdin)

高频 Bash 用法

node dist/analyze.js > result.json 2> analyze.log
node dist/analyze.js >> result.jsonl
node dist/parser.js < input.csv
node dist/generate.js | gzip > result.json.gz
node dist/analyze.js > all.log 2>&1

重定向从左到右处理,顺序会改变结果:

# stdout 先指向文件,stderr 再复制 stdout 当前指向:二者都进 all.log
node dist/analyze.js > all.log 2>&1

# stderr 先复制原 stdout,stdout 后改到文件:二者仍分开
node dist/analyze.js 2>&1 > result.log

2>&1 意为“让 fd 2 指向 fd 1 此刻所指的位置”,不是固定的“合并日志”按钮。

具体代码:可参与管道的 JSON Lines CLI

import { createInterface } from "node:readline";

interface OutputRecord {
  original: string;
  uppercase: string;
}

const lines = createInterface({
  input: process.stdin,
  crlfDelay: Infinity,
});

let count: number = 0;

lines.on("line", (line: string) => {
  const result: OutputRecord = {
    original: line,
    uppercase: line.toUpperCase(),
  };

  process.stdout.write(`${JSON.stringify(result)}\n`);
  count += 1;
});

lines.on("close", () => {
  process.stderr.write(`已处理 ${count} 行\n`);
});

process.stdin.on("error", (error: Error) => {
  process.stderr.write(`输入失败:${error.message}\n`);
  process.exitCode = 1;
});
cat names.txt | ts-node uppercase-jsonl.ts | gzip > names.jsonl.gz

进度写 stderr,所以不会混入交给 gzip 的 stdout。

退出码与 pipefail

单条外部命令在 Bash 中可用 $? 查看退出码:

node dist/analyze.js
echo "$?"

默认情况下,Bash 管道状态是最后一个命令的状态:

node dist/fail.js | gzip > output.gz
echo "$?"  # gzip 成功时可能为 0,即使 fail.js 失败

启用 pipefail

set -o pipefail
node dist/fail.js | gzip > output.gz
echo "$?"

Bash 还提供各段状态数组。必须在管道后立即保存,因为下一条命令会覆盖它:

node dist/generate.js | gzip > output.gz
statuses=("${PIPESTATUS[@]}")
printf 'generate=%s gzip=%s\n' "${statuses[0]}" "${statuses[1]}"

生产 Bash 脚本常见:

set -Eeuo pipefail

不能只记“严格模式”四个字;错误恢复代码要理解每个选项的控制流影响。

正常时间线

Bash 创建管道 → 启动 generate 与 gzip
→ generate stdout 流向 gzip stdin → generate 关闭 stdout
→ gzip 读到 EOF 并完成文件 → 两段 exit 0 → 管道 status 0

异常时间线

generate 中途 exit 3 → gzip 收到 EOF
→ gzip 仍可能产出合法但不完整的压缩文件并 exit 0
→ 默认管道 status 0;pipefail 下 status 3
→ 调用者仍应删除或隔离不完整 output.gz

下游提前结束还可能使上游收到 SIGPIPE,或在 Node.js 中表现为 EPIPE。例如 producer | head -n 1 不一定是 producer 的业务错误,应按 CLI 协议处理。

Bash 脚本实战

#!/usr/bin/env bash
set -Eeuo pipefail

input_path=${1:?"需要输入文件"}
output_path=${2:?"需要输出文件"}
temporary_path="${output_path}.tmp"

cleanup() {
  rm -f -- "$temporary_path"
}

trap cleanup EXIT

node dist/generate.js --input "$input_path" | gzip > "$temporary_path"
mv -- "$temporary_path" "$output_path"

变量必须用双引号包裹。临时文件仅在完整管道成功后移动到最终路径,可避免读者误用半成品。真实服务还要限制授权目录;引号不能防止路径越界。

Windows 与 PowerShell

PowerShell 不是 Bash,不能照搬 set -o pipefail

node dist/analyze.js
$LASTEXITCODE

常见错误

  1. 认为 a | b 默认会暴露 a 的失败。
  2. 启用 pipefail 后仍把不完整输出直接当最终文件。
  3. Shell 变量不加双引号,使空格或通配符改变参数边界。
  4. 混淆 2>&1 > file> file 2>&1
  5. 把 stderr 合并进机器数据流,再要求下游解析纯 JSON。
  6. 在 Node.js 中用 exec("...") 拼用户输入,只为使用管道语法。

最佳实践

练习题

  1. 写一个 exitCode=3 的 Node.js 程序,与 gzip 组成管道,对比有无 pipefail
  2. PIPESTATUS 输出三段管道各自的状态。
  3. 解释 2>&1 > file 中 stderr 最终去了哪里。
  4. 为示例 Bash 脚本增加输出目录白名单检查。

验收点