Bash 管道、重定向与退出状态
本章解决的问题
- Bash 的
|、>、2>如何对应 Node.js 标准流? - 为什么管道前半部分失败,整条命令却可能显示成功?
pipefail解决什么,又没有解决什么?
工作原理
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 "$?"
- 所有命令成功,管道状态为 0。
- 有命令失败,管道状态为最右侧那个非零命令的状态。
- 它让失败可见,但不会自动说明哪一段失败,也不会回滚已写出的文件。
Bash 还提供各段状态数组。必须在管道后立即保存,因为下一条命令会覆盖它:
node dist/generate.js | gzip > output.gz
statuses=("${PIPESTATUS[@]}")
printf 'generate=%s gzip=%s\n' "${statuses[0]}" "${statuses[1]}"
生产 Bash 脚本常见:
set -Eeuo pipefail
-e:部分非零状态会让脚本退出,但在条件、命令列表等上下文有例外。-u:读取未设置变量时报错。pipefail:管道任一段失败可使整条管道失败。-E:让ERRtrap 在更多函数/子 Shell 场景继承。
不能只记“严格模式”四个字;错误恢复代码要理解每个选项的控制流影响。
正常时间线
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
$LASTEXITCODE是最近一个原生程序的数值退出码。$?是最近操作是否成功的布尔值。- PowerShell 管道以对象语义为核心,调用原生程序时会适配文本/字节;不同版本细节不同。
- PowerShell 7.3+ 可用
$PSNativeCommandUseErrorActionPreference使原生命令非零状态参与错误偏好,但脚本仍应明确检查退出码。 - 需要 Bash 时要明确部署 Git Bash、WSL 或容器,不能假设原生 Windows 一定有
bash。
常见错误
- 认为
a | b默认会暴露 a 的失败。 - 启用
pipefail后仍把不完整输出直接当最终文件。 - Shell 变量不加双引号,使空格或通配符改变参数边界。
- 混淆
2>&1 > file与> file 2>&1。 - 把 stderr 合并进机器数据流,再要求下游解析纯 JSON。
- 在 Node.js 中用
exec("...")拼用户输入,只为使用管道语法。
最佳实践
- Bash 自动化明确使用
pipefail,并理解-e的例外。 - stdout 保持机器可读,stderr 保持可诊断。
- 先写受控临时路径,完整成功后再发布最终文件。
- Node.js 调工具时优先
spawn(command, args)并用 Stream 连接,不为管道随意启用 Shell。 - 同时验证退出码、结果格式和文件完整性。
练习题
- 写一个
exitCode=3的 Node.js 程序,与gzip组成管道,对比有无pipefail。 - 用
PIPESTATUS输出三段管道各自的状态。 - 解释
2>&1 > file中 stderr 最终去了哪里。 - 为示例 Bash 脚本增加输出目录白名单检查。
验收点
- [ ] 能画出普通管道连接的标准流。
- [ ] 知道 Bash 默认采用最后一段命令的管道状态。
- [ ] 能解释
pipefail的收益与边界。 - [ ] 不会让诊断日志污染机器可读 stdout。