java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > Java ZIP压缩包生成

Java实现ZIP压缩包的生成方案

作者:Java小白笔记

本文给出 Java 17 中生成 ZIP 压缩包的可落地方案,覆盖输入清单确定、归档路径规划、流式写入、完整性校验、本地文件交付、Spring Boot 下载接口、异常清理、安全防护和测试验收,需要的朋友可以参考下

内容摘要:本文给出 Java 17 中生成 ZIP 压缩包的可落地方案,覆盖输入清单确定、归档路径规划、流式写入、完整性校验、本地文件交付、Spring Boot 下载接口、异常清理、安全防护和测试验收。核心判断是:ZipOutputStream 只解决“如何写压缩流”,不能单独保证结果正确;可靠实现必须同时约束输入快照、重复名称、大小上限、半成品隔离、下载权限和过期清理。

1. 先确定问题:ZIP 生成不是一次 API 调用

1.1 读者最终要解决什么

一个看似简单的“批量下载”功能,实际至少包含六个边界:

  1. 输入边界:哪些文件属于本次归档,生成期间源文件被删除或修改怎么办?
  2. 命名边界:多个文件重名时如何避免覆盖,用户提供的路径如何防止穿越?
  3. 资源边界:大文件和大量文件如何避免一次性加载到堆内存?
  4. 输出边界:如何确认 ZIP 已完整结束,而不是只生成了一个非空半成品?
  5. 交付边界:本地下载、对象存储短链和异步任务分别适用于什么规模?
  6. 生命周期边界:失败文件、过期文件和客户端中断后的临时文件如何清理?

1.2 一句话结论

推荐的默认实现是:先固定并校验输入快照,再按稳定的归档内路径排序,使用 ZipOutputStream 流式写入受控临时文件,关闭后用 ZipFile 校验并原子移动,最后通过鉴权下载并按保留期清理。

这个结论有一个重要边界:它适合中小规模、服务端可访问源文件的同步或短异步导出;当总量超过单机磁盘、生成时间明显超过 HTTP 超时或需要多实例共享时,应把“生成文件”替换为对象存储和异步任务,但仍保留同一套输入、命名、安全和校验契约。

2. 方案选择:内存、临时文件还是异步对象存储

2.1 三种方案的评价维度

方案适用规模优点主要代价失败恢复
ByteArrayOutputStream 内存生成少量小文件代码短,直接返回字节内存峰值约等于 ZIP 大小,容易触发 GC/OOM失败即丢弃内存
本地临时文件生成中小规模同步下载内存稳定,易于 FileSystemResource 下载依赖本机磁盘,集群需处理文件归属保留任务记录后重试
异步任务 + 对象存储大文件、大批量、多实例不占用 HTTP 连接,可横向扩展需要任务状态、对象存储和清理策略可重试、可续传、可过期

2.2 决策建议

3. 端到端闭环与数据契约

3.1 因果骨架

文件/字节流输入
      │
      ▼
固定清单、权限检查、数量/大小限制
      │
      ▼
归档内路径规范化、排序、去重
      │
      ▼
写入 *.part 临时文件
      │  └─ 每个条目:putNextEntry → copy → closeEntry
      ▼
关闭 ZIP 流并重新打开校验
      │
      ├─ 失败:删除 *.part,记录 failureCode
      │
      ▼
原子移动为正式文件或上传对象存储
      │
      ▼
鉴权下载、短期链接、审计
      │
      ▼
保留期清理与失败告警

读图要点:正式文件只有在 ZIP 流关闭且可被 ZipFile 重新打开后才产生;因此客户端永远不应读取 .part 文件。这个状态隔离比“生成完再检查”更重要,因为并发下载可能在校验前读到半成品。

3.2 关键数据契约

对象必填字段不变量失败语义
ZipEntrySpec归档内路径、内容源路径唯一且不含 ..;内容源可读参数错误或读取异常
ZipArchiveResult文件路径、条目数、源字节数、ZIP 字节数、SHA-256文件已关闭、可重新打开生成失败不返回结果
下载请求archiveId、当前用户用户拥有归档访问权,归档未过期404/403,不泄露真实路径
清理任务截止时间只删除受控目录下且已过期文件删除失败告警并重试

3.3 状态机(异步模式)

读图要点:SUCCEEDED 的进入条件必须包含“文件可打开”和“交付地址已持久化”,不能只依据压缩循环没有抛异常。FAILED 只允许从明确的失败原因恢复,不能通过人工把状态改成成功来掩盖半成品。

4. 最小可运行闭环:纯 Java 17

4.1 Maven 配置

<properties>
    <maven.compiler.release>17</maven.compiler.release>
</properties>

ZIP 生成使用 JDK 标准库,不需要额外压缩依赖。若项目采用 Spring Boot,再增加 Web、Validation 和测试依赖即可;不要为了基础 ZIP 写入引入与业务无关的第三方压缩框架。

4.2 完整示例:从目录收集到 ZIP 校验

以下代码是一个可独立运行的示例,路径均为示例数据,不代表生产目录。它展示了“固定清单 → 稳定排序 → 流式复制 → 重新打开校验”的闭环。

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 的最小依赖示例:

<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>

核心配置:

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 内容源与输入对象

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();
                }
            }
        };
    }
}
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 配置类、结果类和异常

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;
    }
}
import java.nio.file.Path;

public record ZipArchiveResult(
        Path archivePath,
        int entryCount,
        long sourceBytes,
        long archiveBytes,
        String sha256) {
}
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.4ZipArchiveService完整实现

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 实现中的关键取舍

  1. 声明大小与实际大小都检查:路径文件可以先读 Files.size,动态流可能未知,所以复制循环仍必须计数。只检查声明值会被变化中的文件绕过。
  2. 先写 .part 再发布:即使底层文件系统不支持原子移动,也要保证下载接口只暴露正式文件名;原子移动是增强保证,不是唯一防线。
  3. 对重复条目直接失败:自动编号更友好,但会改变调用方对文件名的预期。若业务需要自动编号,应把编号规则作为显式契约并记录原始名到新名的映射。
  4. 生成后计算 SHA-256:这会再次读取 ZIP,增加 I/O;当结果需要内容寻址、断点校验或对象存储对账时值得保留,否则可改为可选配置。

6. Spring Boot 下载接口:鉴权、响应头与清理

6.1 注册配置与请求对象

import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableConfigurationProperties(ZipArchiveProperties.class)
public class ZipConfiguration {
}
import jakarta.validation.constraints.NotBlank;

public record ZipDownloadRequest(@NotBlank String archiveId) {
}

6.2 Controller 示例

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 统一异常响应

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:

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。无论底层是磁盘还是对象存储,以下契约不能改变:

8. 失败路径与排错矩阵

场景预期结果半成品处理观测字段恢复动作
输入清单为空EMPTY_ENTRIES删除 .partarchiveId, entryCount修正请求
条目名重复DUPLICATE_ENTRY不创建正式文件archiveName调整命名策略
文件不存在/无权限ZIP_CREATE_FAILED删除 .partentryHash, failureCode重新生成或跳过
单条目超限ENTRY_SIZE_LIMIT删除 .partentryName, bytesWritten拒绝或拆分归档
总大小超限TOTAL_SIZE_LIMIT删除 .partexpectedBytes分批或异步处理
ZIP 中央目录损坏校验失败删除 .partarchivePath, durationMs重试并检查磁盘
原子移动不支持降级普通移动正式文件仍不可提前暴露filesystem保持目录隔离
客户端中断下载下载失败但归档可保留按保留期清理archiveId, bytesSent允许重新下载
清理任务失败不影响已完成归档暂不删除archiveId, age告警并重试

8.1 日志和指标

建议每次生成至少记录:

requestId, archiveId, userId(脱敏), entryCount,
declaredBytes, writtenBytes, archiveBytes,
durationMs, status, failureCode

指标建议包括:生成成功率、平均/分位耗时、输入总字节、压缩后字节、压缩比、失败原因分布、临时目录剩余空间和清理积压数量。不要记录文件内容,也不要把完整用户路径作为高基数指标标签。

9. 安全边界:生成侧和解压侧必须分别防护

9.1 生成侧

9.2 解压侧

ZIP 生成安全不等于解压安全。任何后续解压程序都必须:

  1. 将条目名规范化后再解析目标路径;
  2. 确认目标路径以允许根目录为前缀;
  3. 限制条目数量、总展开大小、单文件大小和压缩比;
  4. 默认拒绝软链接、设备文件和嵌套归档;
  5. 解压到隔离目录,完成扫描后再交给业务使用。

Zip Slip 主要发生在解压侧,Zip Bomb 主要在解压展开阶段耗尽 CPU、磁盘或内存;只在生成端校验文件名,不能替代解压端防护。

10. 测试与可复现验收

10.1 JUnit 5 核心测试

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 命令行检查

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. 生产上线前清单

12. 结论:如何选、何时改

如果文件数量和总大小可控、生成时间低于网关超时,采用本文的本地临时文件方案即可:实现简单,内存稳定,验证边界清晰。如果数据规模、并发量或部署拓扑超出单机边界,应保留同一套输入和校验契约,只替换输出 Sink 为异步任务加对象存储。

真正不能省略的是五个契约:输入快照、归档路径唯一性、流式资源管理、正式发布前校验、失败与过期清理。缺少其中任意一个,系统仍可能“生成了一个能下载的 ZIP”,却无法证明内容完整、权限正确或长期可维护。

发布范围:后端方案文档,不涉及业务代码发布。

结论:本文已形成 Java ZIP 压缩包从输入收集、流式生成、完整性校验到 Spring Boot 下载和生命周期清理的可落地方案;正式接入项目时,优先确认规模上限、数据来源和交付模式。

以上就是Java实现ZIP压缩包的生成方案的详细内容,更多关于Java ZIP压缩包生成的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文