20 上传、下载与客户端中断

1. HTTP 也是 Stream

Express 的:

req 基于 http.IncomingMessage,是 Readable
res 基于 http.ServerResponse,是 Writable

Multer 在路由处理器前消费 multipart 请求,将文件写入磁盘或内存。进入 Controller 时“上传结束”,不代表 ZIP 校验、渲染和下载已经完成。

2. 三个失败阶段

响应头之前

可以返回统一 JSON:

{
  "code": "INVALID_ZIP",
  "message": "压缩包格式不正确",
  "data": null
}

响应头已经发出、文件传输中

不能再改成 JSON,因为客户端已经按 ZIP 解释响应。应销毁传输、记录日志并清理资源。

响应完成后

后续清理失败只能记录和补偿,不能再次修改响应。

因此错误处理中间件应检查:

if (res.headersSent) {
  next(error);
  return;
}

3. 下载 Pipeline

await pipeline(
  createReadStream(resultZipPath),
  res,
);

在此之前设置:

res.type("application/zip");
res.attachment(downloadName);

不要把 res 传入 Pipeline 后再试图发送第二个响应。

4. 客户端中断

上传阶段可观察 req.aborted/aborted;响应端的 close 可能表示连接提前关闭,也可能在正常关闭路径出现,需要结合 res.writableFinished 判断。

const controller = new AbortController();

const onClose = (): void => {
  if (!res.writableFinished) {
    controller.abort();
  }
};

res.once("close", onClose);

try {
  await pipeline(
    createReadStream(resultZipPath),
    res,
    { signal: controller.signal },
  );
} finally {
  res.off("close", onClose);
}

具体事件组合应在当前 Node.js、Nginx 和 HTTP 版本下测试,避免把正常 close 误记为失败。

5. 客户端断开后是否停止业务

短期、仅为这次响应生成的临时任务可以取消。已经进入持久化后台队列的任务可能继续执行,让用户稍后下载结果。必须在创建任务时决定语义,不能由某个 close 监听器临时猜测。

涉及数据库写入时,AbortSignal 不能自动回滚已提交事务;任务要在事务边界主动检查取消,并为已完成阶段设计补偿。

6. sendFile()download() 与 Pipeline

无论选择哪个,都要处理回调/Promise 错误和客户端中断。

7. 原始下载名

磁盘存储名使用 UUID,下载名使用数据库中的原始或生成名称。不要把原始名称拼成本地路径。Express 的 attachment()/download() 会帮助生成 Header,但仍应清除 CR/LF、限制长度并测试 Unicode 文件名。

8. 测试中断

curl --limit-rate 10k \
  --output result.zip \
  http://127.0.0.1:8080/api/export

传输中按 Ctrl+C,检查:

Pipeline 是否 reject
ReadStream 是否销毁
任务状态是否正确
临时文件是否按策略清理
日志是否标记 client_aborted

练习题

  1. 响应头发出后为什么不能返回 JSON 错误?
  2. 使用 writableFinished 区分正常与提前 close。
  3. 为同步导出和后台导出分别定义客户端中断策略。
  4. 用 curl 取消下载并验证句柄与临时文件。