stdin、stdout、stderr 与 stdio
本章解决的问题
- 标准输入、标准输出和标准错误究竟是什么?
- stderr 有输出是否代表失败?stdout 是否只用于成功结果?
- 如何用 TypeScript 流式读取输入、写出结果并处理背压?
工作原理
程序启动时通常拥有三个约定的文件描述符:
0 stdin 标准输入
1 stdout 标准输出
2 stderr 标准错误
stdio 是 standard input/output 的统称,也常指创建子进程时对这些通道的整体配置。它们并不一定连接终端,也可能连接文件、管道或父进程:
键盘/文件/上游程序 ─→ stdin Node.js stdout ─→ 终端/文件/下游程序
stderr ─→ 终端/诊断日志
Node.js 中:
process.stdin; // Readable
process.stdout; // Writable
process.stderr; // Writable
stdout 和 stderr 是两条独立字节流。stdout 适合主要结果,stderr 适合日志和诊断,但它们本身不决定成功:
- 工具可能把正常进度写入 stderr。
- 失败程序可能把结构化错误写入 stdout。
- 真正的进程成败优先看退出码,业务成败还要验证输出协议。
- 两条流独立传输,合并后看到的顺序不保证精确还原跨流写入顺序。
高频 API
process.stdin.on("data", (chunk: Buffer) => {});
process.stdin.setEncoding("utf8");
process.stdin.pipe(destination);
process.stdout.write("result\n");
process.stderr.write("warning\n");
process.stdin.isTTY;
process.stdout.isTTY;
process.stderr.isTTY;
isTTY 在连接交互终端时通常为 true,重定向到文件或管道时通常是 undefined。CLI 常据此决定是否显示颜色、进度动画或提示符。
具体代码:stdin 输入,stdout 输出,stderr 诊断
保存为 sum-lines.ts:
import { createInterface } from "node:readline";
const lines = createInterface({
input: process.stdin,
crlfDelay: Infinity,
});
let total: number = 0;
let lineNumber: number = 0;
let invalid: boolean = false;
lines.on("line", (line: string) => {
lineNumber += 1;
const value: number = Number(line.trim());
if (!Number.isFinite(value)) {
process.stderr.write(`第 ${lineNumber} 行不是有效数字\n`);
process.exitCode = 2;
invalid = true;
return;
}
total += value;
});
lines.on("close", () => {
if (invalid) {
return;
}
process.stdout.write(`${JSON.stringify({ total })}\n`);
});
process.stdin.on("error", (error: Error) => {
process.stderr.write(`读取 stdin 失败:${error.message}\n`);
process.exitCode = 1;
});
运行:
printf "10\n20\n" | ts-node sum-lines.ts
逐行工具应使用 readline 或自己的增量解码器。data 事件的一次 chunk 不等于一行,也不等于一条完整 JSON 消息;UTF-8 字符还可能跨 chunk。
console 与标准流
常见情况下:
console.log("结果或普通信息"); // 写向 stdout
console.error("错误或诊断"); // 写向 stderr
console.warn("警告"); // 写向 stderr
需要严格机器协议时,直接使用 process.stdout.write() 更明确,也不会自动添加空格或换行。不要向 stdout JSON 中混入调试日志,否则下游解析器会失败。
背压:write() 返回 false 不是失败
当下游暂时消费不过来时,Writable.write() 可能返回 false,表示内部缓冲达到阈值。数据已被接受进缓冲,但生产者应暂停,等待 drain 再继续。
import { once } from "node:events";
async function writeLines(lines: readonly string[]): Promise<void> {
for (const line of lines) {
const canContinue: boolean = process.stdout.write(`${line}\n`);
if (!canContinue) {
await once(process.stdout, "drain");
}
}
}
对于大数据,优先用 Stream 和 pipeline();不要把全部 stdin 或 stdout 无上限拼接到字符串。
正常时间线
上游写数据 → stdin 分块到达 → readline 组装行 → 校验并计算
→ stdout 输出 JSON → stdin 到达 EOF → Event Loop 清空 → exit 0
异常时间线
stdin 出现非法行 → stderr 写诊断 → exitCode=2
→ 继续安全消费/收尾输入 → 不输出伪造结果 → 自然退出 2
管道下游提前退出时,写端可能收到 EPIPE。CLI 应根据场景决定把它视作取消、正常截断还是错误;不要让未处理的 stdout error 变成难懂的崩溃。
Bash 与 PowerShell 示例
Bash:
ts-node sum-lines.ts < numbers.txt
ts-node sum-lines.ts > result.json
ts-node sum-lines.ts 2> diagnostic.log
ts-node sum-lines.ts > result.json 2> diagnostic.log
PowerShell 也支持基本的 >、2>,但其管道长期以来传递的是对象/文本语义而非完全等同 Bash 的原始字节管道;不同 PowerShell 版本对外部程序重定向的编码和原生参数处理也有差异。二进制管道应在目标 PowerShell 版本上验证,或由 Node.js pipeline() 直接处理。
Windows 与 Linux
- 三个标准流和编号 0/1/2 都适用。
- Windows 文本常见 CRLF,逐行读取可设置
crlfDelay: Infinity。 - 是否为 TTY 取决于启动环境,不只取决于操作系统;CI、PM2 和 IDE 终端可能不同。
- Linux 工具通常遵循 stdout 数据/stderr 诊断约定,但仍应查阅具体工具文档。
常见错误
- 看到 stderr 非空就立即判定失败。
- 用
datachunk 当成一行或一条完整 JSON。 - 向机器可读 stdout 混入
console.log("正在处理")。 - 无上限收集输入或输出,导致内存耗尽。
- 忽略
write()的背压信号。 - 以为重定向到文件时仍必然存在
isTTY === true。
最佳实践
- 明确协议:stdout 只放机器结果,stderr 放日志,退出码表达最终状态。
- 输出格式、编码和换行方式写入文档;结构化流推荐 JSON Lines。
- 流式消费大数据,并设置字节或行数上限。
- TTY 下才展示颜色和动态进度;管道模式保持纯净稳定。
- 合并 stdout/stderr 用于日志时,接受跨流顺序无法严格还原。
练习题
- 扩展
sum-lines.ts,忽略空行并在 stderr 输出处理行数。 - 让程序输出 JSON Lines,而不是一个最终对象。
- 用重定向分别保存结果和诊断,验证 stderr 有警告但退出码仍可为 0。
- 模拟大量输出,观察
write()何时返回false。
验收点
- [ ] 能说出文件描述符 0、1、2 的含义。
- [ ] 不会用 stderr 是否为空代替退出码判断。
- [ ] 知道 chunk、行和业务消息不是同一边界。
- [ ] 能为大量输出处理背压或使用 Stream 管道。