yauzl 核心 API:打开 ZIP 并控制读取节奏

本章学习 yauzl 的入口 API、常用配置和 ZipFile。示例使用 TypeScript,并以当前项目“上传 ZIP 保存到临时文件”为主要场景。

1. 安装与导入

运行时依赖和类型声明是两件事:

npm install yauzl
npm install --save-dev @types/yauzl

yauzl 使用传统 CommonJS 导出。当前项目编译目标是 CommonJS,可以使用:

import * as yauzl from "yauzl";

如果工程开启了 esModuleInterop,也可能看到默认导入写法:

import yauzl from "yauzl";

不要为了复制某一种导入写法而盲目修改整个工程的模块配置,应以当前 tsconfig.json 和类型检查结果为准。

2. yauzl 常见的四种打开方式

API 输入 适合场景
open() 文件路径 Multer 已将 ZIP 保存到磁盘
fromBuffer() Buffer 很小且已有严格限制的内存上传
fromFd() 文件描述符 调用方已经打开文件并管理 fd
fromRandomAccessReader() 自定义随机读取器 ZIP 位于特殊存储或远程介质

新人在当前功能中重点掌握前两个即可。

3. 使用 open() 打开磁盘文件

yauzl 的原始 API 使用 Node.js 风格 callback:

import * as yauzl from "yauzl";

yauzl.open(
  "./temporary/upload.zip",
  {
    lazyEntries: true,
    autoClose: true,
    decodeStrings: true,
    validateEntrySizes: true,
    strictFileNames: true,
  },
  (error, zipFile) => {
    if (error) {
      console.error("无法打开 ZIP", error);
      return;
    }

    console.log("Entry 数量:", zipFile.entryCount);
    zipFile.readEntry();
  },
);

“打开成功”只说明 yauzl 已识别 ZIP 结构并创建 ZipFile。文件正文仍未全部解压。

open() 可能因为以下原因失败:

不能仅通过 .zip 扩展名判断它是合法 ZIP。

4. 推荐的打开配置

4.1 lazyEntries

lazyEntries: true

默认值是 false。默认模式会持续产生 entry 事件;开启后,只有调用:

zipFile.readEntry();

才会读取下一个 Entry。

推荐在服务端处理上传 ZIP 时开启,因为它让我们形成严格的顺序:

readEntry()
  → 校验当前 Entry
  → 读取或跳过当前 Entry
  → 当前项处理完成
  → 再次 readEntry()

这样更容易限制资源,也不会一口气启动大量异步任务。

4.2 autoClose

autoClose: true

使用 open() 时默认是 true。当 yauzl 正常读到 Central Directory 末尾后,会自动关闭底层文件资源。

但是 autoClose 不代表所有失败路径都无需处理。如果业务在中途拒绝一个 Entry,或者客户端已经断开,应该主动停止流程并调用:

zipFile.close();

同时仍要销毁正在读取或写入的 Stream。

4.3 decodeStrings

decodeStrings: true

默认值是 true。yauzl 会根据 ZIP 文件名相关标志,将文件名解码成字符串,日常业务一般保持默认值。

设置为 false 时,文件名及注释可能以原始 Buffer 形式提供,适合需要自行处理特殊编码的底层工具。这个模式会让 TypeScript 和路径检查都更复杂,不建议新人在普通上传接口中使用。

一些类型声明版本可能把 entry.fileName 固定标为 string,但关闭字符串解码后运行时语义会变化。若确实使用该高级配置,必须同时核对当前 yauzl 版本和类型声明,不要只相信编辑器提示。

4.4 validateEntrySizes

validateEntrySizes: true

默认值是 true。它帮助 yauzl 检查 Entry 声明的大小与实际解压数据是否一致:

保持开启有助于识别损坏或恶意构造的数据,但它不是业务大小限制

下面仍然是必要的:

if (entry.uncompressedSize > MAX_SINGLE_FILE_SIZE) {
  throw new Error("单个文件解压后过大");
}

还需要限制所有 Entry 的累计大小和 Entry 数量。

4.5 strictFileNames

strictFileNames: true

默认值是 false。ZIP 规范使用 / 作为路径分隔符,但现实中有些工具会写入 Windows 反斜杠 \

接收不可信上传时,可以开启严格模式,减少不同操作系统对路径解释不一致的问题。

不过它仍然不能代替应用自己的目标路径检查。应用必须确认最终写入路径位于临时根目录之内。

5. 用 Promise 包装 open()

async/await 项目中,可以只在适配层包装一次 callback:

import * as yauzl from "yauzl";

const ZIP_OPTIONS: yauzl.Options = {
  lazyEntries: true,
  autoClose: true,
  decodeStrings: true,
  validateEntrySizes: true,
  strictFileNames: true,
};

export function openZipFile(
  zipPath: string,
): Promise<yauzl.ZipFile> {
  return new Promise((resolve, reject) => {
    yauzl.open(zipPath, ZIP_OPTIONS, (error, zipFile) => {
      if (error) {
        reject(error);
        return;
      }

      resolve(zipFile);
    });
  });
}

使用方式:

const zipFile = await openZipFile(
  "./temporary/upload.zip",
);

这里的 Promise 只覆盖“打开 ZIP”这一步。打开完成之后,ZipFile 仍会通过 error 事件报告遍历或读取阶段的错误,所以后续代码仍必须注册事件监听器。

不要误以为:

const zipFile = await openZipFile(path);

执行成功就代表 ZIP 内所有文件均合法、均可正常解压。

6. 使用 fromBuffer()

如果 Multer 使用 memoryStorage(),上传结果位于:

req.file.buffer

可以这样打开:

export function openZipBuffer(
  buffer: Buffer,
): Promise<yauzl.ZipFile> {
  return new Promise((resolve, reject) => {
    yauzl.fromBuffer(
      buffer,
      {
        lazyEntries: true,
        decodeStrings: true,
        validateEntrySizes: true,
        strictFileNames: true,
      },
      (error, zipFile) => {
        if (error) {
          reject(error);
          return;
        }

        resolve(zipFile);
      },
    );
  });
}

fromBuffer() 的特点是:

因此只推荐在同时满足以下条件时使用:

例如,每个 ZIP 最多 20 MiB,并发 100 个请求,光上传 Buffer 理论上就可能接近 2 GiB,尚未计算解压和转换产生的数据。

7. ZipFile 的核心成员

entryCount

zipFile.entryCount

表示 Central Directory 中声明的 Entry 数量,可以做一次快速前置检查:

const MAX_ENTRY_COUNT = 500;

if (zipFile.entryCount > MAX_ENTRY_COUNT) {
  zipFile.close();
  throw new Error("ZIP 中的文件数量过多");
}

但后续遍历时仍应自己计数,不应把安全性只寄托在一个元数据字段上。

readEntry()

仅当 lazyEntries: true 时使用:

zipFile.readEntry();

它请求 yauzl 读取下一个目录项。调用后,结果通过 entryenderror 事件送达,不会直接作为返回值返回。

openReadStream()

zipFile.openReadStream(entry, callback);

为某个文件 Entry 创建正文读取流。它也可能失败,例如压缩方法不支持或 ZIP 数据损坏。

close()

zipFile.close();

主动关闭 ZipFile。中途拒绝、超时、客户端断开和业务取消时都可能需要它。

关闭 ZipFile 与清理已经写入的临时文件是两件事,仍要在业务层用 finally 删除工作目录。

8. ZipFile 的重要事件

事件 含义
entry 读到了一个目录项
end 所有 Central Directory Entry 已读完
error 解析或读取过程出错
close ZipFile 底层资源关闭

基本骨架:

const zipFile = await openZipFile(zipPath);

zipFile.on("entry", (entry) => {
  console.log(entry.fileName);
  zipFile.readEntry();
});

zipFile.once("error", (error) => {
  console.error("读取 ZIP 失败", error);
});

zipFile.once("end", () => {
  console.log("所有 Entry 已读取");
});

zipFile.once("close", () => {
  console.log("ZipFile 已关闭");
});

zipFile.readEntry();

endclose 不完全等价:

9. 常见错误认识

错误一:开启 validateEntrySizes 就不会遇到 ZIP 炸弹

它验证“声明是否匹配”,不限制声明本身有多大。一个合法声明解压后 50 GiB 的 Entry 仍然危险。

错误二:autoClose 会删除上传和解压文件

它只负责关闭 yauzl 使用的底层资源,不会删除任何业务临时文件。

错误三:调用一次 readEntry() 会返回 Entry

它没有返回 Entry。结果由事件异步送达。

错误四:fromBuffer() 就是纯流式、低内存

Entry 可以流式读取,但整个 ZIP 输入 Buffer 已经存在内存中。

错误五:strictFileNames 等于完整路径安全

它只是库的一层文件名规则。最终目标路径是否逃出工作目录,必须由应用再次验证。

本章复盘题

  1. open()fromBuffer() 分别适合什么输入?
  2. 为什么服务器读取用户上传 ZIP 时推荐设置 lazyEntries: true
  3. autoClose 能否清理解压到磁盘的临时文件?为什么?
  4. validateEntrySizes 与应用设置的 maxSingleEntrySize 有什么区别?
  5. strictFileNames: true 为什么仍不能代替目标路径检查?
  6. 用 Promise 包装 open() 后,为什么还要监听 ZipFileerror 事件?
  7. 使用 fromBuffer() 时,应该怎样估算最坏并发内存占用?
  8. end 事件和 close 事件各自描述什么状态?

官方参考