07. 在 Express 5 中组合 Multer、yauzl 和 archiver

这一章把前面的能力接入 HTTP 接口。目标是让每一层只处理自己擅长的事情,避免一个 Route 同时负责上传、解压、安全检查、Markdown 转换、归档和清理。

1. 完整请求流程

认证和权限
  → 上传限流
  → Multer 接收 multipart/form-data
  → Controller 检查 req.file
  → Import Service 使用 yauzl 校验并提取
  → Markdown Service 转换和净化 HTML
  → Archive Service 使用 archiver 创建 ZIP
  → Express 返回下载
  → finally 清理工作目录

各层的职责不同:

Multer 的 fileSize 不能防止 ZIP 炸弹;文件扩展名和 mimetype 也不能证明上传内容确实是 ZIP。

2. 推荐的目录和分层

新人项目不需要复杂框架,可以先使用:

services/markdown-export/
├── markdown-export.router.ts
├── markdown-export.controller.ts
├── zip-import.service.ts
├── markdown-render.service.ts
├── zip-export.service.ts
└── markdown-export.types.ts

Router:描述中间件顺序

import { Router } from "express";
import { uploadMarkdownZip } from "./upload-middleware";
import { exportMarkdownZip } from "./markdown-export.controller";

export const markdownExportRouter = Router();

markdownExportRouter.post(
  "/markdown/export",
  requireLogin,
  exportRateLimit,
  uploadMarkdownZip.single("archive"),
  exportMarkdownZip,
);

认证和限流放在 Multer 前面,可以在服务器接收大文件前拒绝无权限或过于频繁的请求。

Controller:连接 HTTP 与业务流程

Controller 负责:

Service:不依赖 reqres

Service 接收明确参数:

interface ImportOptions {
  zipPath: string;
  extractionDirectory: string;
  signal: AbortSignal;
}

不要把整个 reqres 传进 yauzl Service。这样 Service 更容易单元测试,也不会在深层代码中意外发送第二次响应。

3. TypeScript 数据结构

export interface ZipImportLimits {
  maxEntries: number;
  maxSingleEntryBytes: number;
  maxTotalUncompressedBytes: number;
  maxCompressionRatio: number;
  maxPathDepth: number;
}

export interface ImportedFile {
  archivePath: string;
  localPath: string;
  size: number;
  kind: "markdown" | "asset";
}

export interface MarkdownProjectManifest {
  entryMarkdownPath: string;
  files: ImportedFile[];
  totalUncompressedBytes: number;
}

export interface MarkdownExportResult {
  outputZipPath: string;
  downloadName: string;
}

这些接口不是为了“看起来高级”,而是让不同 Service 对输入输出达成明确约定。

4. 先生成文件,再使用 res.download()

这是建议新人首先掌握的方案:

import type { NextFunction, Request, Response } from "express";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";

export async function exportMarkdownZip(
  req: Request,
  res: Response,
  next: NextFunction,
): Promise<void> {
  const workDirectory = await mkdtemp(
    path.join(tmpdir(), "markdown-export-"),
  );

  try {
    if (req.file === undefined) {
      res.status(400).json({
        code: "ARCHIVE_REQUIRED",
        message: "请选择 ZIP 文件",
      });
      return;
    }

    const result = await markdownExportService.convert({
      uploadedZipPath: req.file.path,
      workDirectory,
    });

    await new Promise<void>((resolve, reject) => {
      res.download(
        result.outputZipPath,
        result.downloadName,
        (error) => {
          if (error !== undefined) {
            reject(error);
            return;
          }

          resolve();
        },
      );
    });
  } catch (error: unknown) {
    next(error);
  } finally {
    await rm(workDirectory, {
      recursive: true,
      force: true,
    }).catch((cleanupError: unknown) => {
      logger.error(
        { cleanupError, workDirectory },
        "清理 Markdown 导出目录失败",
      );
    });
  }
}

这里必须使用 res.download() 的回调再认为传输结束。否则 Controller 可能在下载仍进行时进入 finally,提前删除 ZIP。

上传文件是否位于 workDirectory 取决于 Multer 配置。如果它在另一个临时目录,还要单独清理 req.file.path,并确保只删除服务器自己创建的明确路径。

5. 直接把 archiver 接到 res

当文件较大、不希望生成中间 ZIP 时,可以直接流式响应:

import archiver from "archiver";
import type { Response } from "express";
import { finished } from "node:stream/promises";

async function sendDirectoryAsZip(
  res: Response,
  sourceDirectory: string,
  downloadName: string,
): Promise<void> {
  const archive = archiver("zip", {
    zlib: { level: 6 },
  });

  archive.on("error", (error) => {
    res.destroy(error);
  });

  archive.on("warning", (error) => {
    if (error.code !== "ENOENT") {
      res.destroy(error);
    }
  });

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

  archive.pipe(res);
  archive.directory(sourceDirectory, false);

  const responseDone = finished(res);
  await archive.finalize();
  await responseDone;
}

直接响应时通常无法提前知道 Content-Length,Node.js 会按 HTTP 版本选择分块或其他传输方式。这不是错误。

6. headersSent 为什么重要

在 ZIP 还没写入响应前,可以返回 JSON:

res.status(422).json({
  code: "MARKDOWN_ENTRY_NOT_FOUND",
  message: "ZIP 中没有找到入口 Markdown",
});

但 archiver 一旦开始输出,响应头通常已经发送:

console.log(res.headersSent); // 很可能为 true

此时不能这样做:

// 错误:可能触发 ERR_HTTP_HEADERS_SENT
res.status(500).json({ message: "归档失败" });

错误处理中应区分:

app.use((error: unknown, req: Request, res: Response, next: NextFunction) => {
  if (res.headersSent) {
    next(error);
    return;
  }

  res.status(500).json({
    code: "INTERNAL_SERVER_ERROR",
    message: "服务器处理失败",
  });
});

对于已经开始的二进制响应,通常应该销毁流/连接并记录错误。前端会把它视为下载失败或文件不完整。

7. Express 5 能自动捕获什么错误

Express 5 会把 async Route 返回的 rejected Promise 交给错误中间件:

router.post("/export", async (req, res) => {
  throw new Error("会进入错误中间件");
});

但是 EventEmitter 的 error 不会自动进入这个 Promise:

archive.on("error", (error) => {
  // Express 不会仅因为这里出现 error 事件就自动调用 next(error)
});

所以应当使用 finished()pipeline() 或自己创建 Promise,把流的成功和失败纳入 await 链路。

8. 下载名称

function makeDownloadName(projectName: string): string {
  const safeName = projectName
    .normalize("NFC")
    .replace(/[\u0000-\u001F\u007F]/g, "")
    .replace(/[\\/:*?"<>|]/g, "-")
    .trim()
    .slice(0, 80);

  return `${safeName || "markdown-export"}.zip`;
}

然后交给 Express:

res.attachment(makeDownloadName(projectName));

不要将这个“下载展示名”用于服务器临时路径。服务器路径应该使用 mkdtemp() 或 UUID 生成,与用户名称分开。

9. 一条实用的选型原则

初版功能建议:

上传到磁盘
  → yauzl 流式提取到隔离目录
  → 转换到专用 output 目录
  → archiver 生成临时 ZIP
  → res.download()
  → 下载回调结束后 finally 清理

等你已经有监控、资源限制和完整的中止处理,再考虑 archive.pipe(res),而不是一开始为了少一次磁盘 I/O 增加错误处理难度。

复盘题

  1. Multer 的 fileSize 为什么不能防止 ZIP 炸弹?
  2. 为什么建议认证和限流放在 Multer 前面?
  3. 为什么 Service 不应接收整个 reqres
  4. 为什么使用 res.download() 回调后才能删除临时 ZIP?
  5. Express 5 为什么不能自动捕获 EventEmitter 的 error 事件?
  6. res.headersSent === true 后发生错误,为什么不能再返回 JSON?
  7. 服务器临时文件名和用户看到的下载名为什么应该分开?

官方参考