ZIP 转换会同时占用文件句柄、读取流、写入流、CPU、临时磁盘和 HTTP 连接。仅仅向客户端返回一个 500,并不代表这些工作已经停止。
本章的核心原则是:
响应失败、停止后台工作和释放资源是三件事,需要分别处理。
不要把 yauzl、archiver 或文件系统的原始错误全部直接返回前端。可以定义一个简单错误类型:
export type MarkdownZipErrorCode =
| "INVALID_ZIP"
| "ZIP_ENTRY_LIMIT_EXCEEDED"
| "ZIP_UNCOMPRESSED_SIZE_EXCEEDED"
| "ZIP_PATH_TRAVERSAL"
| "UNSUPPORTED_FILE_TYPE"
| "MARKDOWN_ENTRY_NOT_FOUND"
| "ARCHIVE_CREATION_FAILED"
| "EXPORT_ABORTED";
export class MarkdownZipError extends Error {
constructor(
public readonly code: MarkdownZipErrorCode,
public readonly status: number,
message: string,
options?: ErrorOptions,
) {
super(message, options);
this.name = "MarkdownZipError";
}
}
错误中间件把内部错误映射成稳定响应:
if (error instanceof MarkdownZipError) {
res.status(error.status).json({
code: error.code,
message: error.message,
});
return;
}
日志中可以记录 cause、堆栈和内部路径,API 响应中不要泄露服务器绝对路径和依赖实现细节。
try/finally 是清理的主干const workDirectory = await mkdtemp(
path.join(tmpdir(), "markdown-export-"),
);
try {
await convertProject(workDirectory);
} finally {
await rm(workDirectory, {
recursive: true,
force: true,
});
}
无论转换成功、抛错还是取消,finally 都会执行。但要注意:
finally 只会清理你明确写进去的资源;因此,顺序应当是:
发出取消信号
→ 停止/销毁各个流和库对象
→ 等待正在执行的 Promise 结束
→ 删除临时目录
zipFile.close();
它停止继续读取 ZIP,并关闭底层文件描述符。已经打开的 Entry ReadStream 仍应显式销毁:
entryStream.destroy(abortError);
archive.abort();
abort() 会尽快中止 archiver 当前队列,但不要假设它会替你关闭所有由业务创建的输入/输出流。仍需处理目标流和完成 Promise。
source.destroy(abortError);
destination.destroy(abortError);
对于由 pipeline() 连接的流,某一端失败时 pipeline 会协助销毁其他流并拒绝 Promise:
await pipeline(source, destination, { signal });
优先使用 pipeline(),不要只用 source.pipe(destination) 后就认为错误和背压全部处理完毕。
Controller 为一次请求创建一个控制器:
const abortController = new AbortController();
const { signal } = abortController;
把 signal 传给所有 Service:
await markdownExportService.convert({
uploadedZipPath,
workDirectory,
signal,
});
支持 AbortSignal 的 Node.js API 可以直接接收它。不支持的第三方对象,需要建立适配:
function closeZipOnAbort(
zipFile: yauzl.ZipFile,
signal: AbortSignal,
): () => void {
const close = (): void => {
zipFile.close();
};
if (signal.aborted) {
close();
return () => undefined;
}
signal.addEventListener("abort", close, { once: true });
return () => {
signal.removeEventListener("abort", close);
};
}
返回“解除监听函数”很重要,否则长期存在的对象可能保留无用 listener。
archiver 也可以适配:
function abortArchiveOnSignal(
archive: archiver.Archiver,
signal: AbortSignal,
): () => void {
const abort = (): void => {
archive.abort();
};
signal.addEventListener("abort", abort, { once: true });
return () => {
signal.removeEventListener("abort", abort);
};
}
在直接输出 ZIP 的场景,应监听响应的 close:
res.once("close", () => {
if (!res.writableFinished) {
abortController.abort(
new Error("客户端在下载完成前断开连接"),
);
}
});
close 也可能在正常生命周期结束时出现,所以要结合 res.writableFinished 判断是否提前断开。
上传请求体还没接收完成时,可以关注请求的 aborted:
req.once("aborted", () => {
abortController.abort(
new Error("客户端在上传完成前断开连接"),
);
});
不要只监听一个事件就假设覆盖了上传和下载的所有阶段;Multer、Controller 和输出响应所处阶段不同。
async function streamExport(
req: Request,
res: Response,
): Promise<void> {
const controller = new AbortController();
const archive = archiver("zip", {
zlib: { level: 6 },
});
const abortForDisconnect = (): void => {
if (!res.writableFinished) {
controller.abort(
new MarkdownZipError(
"EXPORT_ABORTED",
499,
"客户端中止了下载",
),
);
}
};
res.once("close", abortForDisconnect);
const abortArchive = (): void => {
archive.abort();
if (!res.destroyed) {
res.destroy();
}
};
controller.signal.addEventListener(
"abort",
abortArchive,
{ once: true },
);
try {
res.type("application/zip");
res.attachment("markdown-export.zip");
archive.on("error", (error) => {
controller.abort(error);
});
archive.pipe(res);
archive.directory("./work/output", false);
await archive.finalize();
await finished(res);
} finally {
res.removeListener("close", abortForDisconnect);
controller.signal.removeEventListener(
"abort",
abortArchive,
);
}
}
这是生命周期骨架,不应原样复制固定的 ./work/output。实际代码还要使用每请求独立的临时目录,并处理 warning、日志和清理。
状态码 499 是一些代理和日志系统用来表示客户端关闭请求的非标准约定;通常已经无法把它真正发送给断开的客户端,主要用于内部错误分类和监控。
不会。
以下动作都不会自动取消仍在运行的 ZIP 工作:
res.status(500).json(...);如果没有把中止信号传入业务层,CPU 转换、文件读取、ZIP 压缩和临时文件写入可能继续运行。高负载导出尤其应主动停止,避免为已经不存在的客户端浪费资源。
不过“必须停止”也不是绝对规则。如果请求只是提交一个已经持久化的后台任务,客户端断开后任务可能应该继续,并通过 jobId 查询结果。同步下载和可靠后台任务要明确区分。
const workDirectory = await mkdtemp(
path.join(config.exportTempRoot, "job-"),
);
不要让多个请求共享同一个固定目录。
递归删除前应保证路径来自 mkdtemp(),并位于预期根目录。不要把请求参数直接交给 rm({ recursive: true })。
await rm(workDirectory, {
recursive: true,
force: true,
}).catch((cleanupError: unknown) => {
logger.error(
{ cleanupError, workDirectory },
"临时目录清理失败",
);
});
生产环境还应有定时兜底清理:只删除超过合理存活时间、名称符合本系统规则且不再被活动任务使用的目录。定时清理是兜底,不替代请求内 finally。
app.use((error: unknown, req: Request, res: Response, next: NextFunction) => {
logger.error({ error }, "Markdown ZIP 导出失败");
if (res.headersSent) {
next(error);
return;
}
// 此处再按业务错误映射 JSON
});
Express 的默认错误处理器在响应已经开始时可能关闭连接。业务代码也应首先停止 archive/stream,而不是试图发送第二个响应。
pipeline() 比简单的 .pipe() 多解决了哪些生命周期问题?res.close 时还要检查 res.writableFinished?AbortController 如何连接不原生支持 AbortSignal 的第三方库?finally?