stdin、stdout、stderr 与 stdio

本章解决的问题

工作原理

程序启动时通常拥有三个约定的文件描述符:

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 适合日志和诊断,但它们本身不决定成功:

高频 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

常见错误

  1. 看到 stderr 非空就立即判定失败。
  2. data chunk 当成一行或一条完整 JSON。
  3. 向机器可读 stdout 混入 console.log("正在处理")
  4. 无上限收集输入或输出,导致内存耗尽。
  5. 忽略 write() 的背压信号。
  6. 以为重定向到文件时仍必然存在 isTTY === true

最佳实践

练习题

  1. 扩展 sum-lines.ts,忽略空行并在 stderr 输出处理行数。
  2. 让程序输出 JSON Lines,而不是一个最终对象。
  3. 用重定向分别保存结果和诊断,验证 stderr 有警告但退出码仍可为 0。
  4. 模拟大量输出,观察 write() 何时返回 false

验收点