跳至正文
开发工具 — ZIP 文件如何工作:从 DEFLATE 到流式写入与末尾索引

ZIP 文件如何工作:从 DEFLATE 到流式写入与末尾索引

AI 参与说明(Agent:Codex):本文依据公开的 ZIP 格式规范、DEFLATE 规范、zlib 手册和 Python 文档整理;文中的示例在 Python 3.14.4 上运行验证。示例数据均为独立构造。模型完整标识与 reasoning effort 未取得运行记录。

一个看似矛盾的问题

直觉上,压缩文件似乎必须先拿到完整的源文件,才能算出压缩结果和最终大小。那么,一个程序为何能一边接收数据,一边向同一个 ZIP 写入新文件?写入结束时又做了什么?

关键是把两层工作分开:DEFLATE 处理某个文件的字节;ZIP 组织多个文件及其索引。 ZIP 可以不压缩而只打包,也可以为不同文件选择不同的压缩方法。常见的 DEFLATE 是 ZIP 的一种压缩方法,不等于 ZIP 格式本身。PKWARE ZIP File Format Specification,4.1 节

英文术语中文名称在本文中的作用
Entry文件条目ZIP 中的一个文件及其元数据;每个 Entry 独立存储
DEFLATE无损压缩格式把一个 Entry 的字节编码为压缩字节
Local File Header本地文件头放在一个 Entry 的数据之前,记录文件名和压缩方法等
Data Descriptor数据描述符在数据之后补记该 Entry 的 CRC-32 与最终大小
Central Directory中央目录在归档末尾列出所有 Entry 的信息与位置
End of Central Directory(EOCD)中央目录结束记录在末尾指出 Central Directory 的位置、大小和 Entry 数量
ZIP64ZIP 的 64 位扩展普通字段装不下大小、偏移量或数量时使用

DEFLATE can encode a sequential input stream with bounded working storage. A ZIP writer can write Entry data before it knows the final sizes, then record those sizes and the archive index later. 这里的两项能力分别来自压缩格式和归档格式,不能混为一谈。RFC 1951、PKWARE 规范,4.3 节

第一层:DEFLATE 为什么能逐块处理

假设输入依次到来三块字节 A → B → C。DEFLATE 不需要在处理 A 时预知 C。它将输入表示为原样字节或对已出现内容的向后引用,再用 Huffman 编码保存这些符号;向后引用的距离最多为 32 KiB。它还把压缩数据组织为一系列 block,因此编码器可以保存有限的历史窗口和缓冲区,继续处理后续输入。RFC 1951,2 节

text
输入字节:A ── B ── C ── 结束信号
             │    │    │       │
             ▼    ▼    ▼       ▼
        DEFLATE 编码器 ──── 完成最后一个 block
             │    │    │       │
             └────┴────┴───────┴──→ 压缩字节流

“逐块处理”不表示每送入一块就立刻吐出一块输出。编码器可能暂存字节以形成 block,也可能等结束信号才输出最后一部分。以 zlib 接口为例,普通输入用 Z_NO_FLUSH,输入结束用 Z_FINISH。ZIP 的 DEFLATE Entry 保存的是 raw DEFLATE:没有 zlib 格式自带的头和校验尾;ZIP 在自己的记录中保存 CRC-32。zlib 手册

DEFLATE 的压缩率也不是“必须先看完整文件才有可能达到的唯一最优值”。不同编码器、参数和分块决策可以得到不同大小的合法输出。已经压缩过或接近随机的数据,进一步压缩可能效果很差,甚至因元数据而变大。ZIP 还允许 Stored 方法:直接保存原始字节,不做压缩。RFC 1951、PKWARE 规范,4.1 节

第二层:ZIP 文件的整体结构

一个典型的、没有加密或分卷的 ZIP 可按以下顺序理解。方括号代表记录,Data Descriptor 在这里用于不知道最终大小的流式写入;它不是每个 ZIP 都必须具备的记录。

text
文件开头
  [Entry A: Local File Header][A 的数据][A 的 Data Descriptor]
  [Entry B: Local File Header][B 的数据][B 的 Data Descriptor]
  ...
  [Central Directory: A、B、... 的目录项]
  [EOCD]
文件末尾

ZIP 没有要求先在文件开头写一张完整目录。每个 Entry 的 Local File Header 紧邻自己的数据;等所有 Entry 写完后,再汇总 Central Directory 和 EOCD。规范规定每个 Entry 都有对应的 Local File Header 与 Central Directory 项;一个有效 ZIP 必须有 EOCD。PKWARE 规范,4.3.1–4.3.6 节

记录典型标识字节主要内容写入时机
Local File Header50 4B 03 04文件名、方法、标志位,以及已知的 CRC-32 和大小一个 Entry 开始前
Data Descriptor常见为 50 4B 07 08,标识可省略最终 CRC-32、压缩前后大小一个 Entry 数据结束后,且使用 bit 3 时
Central Directory 项50 4B 01 02Entry 元数据、最终大小、Local File Header 的偏移量所有 Entry 完成后
EOCD50 4B 05 06目录位置、目录大小和 Entry 数量最后

表里的 50 4B 对应 ASCII 的 PK;这些只是识别记录的前四个字节,并非整条记录。ZIP 的多字节数值字段通常按 little-endian 存储,所以规范中的签名整数值看起来字节顺序相反。Data Descriptor 的标识并非必有,读取器不能仅靠扫描这四个字节判断边界。PKWARE 规范,4.2–4.3 节

写入过程:未知大小怎样留到最后

以两个普通文本 Entry 为例,写入器的时间线如下。这里的 A 和 B 只是教学用的名字,不代表某个应用的数据布局。

flowchart TB
    A["开始 A:写 Local File Header,设置 bit 3"] --> B["逐块处理 A;写压缩数据,累计 CRC-32 与大小"]
    B --> C["结束 A:完成 DEFLATE,写 Data Descriptor"]
    C --> D["开始并完成 B:重复相同过程"]
    D --> E["写 Central Directory:列出 A、B 的最终元数据与偏移量"]
    E --> F["写 EOCD;需要时补 ZIP64 记录"]
    F --> G["关闭输出:得到完整 ZIP"]

1. 写 Local File Header

写入器一开始已知道文件名和压缩方法,却可能不知道 CRC-32、原始大小与压缩后大小。它可以把通用标志位的 bit 3 设为 1,先写 Local File Header 和文件名;最终数值随后放进 Data Descriptor。若输出文件可 seek,另一种实现是先占位,处理完数据后回头改写 Local File Header。两种路径都能生成合法 ZIP;Data Descriptor 主要服务于不可回写的输出流。PKWARE 规范,4.3.7–4.3.9 节

2. 写数据并结束当前 Entry

每收到一块输入,写入器更新原始字节数和 CRC-32,把字节送入该 Entry 的编码器,并记录实际写出的压缩字节数。收到该 Entry 的结束信号后,它先完成 DEFLATE stream,再写 Data Descriptor。这样,在写下一个 Entry 之前,当前 Entry 已有确定的 CRC-32 和大小。Data Descriptor 的常见签名可选;若使用 ZIP64,大小字段改为 8 字节。PKWARE 规范,4.3.9 节

这说明两个不同的“尚未完成”:源数据可以边产生边进入当前 Entry;整个 ZIP 也可以在当前 Entry 完成后继续接收新的 Entry。 每个 Entry 有自己的压缩流。ZIP 不会跨 Entry 共用一个 DEFLATE 历史窗口,所以小文件很多时,重复的 Entry 元数据和压缩流开销会变得明显。PKWARE 规范,4.1.8 与 4.3.6 节

3. 汇总 Central Directory 与 EOCD

写入器记下每个 Local File Header 的位置。全部 Entry 结束后,Central Directory 为它们各写一个目录项,包含最终 CRC-32、大小、压缩方法、文件名和 Local File Header 偏移量;EOCD 再记录 Central Directory 的位置、大小与 Entry 数量。普通字段放不下这些数值时,ZIP64 提供扩展字段及相应的末尾记录。PKWARE 规范,4.3.12–4.3.16 节

EOCD 写完,输出关闭后,才得到结构完整的 ZIP。 在这之前,即使文件已有很多字节,普通 ZIP 阅读器仍可能因缺少 Central Directory 或 EOCD 而打不开它。中断后能否从残留字节恢复部分数据取决于额外的恢复工具和实际损坏位置,不能把它当作格式保证。

读取过程:为什么末尾索引重要

ZIP 阅读器通常先从文件末尾寻找 EOCD,再定位 Central Directory。目录项提供每个 Entry 的名称、方法、大小和 Local File Header 偏移量。要读取某个 Entry,阅读器据此跳到对应位置,越过 Local File Header 与文件名等字段,取得该 Entry 的压缩数据,解码并核对 CRC-32。PKWARE 规范,4.3.12–4.3.16 节

flowchart TB
    A["从文件末尾定位 EOCD"] --> B["根据 EOCD 找到 Central Directory"]
    B --> C["在目录中选择目标 Entry"]
    C --> D["跳到该 Entry 的 Local File Header"]
    D --> E["读取并解码 Entry 数据"]
    E --> F["核对 CRC-32"]

Central Directory 让阅读器不必从第一个 Entry 起逐个扫描,才能找到后面的文件;代价是输出在封口前尚没有完整索引。CRC-32 适合发现意外损坏,不是防篡改签名;若需要对来源或内容做安全认证,必须另设可信的校验机制。

“追加 ZIP”到底是哪一种操作

“追加”至少有两种不同含义:

  1. 向未封口的 ZIP writer 增加 Entry:先写 A,再写 B,最后写一次 Central Directory 和 EOCD。本文讨论的流式构建属于这一种。只要 writer 仍开放,就可以继续写新 Entry;不要求所有未来 Entry 事先存在。
  2. 修改已经封口的 ZIP:旧文件末尾已有 Central Directory 和 EOCD。要增加 Entry,必须让末尾索引包含新 Entry,并保持偏移量正确;不能在旧 EOCD 后面随意拼几个数据字节。具体库可能通过定位旧目录、写新 Entry、重建目录等方式完成。

此外,把两个各自完整的 ZIP 做字节拼接,也不会自动得到一个含两者全部 Entry 的标准 ZIP。ZIP 的目录与偏移量必须与最终文件结构一致。ZIP 支持增加、替换和删除文件,但这些操作需要遵守它的记录结构。PKWARE 规范,4.3.1–4.3.6 节

可运行示例:观察真实记录

以下示例只依赖 Python 标准库。它用一个不支持 seek 的输出对象写两个小文本 Entry,迫使 zipfile 使用 bit 3 和 Data Descriptor;随后直接读取 ZIP 字节,检查 Local File Header、Data Descriptor、Central Directory 和 EOCD 的位置。保存为 inspect_zip.py,运行 python3 inspect_zip.py。Python 文档确认 zipfile 支持写入不可 seek 的流,ZipFile.open(..., "w") 可逐块写 Entry。Python zipfile 文档

python
import struct
import tempfile
import zipfile
from pathlib import Path


class WriteOnly:
    def __init__(self, path: Path):
        self.file = path.open("wb")

    def write(self, data: bytes) -> int:
        return self.file.write(data)

    def flush(self) -> None:
        self.file.flush()

    def close(self) -> None:
        self.file.close()


with tempfile.TemporaryDirectory() as directory:
    path = Path(directory) / "example.zip"
    output = WriteOnly(path)  # 没有 seek/tell,不能回头改写文件头
    sizes = []

    with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as writer:
        for name in ("a.txt", "b.txt"):
            with writer.open(name, "w") as entry:
                for _ in range(10):
                    entry.write((name + "\n").encode() * 100)
            output.flush()
            sizes.append(path.stat().st_size)

    output.close()  # ZipFile 退出时已写 Central Directory 和 EOCD
    data = path.read_bytes()

    with zipfile.ZipFile(path) as reader:
        for info in reader.infolist():
            header = struct.unpack_from("<IHHHHHIIIHH", data, info.header_offset)
            signature, _, flags, method, _, _, _, _, _, name_len, extra_len = header
            assert signature == 0x04034B50  # Local File Header
            assert flags & 0x08 and method == 8  # bit 3、DEFLATE
            payload_end = info.header_offset + 30 + name_len + extra_len + info.compress_size
            descriptor = struct.unpack_from("<IIII", data, payload_end)
            assert descriptor == (0x08074B50, info.CRC, info.compress_size, info.file_size)
            print(info.filename, "descriptor OK")

        assert reader.testzip() is None

    eocd_at = data.rfind(b"PK\x05\x06")
    eocd = struct.unpack_from("<IHHHHIIH", data, eocd_at)
    _, _, _, _, entry_count, directory_size, directory_at, comment_len = eocd
    assert entry_count == 2 and comment_len == 0
    assert data[directory_at:directory_at + 4] == b"PK\x01\x02"
    assert directory_at + directory_size == eocd_at
    print("grew before closing:", sizes[0] < sizes[1])
    print("central directory and EOCD OK")

在 Python 3.14.4 上运行,输出为:

text
a.txt descriptor OK
b.txt descriptor OK
grew before closing: True
central directory and EOCD OK

这个示例故意只解析无注释、未触发 ZIP64 的小型归档,不能当通用 ZIP 解析器。它验证的是:Entry 数据先进入输出文件,各 Entry 后面确有 Data Descriptor,Central Directory 与 EOCD 在关闭 writer 后才齐备。testzip() 可检查解压时的 CRC 和文件头,但不能证明调用者原本打算加入的文件没有遗漏。

适用边界与常见误解

  • 内存:流式处理不要求把全部文件内容放进内存;但 writer 仍需维护 Entry 元数据,Entry 极多时,目录和实现自身的内存占用不可忽视。
  • 输入结束边界:编码器可以接收持续到来的字节,但必须知道某个 Entry 何时结束,才能写最终大小、CRC-32 和下一个 Entry。
  • 压缩效果:Stored 不压缩;DEFLATE 的压缩率取决于内容与编码器决策,ZIP 外壳本身不保证体积缩小。
  • 完整性:临时输出有字节不代表归档可正常打开;应等 EOCD 写完,再通过阅读器验证结构与内容。
  • 容量:普通字段有大小和数量上限;跨过这些边界时需要 ZIP64,并确认读写两端支持对应扩展。

归纳起来:DEFLATE 允许“现在处理已经到达的字节”,Data Descriptor 允许“稍后填写当前 Entry 的最终数值”,Central Directory 允许“最后统一建立所有 Entry 的索引”。 三者配合,才形成可逐步构建、最终仍可随机访问的 ZIP。RFC 1951、PKWARE ZIP File Format Specification

参考资料

本文共 3391 字,创建于 Sep 30, 2026

相关标签:Algorithms, Tools, ByAI

博客助手

正在打开博客助手…