Java 实现 ZIP 压缩包生成方案
文章目录
- [Java 实现 ZIP 压缩包生成方案](#Java 实现 ZIP 压缩包生成方案)
-
- [1. 先确定问题:ZIP 生成不是一次 API 调用](#1. 先确定问题:ZIP 生成不是一次 API 调用)
-
- [1.1 读者最终要解决什么](#1.1 读者最终要解决什么)
- [1.2 一句话结论](#1.2 一句话结论)
- [2. 方案选择:内存、临时文件还是异步对象存储](#2. 方案选择:内存、临时文件还是异步对象存储)
-
- [2.1 三种方案的评价维度](#2.1 三种方案的评价维度)
- [2.2 决策建议](#2.2 决策建议)
- [3. 端到端闭环与数据契约](#3. 端到端闭环与数据契约)
-
- [3.1 因果骨架](#3.1 因果骨架)
- [3.2 关键数据契约](#3.2 关键数据契约)
- [3.3 状态机(异步模式)](#3.3 状态机(异步模式))
- [4. 最小可运行闭环:纯 Java 17](#4. 最小可运行闭环:纯 Java 17)
-
- [4.1 Maven 配置](#4.1 Maven 配置)
- [4.2 完整示例:从目录收集到 ZIP 校验](#4.2 完整示例:从目录收集到 ZIP 校验)
- [4.3 代码契约与边界](#4.3 代码契约与边界)
- [4.4 为什么要先收集再写入](#4.4 为什么要先收集再写入)
- [5. 生产化核心实现:可插拔内容源与限制](#5. 生产化核心实现:可插拔内容源与限制)
-
- [5.1 依赖与配置](#5.1 依赖与配置)
- [5.2 内容源与输入对象](#5.2 内容源与输入对象)
- [5.3 配置类、结果类和异常](#5.3 配置类、结果类和异常)
- [5.4 `ZipArchiveService` 完整实现](#5.4
ZipArchiveService完整实现) - [5.5 实现中的关键取舍](#5.5 实现中的关键取舍)
- [6. Spring Boot 下载接口:鉴权、响应头与清理](#6. Spring Boot 下载接口:鉴权、响应头与清理)
-
- [6.1 注册配置与请求对象](#6.1 注册配置与请求对象)
- [6.2 Controller 示例](#6.2 Controller 示例)
- [6.3 统一异常响应](#6.3 统一异常响应)
- [7. 异步任务与对象存储:何时替换本地文件](#7. 异步任务与对象存储:何时替换本地文件)
-
- [7.1 触发条件](#7.1 触发条件)
- [7.2 替换点](#7.2 替换点)
- [8. 失败路径与排错矩阵](#8. 失败路径与排错矩阵)
-
- [8.1 日志和指标](#8.1 日志和指标)
- [9. 安全边界:生成侧和解压侧必须分别防护](#9. 安全边界:生成侧和解压侧必须分别防护)
-
- [9.1 生成侧](#9.1 生成侧)
- [9.2 解压侧](#9.2 解压侧)
- [10. 测试与可复现验收](#10. 测试与可复现验收)
-
- [10.1 JUnit 5 核心测试](#10.1 JUnit 5 核心测试)
- [10.2 命令行检查](#10.2 命令行检查)
- [10.3 三层验证结论](#10.3 三层验证结论)
- [11. 生产上线前清单](#11. 生产上线前清单)
- [12. 结论:如何选、何时改](#12. 结论:如何选、何时改)
内容摘要:本文给出 Java 17 中生成 ZIP 压缩包的可落地方案,覆盖输入清单确定、归档路径规划、流式写入、完整性校验、本地文件交付、Spring Boot 下载接口、异常清理、安全防护和测试验收。核心判断是:
ZipOutputStream只解决"如何写压缩流",不能单独保证结果正确;可靠实现必须同时约束输入快照、重复名称、大小上限、半成品隔离、下载权限和过期清理。
1. 先确定问题:ZIP 生成不是一次 API 调用
1.1 读者最终要解决什么
一个看似简单的"批量下载"功能,实际至少包含六个边界:
- 输入边界:哪些文件属于本次归档,生成期间源文件被删除或修改怎么办?
- 命名边界:多个文件重名时如何避免覆盖,用户提供的路径如何防止穿越?
- 资源边界:大文件和大量文件如何避免一次性加载到堆内存?
- 输出边界:如何确认 ZIP 已完整结束,而不是只生成了一个非空半成品?
- 交付边界:本地下载、对象存储短链和异步任务分别适用于什么规模?
- 生命周期边界:失败文件、过期文件和客户端中断后的临时文件如何清理?
1.2 一句话结论
推荐的默认实现是:先固定并校验输入快照,再按稳定的归档内路径排序,使用 ZipOutputStream 流式写入受控临时文件,关闭后用 ZipFile 校验并原子移动,最后通过鉴权下载并按保留期清理。
这个结论有一个重要边界:它适合中小规模、服务端可访问源文件的同步或短异步导出;当总量超过单机磁盘、生成时间明显超过 HTTP 超时或需要多实例共享时,应把"生成文件"替换为对象存储和异步任务,但仍保留同一套输入、命名、安全和校验契约。
2. 方案选择:内存、临时文件还是异步对象存储
2.1 三种方案的评价维度
| 方案 | 适用规模 | 优点 | 主要代价 | 失败恢复 |
|---|---|---|---|---|
ByteArrayOutputStream 内存生成 |
少量小文件 | 代码短,直接返回字节 | 内存峰值约等于 ZIP 大小,容易触发 GC/OOM | 失败即丢弃内存 |
| 本地临时文件生成 | 中小规模同步下载 | 内存稳定,易于 FileSystemResource 下载 |
依赖本机磁盘,集群需处理文件归属 | 保留任务记录后重试 |
| 异步任务 + 对象存储 | 大文件、大批量、多实例 | 不占用 HTTP 连接,可横向扩展 | 需要任务状态、对象存储和清理策略 | 可重试、可续传、可过期 |
2.2 决策建议
- 默认示例采用本地临时文件,因为它能完整展示 ZIP 生成、校验和 HTTP 下载,且不强绑定云厂商 SDK。
- 不建议默认把全部输入读进内存。压缩库本身是流式的,提前聚合只会把 I/O 问题转化为堆内存问题。
- 不建议 让 Controller 直接操作
ZipOutputStream。Controller 应负责鉴权和 HTTP 响应,Service 负责输入快照、压缩和结果契约,便于测试和未来切换对象存储。
3. 端到端闭环与数据契约
3.1 因果骨架
text
文件/字节流输入
│
▼
固定清单、权限检查、数量/大小限制
│
▼
归档内路径规范化、排序、去重
│
▼
写入 *.part 临时文件
│ └─ 每个条目:putNextEntry → copy → closeEntry
▼
关闭 ZIP 流并重新打开校验
│
├─ 失败:删除 *.part,记录 failureCode
│
▼
原子移动为正式文件或上传对象存储
│
▼
鉴权下载、短期链接、审计
│
▼
保留期清理与失败告警
读图要点 :正式文件只有在 ZIP 流关闭且可被 ZipFile 重新打开后才产生;因此客户端永远不应读取 .part 文件。这个状态隔离比"生成完再检查"更重要,因为并发下载可能在校验前读到半成品。
3.2 关键数据契约
| 对象 | 必填字段 | 不变量 | 失败语义 |
|---|---|---|---|
ZipEntrySpec |
归档内路径、内容源 | 路径唯一且不含 ..;内容源可读 |
参数错误或读取异常 |
ZipArchiveResult |
文件路径、条目数、源字节数、ZIP 字节数、SHA-256 | 文件已关闭、可重新打开 | 生成失败不返回结果 |
| 下载请求 | archiveId、当前用户 |
用户拥有归档访问权,归档未过期 | 404/403,不泄露真实路径 |
| 清理任务 | 截止时间 | 只删除受控目录下且已过期文件 | 删除失败告警并重试 |
3.3 状态机(异步模式)
#mermaid-svg-8QXlK0ahzNM3DYKw{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-8QXlK0ahzNM3DYKw .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-8QXlK0ahzNM3DYKw .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-8QXlK0ahzNM3DYKw .error-icon{fill:#552222;}#mermaid-svg-8QXlK0ahzNM3DYKw .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-8QXlK0ahzNM3DYKw .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-8QXlK0ahzNM3DYKw .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-8QXlK0ahzNM3DYKw .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-8QXlK0ahzNM3DYKw .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-8QXlK0ahzNM3DYKw .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-8QXlK0ahzNM3DYKw .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-8QXlK0ahzNM3DYKw .marker{fill:#333333;stroke:#333333;}#mermaid-svg-8QXlK0ahzNM3DYKw .marker.cross{stroke:#333333;}#mermaid-svg-8QXlK0ahzNM3DYKw svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-8QXlK0ahzNM3DYKw p{margin:0;}#mermaid-svg-8QXlK0ahzNM3DYKw defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-8QXlK0ahzNM3DYKw g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-8QXlK0ahzNM3DYKw g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-8QXlK0ahzNM3DYKw g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-8QXlK0ahzNM3DYKw g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-8QXlK0ahzNM3DYKw g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-8QXlK0ahzNM3DYKw .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-8QXlK0ahzNM3DYKw .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-8QXlK0ahzNM3DYKw .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-8QXlK0ahzNM3DYKw .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-8QXlK0ahzNM3DYKw .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-8QXlK0ahzNM3DYKw .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-8QXlK0ahzNM3DYKw .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-8QXlK0ahzNM3DYKw .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8QXlK0ahzNM3DYKw .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-8QXlK0ahzNM3DYKw .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8QXlK0ahzNM3DYKw .edgeLabel .label text{fill:#333;}#mermaid-svg-8QXlK0ahzNM3DYKw .label div .edgeLabel{color:#333;}#mermaid-svg-8QXlK0ahzNM3DYKw .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-8QXlK0ahzNM3DYKw .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-8QXlK0ahzNM3DYKw .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-8QXlK0ahzNM3DYKw .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-8QXlK0ahzNM3DYKw .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-8QXlK0ahzNM3DYKw .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-8QXlK0ahzNM3DYKw .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-8QXlK0ahzNM3DYKw #statediagram-barbEnd{fill:#333333;}#mermaid-svg-8QXlK0ahzNM3DYKw .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-8QXlK0ahzNM3DYKw .cluster-label,#mermaid-svg-8QXlK0ahzNM3DYKw .nodeLabel{color:#131300;}#mermaid-svg-8QXlK0ahzNM3DYKw .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-8QXlK0ahzNM3DYKw .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-8QXlK0ahzNM3DYKw .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-8QXlK0ahzNM3DYKw .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-8QXlK0ahzNM3DYKw .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-8QXlK0ahzNM3DYKw .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-8QXlK0ahzNM3DYKw .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-8QXlK0ahzNM3DYKw .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-8QXlK0ahzNM3DYKw .note-edge{stroke-dasharray:5;}#mermaid-svg-8QXlK0ahzNM3DYKw .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-8QXlK0ahzNM3DYKw .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-8QXlK0ahzNM3DYKw .statediagram-note text{fill:black;}#mermaid-svg-8QXlK0ahzNM3DYKw .statediagram-note .nodeLabel{color:black;}#mermaid-svg-8QXlK0ahzNM3DYKw .statediagram .edgeLabel{color:red;}#mermaid-svg-8QXlK0ahzNM3DYKw #dependencyStart,#mermaid-svg-8QXlK0ahzNM3DYKw #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-8QXlK0ahzNM3DYKw .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-8QXlK0ahzNM3DYKw :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} worker领取
校验通过并完成上传
读取/磁盘/上传失败
超过保留期
有限次重试
PENDING
RUNNING
SUCCEEDED
FAILED
EXPIRED
读图要点 :SUCCEEDED 的进入条件必须包含"文件可打开"和"交付地址已持久化",不能只依据压缩循环没有抛异常。FAILED 只允许从明确的失败原因恢复,不能通过人工把状态改成成功来掩盖半成品。
4. 最小可运行闭环:纯 Java 17
4.1 Maven 配置
xml
<properties>
<maven.compiler.release>17</maven.compiler.release>
</properties>
ZIP 生成使用 JDK 标准库,不需要额外压缩依赖。若项目采用 Spring Boot,再增加 Web、Validation 和测试依赖即可;不要为了基础 ZIP 写入引入与业务无关的第三方压缩框架。
4.2 完整示例:从目录收集到 ZIP 校验
以下代码是一个可独立运行的示例,路径均为示例数据,不代表生产目录。它展示了"固定清单 → 稳定排序 → 流式复制 → 重新打开校验"的闭环。
java
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;
public final class ZipDirectoryDemo {
private static final int BUFFER_SIZE = 8 * 1024;
private ZipDirectoryDemo() {
}
/**
* 将目录下的普通文件递归写入 ZIP,并在完成后校验条目列表。
*
* @param sourceRoot 源目录
* @param targetZip 目标 ZIP 文件
* @return 实际写入的条目数量
* @throws IOException 文件访问、压缩或校验失败
*/
public static int zipDirectory(Path sourceRoot, Path targetZip) throws IOException {
Path normalizedRoot = sourceRoot.toAbsolutePath().normalize();
if (!Files.isDirectory(normalizedRoot)) {
throw new IOException("源目录不存在或不是目录: " + normalizedRoot);
}
List<Path> files = collectRegularFiles(normalizedRoot);
List<String> expectedNames = files.stream()
.map(path -> toArchiveName(normalizedRoot, path))
.sorted()
.toList();
Path parent = targetZip.toAbsolutePath().normalize().getParent();
if (parent != null) {
Files.createDirectories(parent);
}
Path partFile = targetZip.resolveSibling(targetZip.getFileName() + ".part");
try {
try (OutputStream output = Files.newOutputStream(
partFile,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE);
ZipOutputStream zipOutput = new ZipOutputStream(output)) {
for (Path file : files.stream()
.sorted(Comparator.comparing(path -> toArchiveName(normalizedRoot, path)))
.toList()) {
String archiveName = toArchiveName(normalizedRoot, file);
ZipEntry entry = new ZipEntry(archiveName);
zipOutput.putNextEntry(entry);
try (InputStream input = Files.newInputStream(file)) {
copy(input, zipOutput);
} finally {
zipOutput.closeEntry();
}
}
zipOutput.finish();
}
validateZip(partFile, expectedNames);
Files.move(
partFile,
targetZip,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
return expectedNames.size();
} catch (IOException | RuntimeException ex) {
Files.deleteIfExists(partFile);
throw ex;
}
}
private static List<Path> collectRegularFiles(Path root) throws IOException {
List<Path> result = new ArrayList<>();
Files.walkFileTree(root, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
if (attrs.isRegularFile()) {
result.add(file.toAbsolutePath().normalize());
}
return FileVisitResult.CONTINUE;
}
});
return result;
}
private static String toArchiveName(Path root, Path file) {
String name = root.relativize(file).toString().replace('\\', '/');
validateArchiveName(name);
return name;
}
private static void validateArchiveName(String name) {
if (name.isBlank() || name.startsWith("/") || name.indexOf('\\0') >= 0) {
throw new IllegalArgumentException("非法 ZIP 条目名称");
}
String[] segments = name.split("/");
for (String segment : segments) {
if (segment.isBlank() || segment.equals(".") || segment.equals("..")) {
throw new IllegalArgumentException("ZIP 条目包含非法路径段: " + name);
}
}
}
private static void copy(InputStream input, OutputStream output) throws IOException {
byte[] buffer = new byte[BUFFER_SIZE];
int read;
while ((read = input.read(buffer)) != -1) {
output.write(buffer, 0, read);
}
}
private static void validateZip(Path zipFile, List<String> expectedNames) throws IOException {
Set<String> actualNames = new HashSet<>();
try (ZipFile zip = new ZipFile(zipFile.toFile())) {
zip.stream().forEach(entry -> actualNames.add(entry.getName()));
}
if (!actualNames.equals(new HashSet<>(expectedNames))) {
throw new IOException("ZIP 条目校验失败,expected=" + expectedNames + ", actual=" + actualNames);
}
}
public static void main(String[] args) throws IOException {
Path source = Files.createTempDirectory("zip-source-");
Files.createDirectories(source.resolve("reports"));
Files.writeString(source.resolve("README.txt"), "zip demo");
Files.writeString(source.resolve("reports/年报.csv"), "月份,金额\n1月,100\n");
Path target = source.resolveSibling("zip-result.zip");
int count = zipDirectory(source, target);
System.out.printf("created=%s, entries=%d, bytes=%d%n", target, count, Files.size(target));
}
}
4.3 代码契约与边界
| 项目 | 说明 |
|---|---|
| 输入 | sourceRoot 必须是目录;只收集普通文件,不跟随软链接进入归档 |
| 输出 | 目标 ZIP;成功时 .part 被原子移动为正式文件 |
| 不变量 | 条目名使用 /;每个输入文件对应一个唯一条目;正式文件可被 ZipFile 打开 |
| 失败 | 任一读取、压缩、校验或移动异常都会删除 .part 并向上抛出 |
| 证据 | ZipFile 重新打开并比对条目集合;仅证明示例数据闭环,不证明生产磁盘和并发行为 |
4.4 为什么要先收集再写入
直接在 walkFileTree 回调中写 ZIP 看起来更省代码,但会把"输入清单变化"和"压缩输出"耦合在一起:遍历到一半时文件可能被删除,条目顺序也会受文件系统影响。先固定清单并排序,才能做到可重试、可比较和可审计。代价是需要暂存路径清单;对百万级文件,应把清单存入数据库或分页生成任务,而不是无限增长内存列表。
5. 生产化核心实现:可插拔内容源与限制
5.1 依赖与配置
Spring Boot 3.x 的最小依赖示例:
xml
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
核心配置:
yaml
zip:
temp-dir: /var/app/zip-tmp
allowed-root: /var/app/export-source
max-entry-count: 10000
max-entry-size-bytes: 536870912
max-total-size-bytes: 2147483648
buffer-size: 8192
retention: 24h
download-url-expire: 10m
配置不是越大越好:max-total-size-bytes 同时约束磁盘占用和下载成本;buffer-size 只影响单个复制缓冲区,不会把 ZIP 变成内存文件;retention 必须与定时清理频率配套。生产环境还应通过启动检查确认临时目录存在、可写且所在文件系统有足够余量。
5.2 内容源与输入对象
java
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.OptionalLong;
@FunctionalInterface
public interface ZipContent {
/**
* 每次调用都返回一个新的输入流;调用方负责关闭。
*/
InputStream openStream() throws IOException;
/**
* 返回已知大小;未知时返回空。
*/
default OptionalLong declaredSize() {
return OptionalLong.empty();
}
static ZipContent fromPath(Path path) {
Path normalized = path.toAbsolutePath().normalize();
return new ZipContent() {
@Override
public InputStream openStream() throws IOException {
return Files.newInputStream(normalized);
}
@Override
public OptionalLong declaredSize() {
try {
if (!Files.isRegularFile(normalized, LinkOption.NOFOLLOW_LINKS)) {
return OptionalLong.empty();
}
return OptionalLong.of(Files.size(normalized));
} catch (IOException ex) {
return OptionalLong.empty();
}
}
};
}
}
java
import java.io.InputStream;
import java.util.Objects;
public record ZipEntrySpec(String archiveName, ZipContent content) {
public ZipEntrySpec {
Objects.requireNonNull(archiveName, "archiveName");
Objects.requireNonNull(content, "content");
}
}
对于数据库导出、对象存储下载或动态报表,ZipContent 只要求"可重新打开的流"。如果内容不可重复读取,就不能在失败后简单重试;应先把源内容固化为临时对象,或把任务设计为一次性不可重试并明确告警。
5.3 配置类、结果类和异常
java
import java.nio.file.Path;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "zip")
public class ZipArchiveProperties {
/** ZIP 临时文件目录。 */
private Path tempDir;
/** 允许作为输入来源的根目录。 */
private Path allowedRoot;
/** 单个归档允许的最大条目数。 */
private int maxEntryCount = 10_000;
/** 单个条目的最大原始字节数。 */
private long maxEntrySizeBytes = 512L * 1024 * 1024;
/** 所有条目的最大原始字节总数。 */
private long maxTotalSizeBytes = 2L * 1024 * 1024 * 1024;
/** 流复制缓冲区大小。 */
private int bufferSize = 8192;
/** 临时文件保留时间。 */
private Duration retention = Duration.ofHours(24);
public Path getTempDir() {
return tempDir;
}
public void setTempDir(Path tempDir) {
this.tempDir = tempDir;
}
public Path getAllowedRoot() {
return allowedRoot;
}
public void setAllowedRoot(Path allowedRoot) {
this.allowedRoot = allowedRoot;
}
public int getMaxEntryCount() {
return maxEntryCount;
}
public void setMaxEntryCount(int maxEntryCount) {
this.maxEntryCount = maxEntryCount;
}
public long getMaxEntrySizeBytes() {
return maxEntrySizeBytes;
}
public void setMaxEntrySizeBytes(long maxEntrySizeBytes) {
this.maxEntrySizeBytes = maxEntrySizeBytes;
}
public long getMaxTotalSizeBytes() {
return maxTotalSizeBytes;
}
public void setMaxTotalSizeBytes(long maxTotalSizeBytes) {
this.maxTotalSizeBytes = maxTotalSizeBytes;
}
public int getBufferSize() {
return bufferSize;
}
public void setBufferSize(int bufferSize) {
this.bufferSize = bufferSize;
}
public Duration getRetention() {
return retention;
}
public void setRetention(Duration retention) {
this.retention = retention;
}
}
java
import java.nio.file.Path;
public record ZipArchiveResult(
Path archivePath,
int entryCount,
long sourceBytes,
long archiveBytes,
String sha256) {
}
java
public class ZipArchiveException extends RuntimeException {
private final String failureCode;
public ZipArchiveException(String failureCode, String message, Throwable cause) {
super(message, cause);
this.failureCode = failureCode;
}
public String getFailureCode() {
return failureCode;
}
}
5.4 ZipArchiveService 完整实现
java
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;
import org.springframework.stereotype.Service;
@Service
public class ZipArchiveService {
private final ZipArchiveProperties properties;
public ZipArchiveService(ZipArchiveProperties properties) {
this.properties = properties;
}
/**
* 将多个内容源流式写入 ZIP 文件,并在发布前完成完整性校验。
*
* @param requestedEntries 输入条目;归档名必须唯一
* @param archiveFileName 目标文件名,只允许普通文件名
* @return 已发布的 ZIP 结果
* @throws ZipArchiveException 参数、读取、校验或文件系统失败
*/
public ZipArchiveResult create(List<ZipEntrySpec> requestedEntries, String archiveFileName) {
List<ZipEntrySpec> entries = snapshotAndValidate(requestedEntries);
Path tempDir = requireTempDir();
Path target = tempDir.resolve(validateFileName(archiveFileName)).normalize();
if (!target.getParent().equals(tempDir.toAbsolutePath().normalize())) {
throw new ZipArchiveException("INVALID_ARCHIVE_NAME", "目标文件名不能包含路径", null);
}
Path part = target.resolveSibling(target.getFileName() + ".part");
long sourceBytes = 0;
try {
Files.createDirectories(tempDir);
try (OutputStream output = Files.newOutputStream(
part,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE);
ZipOutputStream zipOutput = new ZipOutputStream(output)) {
byte[] buffer = new byte[properties.getBufferSize()];
for (ZipEntrySpec spec : entries) {
ZipEntry zipEntry = new ZipEntry(spec.archiveName());
zipOutput.putNextEntry(zipEntry);
try (InputStream input = spec.content().openStream()) {
long entryBytes = copyWithLimit(
input,
zipOutput,
buffer,
properties.getMaxEntrySizeBytes(),
sourceBytes,
properties.getMaxTotalSizeBytes());
sourceBytes = Math.addExact(sourceBytes, entryBytes);
} finally {
zipOutput.closeEntry();
}
}
zipOutput.finish();
}
validateZip(part, entries);
moveAsPublished(part, target);
return new ZipArchiveResult(
target,
entries.size(),
sourceBytes,
Files.size(target),
sha256(target));
} catch (IOException | ArithmeticException ex) {
deleteQuietly(part);
throw new ZipArchiveException("ZIP_CREATE_FAILED", "ZIP 生成失败", ex);
}
}
private List<ZipEntrySpec> snapshotAndValidate(List<ZipEntrySpec> requestedEntries) {
if (requestedEntries == null || requestedEntries.isEmpty()) {
throw new ZipArchiveException("EMPTY_ENTRIES", "至少需要一个 ZIP 条目", null);
}
if (requestedEntries.size() > properties.getMaxEntryCount()) {
throw new ZipArchiveException("ENTRY_COUNT_LIMIT", "超过最大条目数", null);
}
List<ZipEntrySpec> snapshot = new ArrayList<>(requestedEntries);
Set<String> names = new HashSet<>();
long declaredTotal = 0;
for (ZipEntrySpec spec : snapshot) {
validateArchiveName(spec.archiveName());
if (!names.add(spec.archiveName())) {
throw new ZipArchiveException("DUPLICATE_ENTRY", "重复 ZIP 条目: " + spec.archiveName(), null);
}
long declaredSize = spec.content().declaredSize().orElse(-1);
if (declaredSize > properties.getMaxEntrySizeBytes()) {
throw new ZipArchiveException("ENTRY_SIZE_LIMIT", "单条目超过大小限制", null);
}
if (declaredSize >= 0) {
declaredTotal = Math.addExact(declaredTotal, declaredSize);
}
}
if (declaredTotal > properties.getMaxTotalSizeBytes()) {
throw new ZipArchiveException("TOTAL_SIZE_LIMIT", "条目总大小超过限制", null);
}
snapshot.sort(java.util.Comparator.comparing(ZipEntrySpec::archiveName));
return List.copyOf(snapshot);
}
private Path requireTempDir() {
if (properties.getTempDir() == null) {
throw new ZipArchiveException("TEMP_DIR_MISSING", "未配置 ZIP 临时目录", null);
}
return properties.getTempDir().toAbsolutePath().normalize();
}
private String validateFileName(String fileName) {
if (fileName == null || fileName.isBlank()
|| fileName.contains("/")
|| fileName.contains("\\")
|| fileName.contains("..")
|| fileName.indexOf('\0') >= 0) {
throw new ZipArchiveException("INVALID_ARCHIVE_NAME", "非法归档文件名", null);
}
return fileName.endsWith(".zip") ? fileName : fileName + ".zip";
}
private void validateArchiveName(String name) {
if (name == null || name.isBlank() || name.startsWith("/")
|| name.contains("\\") || name.indexOf('\0') >= 0) {
throw new ZipArchiveException("INVALID_ENTRY_NAME", "非法 ZIP 条目名", null);
}
for (String segment : name.split("/", -1)) {
if (segment.isBlank() || segment.equals(".") || segment.equals("..")) {
throw new ZipArchiveException("INVALID_ENTRY_NAME", "ZIP 条目包含路径穿越片段", null);
}
}
}
private long copyWithLimit(
InputStream input,
OutputStream output,
byte[] buffer,
long entryLimit,
long alreadyWritten,
long totalLimit) throws IOException {
long entryBytes = 0;
int read;
while ((read = input.read(buffer)) != -1) {
entryBytes = Math.addExact(entryBytes, read);
long total = Math.addExact(alreadyWritten, entryBytes);
if (entryBytes > entryLimit) {
throw new IOException("单条目超过大小限制");
}
if (total > totalLimit) {
throw new IOException("条目总大小超过限制");
}
output.write(buffer, 0, read);
}
return entryBytes;
}
private void validateZip(Path part, List<ZipEntrySpec> expected) throws IOException {
Set<String> expectedNames = expected.stream()
.map(ZipEntrySpec::archiveName)
.collect(Collectors.toSet());
Set<String> actualNames = new HashSet<>();
try (ZipFile zipFile = new ZipFile(part.toFile())) {
zipFile.stream().forEach(entry -> actualNames.add(entry.getName()));
}
if (!actualNames.equals(expectedNames)) {
throw new IOException("ZIP 条目集合不一致");
}
}
private void moveAsPublished(Path part, Path target) throws IOException {
try {
Files.move(part, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException ex) {
Files.move(part, target, StandardCopyOption.REPLACE_EXISTING);
}
}
private String sha256(Path file) throws IOException {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream input = Files.newInputStream(file)) {
byte[] buffer = new byte[properties.getBufferSize()];
int read;
while ((read = input.read(buffer)) != -1) {
digest.update(buffer, 0, read);
}
}
return HexFormat.of().formatHex(digest.digest());
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("JDK 未提供 SHA-256", ex);
}
}
private void deleteQuietly(Path file) {
try {
Files.deleteIfExists(file);
} catch (IOException ignored) {
// 清理失败交给日志和定时清理任务兜底,不能覆盖原始失败原因。
}
}
}
5.5 实现中的关键取舍
- 声明大小与实际大小都检查 :路径文件可以先读
Files.size,动态流可能未知,所以复制循环仍必须计数。只检查声明值会被变化中的文件绕过。 - 先写
.part再发布:即使底层文件系统不支持原子移动,也要保证下载接口只暴露正式文件名;原子移动是增强保证,不是唯一防线。 - 对重复条目直接失败:自动编号更友好,但会改变调用方对文件名的预期。若业务需要自动编号,应把编号规则作为显式契约并记录原始名到新名的映射。
- 生成后计算 SHA-256:这会再次读取 ZIP,增加 I/O;当结果需要内容寻址、断点校验或对象存储对账时值得保留,否则可改为可选配置。
6. Spring Boot 下载接口:鉴权、响应头与清理
6.1 注册配置与请求对象
java
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties(ZipArchiveProperties.class)
public class ZipConfiguration {
}
java
import jakarta.validation.constraints.NotBlank;
public record ZipDownloadRequest(@NotBlank String archiveId) {
}
6.2 Controller 示例
java
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/zip-archives")
public class ZipDownloadController {
private final ZipArchiveProperties properties;
private final ZipArchiveAccessService accessService;
public ZipDownloadController(
ZipArchiveProperties properties,
ZipArchiveAccessService accessService) {
this.properties = properties;
this.accessService = accessService;
}
@GetMapping("/{archiveId}/download")
public ResponseEntity<Resource> download(@PathVariable String archiveId) throws IOException {
Path archive = accessService.requireReadableArchive(archiveId);
Resource resource = new FileSystemResource(archive);
String downloadName = archive.getFileName().toString();
ContentDisposition disposition = ContentDisposition.attachment()
.filename(downloadName, StandardCharsets.UTF_8)
.build();
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType("application/zip"))
.contentLength(Files.size(archive))
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
.header(HttpHeaders.CACHE_CONTROL, "private, max-age=0, no-store")
.body(resource);
}
}
上例中的 ZipArchiveAccessService 不是可省略的装饰层,它必须完成三件事:根据 archiveId 查询归档元数据、校验当前用户/租户权限、确认文件位于受控临时目录且未过期。Controller 不能直接把请求参数拼成文件路径,否则会把路径穿越和越权下载带进 HTTP 层。
6.3 统一异常响应
java
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class ZipExceptionHandler {
@ExceptionHandler(ZipArchiveException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, String> handle(ZipArchiveException ex) {
return Map.of(
"code", ex.getFailureCode(),
"message", "ZIP 处理失败");
}
}
对外响应不应直接返回本地路径、堆栈或底层 IOException 文本;内部日志可以保留 requestId、archiveId、failureCode、条目名摘要和耗时,且要对真实路径做脱敏或哈希化。
7. 异步任务与对象存储:何时替换本地文件
7.1 触发条件
出现以下任一情况,就不应继续把 ZIP 生成绑定在同步 HTTP 请求上:
- 预计生成时间可能超过网关或浏览器超时;
- 总文件量接近单机临时盘上限;
- 服务有多个实例,下载请求可能落到不同节点;
- 需要失败重试、进度展示、断点下载或审计记录;
- 归档结果需要跨节点、跨区域或长期保留。
7.2 替换点
将 ZipArchiveService.create 的输出从 Path 抽象为 ZipArchiveSink:
java
import java.io.IOException;
import java.io.OutputStream;
public interface ZipArchiveSink {
OutputStream open(String archiveId) throws IOException;
void publish(String archiveId, long size, String sha256) throws IOException;
}
本地实现把流写到 .part 文件;对象存储实现可以写入 multipart upload。无论底层是磁盘还是对象存储,以下契约不能改变:
- 生成完成前不可下载;
publish只有在 ZIP 可重新打开并通过条目校验后才能调用;- 任务状态和对象键必须一起持久化;
- 失败必须可区分"源读取失败""压缩失败""上传失败"和"清理失败"。
8. 失败路径与排错矩阵
| 场景 | 预期结果 | 半成品处理 | 观测字段 | 恢复动作 |
|---|---|---|---|---|
| 输入清单为空 | EMPTY_ENTRIES |
删除 .part |
archiveId, entryCount |
修正请求 |
| 条目名重复 | DUPLICATE_ENTRY |
不创建正式文件 | archiveName |
调整命名策略 |
| 文件不存在/无权限 | ZIP_CREATE_FAILED |
删除 .part |
entryHash, failureCode |
重新生成或跳过 |
| 单条目超限 | ENTRY_SIZE_LIMIT |
删除 .part |
entryName, bytesWritten |
拒绝或拆分归档 |
| 总大小超限 | TOTAL_SIZE_LIMIT |
删除 .part |
expectedBytes |
分批或异步处理 |
| ZIP 中央目录损坏 | 校验失败 | 删除 .part |
archivePath, durationMs |
重试并检查磁盘 |
| 原子移动不支持 | 降级普通移动 | 正式文件仍不可提前暴露 | filesystem |
保持目录隔离 |
| 客户端中断下载 | 下载失败但归档可保留 | 按保留期清理 | archiveId, bytesSent |
允许重新下载 |
| 清理任务失败 | 不影响已完成归档 | 暂不删除 | archiveId, age |
告警并重试 |
8.1 日志和指标
建议每次生成至少记录:
text
requestId, archiveId, userId(脱敏), entryCount,
declaredBytes, writtenBytes, archiveBytes,
durationMs, status, failureCode
指标建议包括:生成成功率、平均/分位耗时、输入总字节、压缩后字节、压缩比、失败原因分布、临时目录剩余空间和清理积压数量。不要记录文件内容,也不要把完整用户路径作为高基数指标标签。
9. 安全边界:生成侧和解压侧必须分别防护
9.1 生成侧
- 只允许来自白名单根目录的普通文件;默认不跟随软链接;
- 归档内名称拒绝绝对路径、反斜杠、
.、..、空路径段和 NUL; - 限制条目数量、单条目大小、总原始大小和临时目录占用;
- 根据用户/租户权限生成输入清单,不能只在下载时检查;
- 过滤密钥、配置、私钥、会话文件等敏感文件;
- 归档文件名使用服务端生成的 ID,不能直接信任用户输入。
9.2 解压侧
ZIP 生成安全不等于解压安全。任何后续解压程序都必须:
- 将条目名规范化后再解析目标路径;
- 确认目标路径以允许根目录为前缀;
- 限制条目数量、总展开大小、单文件大小和压缩比;
- 默认拒绝软链接、设备文件和嵌套归档;
- 解压到隔离目录,完成扫描后再交给业务使用。
Zip Slip 主要发生在解压侧,Zip Bomb 主要在解压展开阶段耗尽 CPU、磁盘或内存;只在生成端校验文件名,不能替代解压端防护。
10. 测试与可复现验收
10.1 JUnit 5 核心测试
java
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.io.ByteArrayInputStream;
import java.nio.file.Path;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipFile;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ZipArchiveServiceTest {
@TempDir
Path tempDir;
@Test
void shouldCreateZipAndKeepDeterministicEntryNames() throws Exception {
ZipArchiveProperties properties = new ZipArchiveProperties();
properties.setTempDir(tempDir);
properties.setMaxEntryCount(10);
properties.setMaxEntrySizeBytes(1024);
properties.setMaxTotalSizeBytes(4096);
ZipArchiveService service = new ZipArchiveService(properties);
ZipArchiveResult result = service.create(
List.of(
new ZipEntrySpec("b.txt", () -> new ByteArrayInputStream("B".getBytes(StandardCharsets.UTF_8))),
new ZipEntrySpec("a/中文.txt", () -> new ByteArrayInputStream("A".getBytes(StandardCharsets.UTF_8)))),
"demo");
assertEquals(2, result.entryCount());
try (ZipFile zipFile = new ZipFile(result.archivePath().toFile())) {
assertEquals(2, zipFile.size());
List<String> actualNames = zipFile.stream()
.map(entry -> entry.getName())
.collect(java.util.stream.Collectors.toCollection(ArrayList::new));
assertEquals(List.of("a/中文.txt", "b.txt"), actualNames);
}
}
@Test
void shouldRejectDuplicateEntryName() {
ZipArchiveProperties properties = new ZipArchiveProperties();
properties.setTempDir(tempDir);
ZipArchiveService service = new ZipArchiveService(properties);
assertThrows(ZipArchiveException.class, () -> service.create(
List.of(
new ZipEntrySpec("same.txt", () -> InputStream.nullInputStream()),
new ZipEntrySpec("same.txt", () -> InputStream.nullInputStream())),
"duplicate.zip"));
}
}
测试中的 findFirst() 不适合作为通用顺序断言,因为 ZIP 条目顺序应由实现的排序契约决定,测试最好收集名称后与期望列表逐项比较。上面的片段用于展示 API 形态;正式测试应补上排序断言、超限断言、失败清理断言和中文名读取断言。
10.2 命令行检查
bash
mvn -Dtest=ZipArchiveServiceTest test
unzip -l /var/app/zip-tmp/demo.zip
file /var/app/zip-tmp/demo.zip
sha256sum /var/app/zip-tmp/demo.zip
10.3 三层验证结论
| 层次 | 本文能证明什么 | 仍未证明什么 |
|---|---|---|
| 已证明 | 标准库流式 API、条目排序、临时文件隔离和 ZipFile 校验的实现路径 |
需要实际编译/测试执行来确认具体项目接线 |
| 未证明 | 真实对象存储、多实例共享盘、生产权限、网关超时和磁盘告警 | 这些都依赖部署拓扑与运行环境 |
| 下一步 | 在隔离环境执行大文件、并发导出、下载中断、清理失败和重试验收 | 通过后再决定同步或异步发布 |
11. 生产上线前清单
- 明确输入快照时点,以及源文件并发修改语义;
- 明确重复归档名是拒绝、编号还是按业务 ID 分目录;
- 配置并验证临时目录权限、磁盘配额和清理任务;
- 设置条目数、单文件、总大小和并发任务上限;
- 生成端和解压端分别完成 Zip Slip/Zip Bomb 防护;
- 下载接口按用户/租户鉴权,不从 URL 直接拼接物理路径;
- 成功状态只在 ZIP 校验和交付地址持久化后写入;
- 失败路径删除半成品,清理失败有告警和重试;
- 对关键指标、日志字段和审计保留期达成共识;
- 用真实规模压测决定同步、本地文件还是异步对象存储;
- 记录 JDK、Spring Boot、容器/主机文件系统和客户端兼容性。
12. 结论:如何选、何时改
如果文件数量和总大小可控、生成时间低于网关超时,采用本文的本地临时文件方案即可:实现简单,内存稳定,验证边界清晰。如果数据规模、并发量或部署拓扑超出单机边界,应保留同一套输入和校验契约,只替换输出 Sink 为异步任务加对象存储。
真正不能省略的是五个契约:输入快照、归档路径唯一性、流式资源管理、正式发布前校验、失败与过期清理。缺少其中任意一个,系统仍可能"生成了一个能下载的 ZIP",却无法证明内容完整、权限正确或长期可维护。
发布范围:后端方案文档,不涉及业务代码发布。
结论:本文已形成 Java ZIP 压缩包从输入收集、流式生成、完整性校验到 Spring Boot 下载和生命周期清理的可落地方案;正式接入项目时,优先确认规模上限、数据来源和交付模式。