ZIP 导入与导出工作流

本章先建立整体认识:为什么 Node.js 的 zlib 不能代替 ZIP 库,以及 yauzlarchiver 在 Markdown 转换服务中分别负责什么。

1. 先看最终要完成的事情

当前 Markdown ZIP 转换功能可以概括为:

用户上传 ZIP
  ↓
Multer 将上传文件保存到临时目录
  ↓
yauzl 逐个读取 ZIP 中的目录项(Entry)
  ↓
校验路径、数量、大小和允许的文件类型
  ↓
提取 Markdown、图片等文件
  ↓
Markdown 转换成 HTML
  ↓
archiver 将 HTML 和资源重新打包为 ZIP
  ↓
Express 返回下载响应
  ↓
清理临时文件

这里有两条方向相反的流水线:

方向 主要工作
导入 yauzl 打开并读取已有 ZIP
导出 archiver 创建一个新的 ZIP

不要把两个库混为一谈。yauzl 不负责把 HTML 打包输出,archiver 也不是用来安全检查用户上传 ZIP 的主要工具。

2. ZIP 和压缩算法不是一回事

ZIP 是一种归档容器格式。一个 ZIP 可以包含多个文件、目录及其元数据,例如:

markdown-project.zip
├── README.md
├── chapters/
│   └── getting-started.md
└── assets/
    └── architecture.png

ZIP 内除了文件正文,还有:

Deflate 则是一种压缩算法,它关心的是“怎样把一段字节压小”,并不知道一个归档中有哪些文件。

可以用一个不严格但容易记忆的比喻:

Deflate = 折叠衣服的方法
ZIP     = 装有多件衣服、标签和清单的行李箱

3. node:zlib 为什么不够

Node.js 内置的 node:zlib 提供 gzip、Deflate、Brotli 等压缩流:

import { createReadStream, createWriteStream } from "node:fs";
import { pipeline } from "node:stream/promises";
import { createGzip } from "node:zlib";

await pipeline(
  createReadStream("report.md"),
  createGzip(),
  createWriteStream("report.md.gz"),
);

这会得到一个 .gz 文件,但它没有替我们实现完整的 ZIP 目录结构,也不能让我们方便地列出 ZIP 中所有成员。

因此:

yauzl 内部仍可能使用 Node.js 的解压能力来处理 Deflate 数据,但它额外实现了 ZIP 格式的解析。

4. ZIP 为什么能先列出文件

一个普通 ZIP 的概念结构大致如下:

[文件 A 的本地头部][文件 A 数据]
[文件 B 的本地头部][文件 B 数据]
[文件 C 的本地头部][文件 C 数据]
[Central Directory]
[End of Central Directory]

Central Directory 像一张总清单,记录每个文件的名称、大小、压缩方式和数据位置。yauzl 会利用它产生一个个 Entry

注意:Entry 主要是元数据,收到 entry 事件并不表示文件正文已经全部读取到内存。真正需要正文时,再调用:

zipFile.openReadStream(entry, callback);

得到可读取的数据流。

这种“先读目录项,需要时才打开正文流”的设计,正适合服务器处理不可信上传文件。

5. yauzl 为什么看起来比 extractAll() 麻烦

yauzl 是相对底层的 ZIP 读取库。它通常要求我们自己完成:

  1. 打开 ZIP;
  2. 读取下一个 Entry;
  3. 检查 Entry;
  4. 打开该 Entry 的读取流;
  5. 把流写入安全位置;
  6. 当前项完成后再读取下一项。

这确实比“一行解压全部文件”多一些代码,但它让应用有机会在每个文件写入之前检查:

对于服务端接收用户上传文件,这种控制能力比 API 简短更重要。

6. 为什么使用流

假设 ZIP 中有一个 300 MiB 的图片。如果把整个文件收集成 Buffer:

ZIP 文件 Buffer
+ 解压后的图片 Buffer
+ 转换过程中的临时数据
+ 多个并发请求

Node.js 内存可能迅速增加。

流式处理的思路是:

yauzl Entry ReadStream
          ↓ 一小块一小块读取
     文件 WriteStream

后续代码会使用:

import { pipeline } from "node:stream/promises";

await pipeline(entryStream, outputStream);

pipeline() 会连接读取端和写入端,并处理背压:如果磁盘暂时写不过来,读取端会减慢,而不是无限制把数据堆入内存。

7. 磁盘临时文件还是内存 Buffer

上传 ZIP 保存到磁盘

适合当前服务的默认方案:

Multer DiskStorage
  → yauzl.open(zipPath)
  → 逐项读取

优点:

代价是必须可靠清理临时目录。

上传 ZIP 保存在 Buffer

也可以使用:

Multer MemoryStorage
  → yauzl.fromBuffer(req.file.buffer)

适合体积非常小、数量和并发都有严格上限的文件。虽然 Entry 正文仍可流式读取,但整个 ZIP Buffer 已经在内存中,直到 ZipFile 和 Buffer 不再被引用。

因此,fromBuffer() 并不等于“没有内存风险”。

8. archiver 在导出阶段做什么

转换完成后,archiver 接收文件、目录、Buffer 或 Stream,并把生成的 ZIP 写到输出流:

生成的 index.html ─┐
assets/ 目录 ──────┼→ archiver → result.zip 或 Express Response
manifest.json ─────┘

最小概念示例:

import archiver from "archiver";
import { createWriteStream } from "node:fs";

const output = createWriteStream("result.zip");
const archive = archiver("zip", {
  zlib: { level: 6 },
});

archive.pipe(output);
archive.file("work/index.html", { name: "index.html" });
archive.directory("work/assets", "assets");

await archive.finalize();

这一章只需记住:要先监听错误、再添加内容、最后调用 finalize()。后续 archiver 专章会讨论输出到 Express Response 时的错误和中断问题。

9. 三种不同的安全问题

当前服务同时面对三类风险,不能只解决其中一种:

层次 典型风险 主要处理位置
ZIP 结构 Zip Slip、ZIP 炸弹、过多 Entry yauzl 遍历阶段
文件内容 伪造扩展名、危险 SVG、恶意文档 文件验证或扫描阶段
HTML 内容 Markdown 内嵌 HTML、脚本、危险 URL Markdown 渲染和 HTML 清理阶段

例如,路径检查通过只代表文件不会写出临时目录,并不代表该文件内容安全。

10. 新人阶段的推荐实现顺序

不要第一次就把全部功能写在一个 Express Route 中。可以按以下顺序练习:

  1. 用 yauzl 列出 ZIP 中的 Entry,不写文件;
  2. 一次只提取一个已知的小文件;
  3. 使用 pipeline() 逐项写入临时目录;
  4. 加入路径、数量和大小限制;
  5. 转换 Markdown;
  6. 用 archiver 把固定目录打包到磁盘;
  7. 最后接入 Express 上传、下载和客户端中断处理。

这样每一步都能单独验证,出现问题时也容易判断属于 ZIP、转换还是 HTTP 层。

本章复盘题

  1. 为什么 node:zlib 可以生成 .gz,却不能直接替代 yauzl 列出 ZIP 中的多个文件?
  2. ZIP 中的 Central Directory 起什么作用?
  3. yauzl 的 Entry 和 Entry 文件正文有什么区别?
  4. 为什么服务端处理用户上传 ZIP 时,逐项读取通常比 extractAll() 更适合?
  5. fromBuffer() 能流式读取 Entry,为什么仍然可能带来内存压力?
  6. pipeline() 中的背压解决了什么问题?
  7. ZIP 路径安全、文件内容安全与最终 HTML 安全为什么必须分别处理?

官方参考