12 pipe() 与错误边界

1. 正确的方法签名

常见形式是:

readable.pipe(writable);

并不是一般意义上的 Readable.pipe(Readable)。只有 Transform/Duplex 同时具有可写输入端和可读输出端,才能放在中间:

input
  .pipe(transform)
  .pipe(output);

2. pipe() 返回目标

const returned = input.pipe(output);
console.log(returned === output); // true

这允许链式调用,但也让新人容易只监听最后一段错误。

3. 怎样知道传输完成

源 Readable 的:

end = 源数据被读取完

目标 Writable 的:

finish = 所有写入数据已处理完

复制文件时,更接近“目标写完”的是 finish,不能只等待源 end。但双方任何一端报错时,对应成功事件都可能不出现。

4. pipe() 不等于完整错误管理

危险写法:

input.pipe(output);

output.on("finish", () => {
  console.log("完成");
});

如果 input 报错,不能假设 output 一定按你期望自动销毁;如果 output 报错,也不能依赖所有源自动停止。你需要监听每一段错误、取消其他段、避免重复 reject,并等待资源关闭。

5. 手工封装为何复杂

function copyWithPipe(
  input: Readable,
  output: Writable,
): Promise<void> {
  return new Promise((resolve, reject) => {
    let settled = false;

    const fail = (error: Error): void => {
      if (settled) return;
      settled = true;
      input.destroy();
      output.destroy();
      reject(error);
    };

    input.once("error", fail);
    output.once("error", fail);
    output.once("finish", () => {
      if (settled) return;
      settled = true;
      resolve();
    });

    input.pipe(output);
  });
}

即使这样还要考虑监听器清理、提前 close、同步抛错、多个 Transform 和销毁完成。Node.js 已经提供 pipeline() 处理这些组合问题,不应重复造一个不完整版本。

6. { end: false }

input.pipe(output, { end: false });

源结束后目标不会自动 end(),适合多个源顺序写入同一个目标,但调用者必须明确何时最终 output.end()。遗漏会导致 finish 永远不发生、句柄持续打开。

7. 部分文件

任一端失败时,目标文件可能已经写入一部分。Stream 销毁只负责资源,不会自动执行:

await rm(outputPath, { force: true });

业务层必须决定删除半成品、保留诊断,还是写入临时路径后永不暴露半成品。

8. 与 yauzl Entry Stream 对照

Entry ReadStream error
  → 目标 WriteStream 不应继续等待

WriteStream error
  → Entry ReadStream 不应继续解压

ZIP Reader error
  → 当前 Entry 和整体任务都应终止

这正是多个 .pipe() 让错误边界难以管理的原因。

练习题

  1. 为什么源 end 不证明目标文件写完?
  2. 为什么目标 close 不证明复制成功?
  3. { end: false } 最容易造成什么资源问题?
  4. 分别模拟输入和输出报错,记录每一端事件。