08. 错误处理、资源清理与客户端中止

ZIP 转换会同时占用文件句柄、读取流、写入流、CPU、临时磁盘和 HTTP 连接。仅仅向客户端返回一个 500,并不代表这些工作已经停止。

本章的核心原则是:

响应失败、停止后台工作和释放资源是三件事,需要分别处理。

1. 建立稳定的业务错误

不要把 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 响应中不要泄露服务器绝对路径和依赖实现细节。

2. try/finally 是清理的主干

const workDirectory = await mkdtemp(
  path.join(tmpdir(), "markdown-export-"),
);

try {
  await convertProject(workDirectory);
} finally {
  await rm(workDirectory, {
    recursive: true,
    force: true,
  });
}

无论转换成功、抛错还是取消,finally 都会执行。但要注意:

因此,顺序应当是:

发出取消信号
  → 停止/销毁各个流和库对象
  → 等待正在执行的 Promise 结束
  → 删除临时目录

3. 各对象如何停止

yauzl

zipFile.close();

它停止继续读取 ZIP,并关闭底层文件描述符。已经打开的 Entry ReadStream 仍应显式销毁:

entryStream.destroy(abortError);

archiver

archive.abort();

abort() 会尽快中止 archiver 当前队列,但不要假设它会替你关闭所有由业务创建的输入/输出流。仍需处理目标流和完成 Promise。

Node.js Stream

source.destroy(abortError);
destination.destroy(abortError);

对于由 pipeline() 连接的流,某一端失败时 pipeline 会协助销毁其他流并拒绝 Promise:

await pipeline(source, destination, { signal });

优先使用 pipeline(),不要只用 source.pipe(destination) 后就认为错误和背压全部处理完毕。

4. 使用 AbortController 统一传播取消

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);
  };
}

5. 客户端主动断开如何识别

在直接输出 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 和输出响应所处阶段不同。

6. 一个直接输出 ZIP 的取消骨架

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 是一些代理和日志系统用来表示客户端关闭请求的非标准约定;通常已经无法把它真正发送给断开的客户端,主要用于内部错误分类和监控。

7. 响应之后,任务会不会自动停止

不会。

以下动作都不会自动取消仍在运行的 ZIP 工作:

如果没有把中止信号传入业务层,CPU 转换、文件读取、ZIP 压缩和临时文件写入可能继续运行。高负载导出尤其应主动停止,避免为已经不存在的客户端浪费资源。

不过“必须停止”也不是绝对规则。如果请求只是提交一个已经持久化的后台任务,客户端断开后任务可能应该继续,并通过 jobId 查询结果。同步下载和可靠后台任务要明确区分。

8. 清理临时目录的注意事项

每请求独立目录

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

9. 已发送响应后的错误处理

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,而不是试图发送第二个响应。

复盘题

  1. 为什么“返回 500”不等于“后台 ZIP 转换已经停止”?
  2. yauzl、archiver 和普通 Stream 分别如何主动停止?
  3. pipeline() 比简单的 .pipe() 多解决了哪些生命周期问题?
  4. 为什么监听 res.close 时还要检查 res.writableFinished
  5. AbortController 如何连接不原生支持 AbortSignal 的第三方库?
  6. 为什么清理错误不应覆盖原始业务错误?
  7. 同步下载任务和可靠后台任务在客户端断开后的策略有什么区别?
  8. 为什么还需要定时清理,而不能只依赖 finally

官方参考