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
res.sendFile():按文件内容响应,适合框架托管的文件发送。res.download():额外设置附件下载语义。res.attachment():只设置 Content-Disposition/类型,仍需发送内容。pipeline(readStream, res):需要掌控取消、统计和多级 Transform 时更灵活。
无论选择哪个,都要处理回调/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
练习题
- 响应头发出后为什么不能返回 JSON 错误?
- 使用
writableFinished区分正常与提前 close。 - 为同步导出和后台导出分别定义客户端中断策略。
- 用 curl 取消下载并验证句柄与临时文件。