XLSX 是一组有关系约束的 ZIP 部件。导出要在不能回写的输出流上写 Local Header、Data Descriptor 和 Central Directory;导入则要从工作簿关系(workbook relationship)找到真正的工作表。本文把两条路径放在同一个包结构里解释。
XLSX 不是"一个 XML 文件",而是一个 ZIP 包:
text
[Content_Types].xml
_rels/.rels
xl/workbook.xml
xl/_rels/workbook.xml.rels
xl/worksheets/...
xl/styles.xml
xl/sharedStrings.xml(可选)
导出和导入面对的是同一个包,只是方向相反:
text
导出:对象 → XML part → ZIP entry → 输出流
导入:输入流 → ZIP entry → XML part → 对象
导出:为什么要支持顺序写
HTTP 响应流通常不支持 Seek。写一个 ZIP entry 时,Local Header 在数据之前,但 CRC、压缩后尺寸和未压缩尺寸要等数据写完才知道。
ZIP 的 Data Descriptor 允许 Local Header 先把这些字段留空:
text
Local Header(CRC / size = 0)
压缩数据
Data Descriptor(真实 CRC / size)
ForwardOnlyZipWriter 用这个机制写 entry。entry 完成后,再把已经得到的 CRC、尺寸和偏移保存为 Central Directory 元数据;所有 entry 完成后,统一在文件尾写 Central Directory。
ZIP Entry 的数据流
一条 entry 的数据流是:
text
业务 XML 字节
↓
EntryWriteStream
├─ 统计未压缩大小
├─ 更新未压缩数据的 CRC32
↓
ICompressor / DeflateStream
↓
CountingWriteStream
├─ 统计压缩后大小
↓
底层输出流
这不是"任意几层 Stream 都能随便拼"的模式演示。CRC、压缩、计数和 Data Descriptor 共享 entry 生命周期:压缩器必须 Finish,才能得到最终压缩尺寸;Data Descriptor 必须在尺寸和 CRC 确定后写入。
导入:不能假设第一个工作表叫 sheet1.xml
读取时,最简单的实现会直接打开:
text
xl/worksheets/sheet1.xml
外部工具生成的工作簿不保证这个路径就是逻辑上的第一个 sheet。Reader 当前读取第一个可读 worksheet,定位路径来自:
text
xl/workbook.xml
↓ sheet 的 r:id
xl/_rels/workbook.xml.rels
↓ Relationship.Target
xl/worksheets/实际文件.xml
XlsxReader 先根据工作簿关系(workbook relationship)找第一个可读 worksheet;关系文件缺失时,才回退到 worksheets 目录中的 XML entry。这让 Reader 能处理工作表路径不按默认命名的 XLSX。
同一个边界,读写两边都要守
| 约束 | 导出侧 | 导入侧 |
|---|---|---|
| 关系文件 | 写出 workbook 与各 part 的关系 | 根据关系定位 worksheet |
| Shared Strings | 最后写出 sharedStrings.xml | 初始化时加载 sharedStrings.xml |
| ZIP 顺序 | Data Descriptor + Central Directory | ZipArchive 打开现有包 |
| 流所有权 | Write(Stream, ...) 由调用方释放 |
Read(Stream, ...) 默认在枚举结束时关闭 |
ZIP32 的范围
当前 writer 不支持 ZIP64。单个 entry、归档偏移和 Central Directory 尺寸受 ZIP32 的 32 位字段限制;entry 数量和名称长度还有 16 位字段限制。超过限制时应拆分文件、拆分 sheet,或改用支持 ZIP64 的方案。