1. 概述

有时我们需要让 REST API 支持下载 ZIP 压缩包,这能有效减少网络传输负担。但直接使用默认配置开发这类接口时,可能会踩一些坑。

本文将演示如何通过 @RequestMapping 注解让接口输出 ZIP 文件,并探讨几种实现方案。

2. 字节数组方式生成 ZIP

最直接的方式是将 ZIP 文件转为字节数组直接返回。先创建一个返回字节流的 REST 接口:

@GetMapping(value = "/zip-as-byte", produces = "application/zip")
public ResponseEntity<byte[]> getZipFileAsByteArray() throws IOException {
    ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
    ZipOutputStream zipOutputStream = new ZipOutputStream(byteArrayOutputStream);
    
    addFilesToArchive(zipOutputStream);
    
    zipOutputStream.close();
    
    return ResponseEntity.ok()
            .header("Content-Disposition", "attachment; filename=files.zip")
            .body(byteArrayOutputStream.toByteArray());
}

关键点解析:

  • 使用 @GetMapping 简化 @RequestMapping 配置
  • produces 指定 application/zip MIME 类型
  • 通过 ByteArrayOutputStream + ZipOutputStream 构建压缩包
  • 设置 Content-Disposition 头实现下载功能

实现 addFilesToArchive() 方法添加文件:

private void addFilesToArchive(ZipOutputStream zipOutputStream) throws IOException {
    Resource resource1 = new ClassPathResource("test-file1.txt");
    Resource resource2 = new ClassPathResource("test-file2.txt");
    
    addToZipFile(resource1, zipOutputStream);
    addToZipFile(resource2, zipOutputStream);
}

private void addToZipFile(Resource resource, ZipOutputStream zipOutputStream) throws IOException {
    ZipEntry zipEntry = new ZipEntry(resource.getFilename());
    zipOutputStream.putNextEntry(zipEntry);
    
    StreamUtils.copy(resource.getInputStream(), zipOutputStream);
    
    zipOutputStream.closeEntry();
}

测试接口:

@Test
public void whenDownloadZipAsByteArray_thenSuccess() throws IOException {
    ResponseEntity<byte[]> response = restTemplate.getForEntity("/zip-as-byte", byte[].class);
    
    assertEquals(HttpStatus.OK, response.getStatusCode());
    assertEquals("attachment; filename=files.zip", 
        response.getHeaders().getFirst("Content-Disposition"));
    
    File zipFile = new File("downloaded-byte-array.zip");
    Files.write(zipFile.toPath(), response.getBody());
    
    List<String> fileNames = fetchFileNamesFromArchive(zipFile);
    assertTrue(fileNames.contains("test-file1.txt"));
    assertTrue(fileNames.contains("test-file2.txt"));
}

⚠️ 注意:这种方式只适合小文件,大文件会吃光堆内存!因为 ByteArrayInputStream 会把整个 ZIP 文件加载到内存中。

3. 流式传输 ZIP

对于大文件,应该避免全量加载到内存。更优雅的方案是边生成边传输

@GetMapping(value = "/zip-as-stream", produces = "application/zip")
public void getZipFileAsStream(HttpServletResponse response) throws IOException {
    response.setContentType("application/zip");
    response.setHeader("Content-Disposition", "attachment; filename=files.zip");
    
    ZipOutputStream zipOutputStream = new ZipOutputStream(response.getOutputStream());
    
    addFilesToArchive(zipOutputStream);
    
    zipOutputStream.close();
}

核心改进:

  • 直接使用 HttpServletResponse 的输出流
  • 数据直接流向客户端,不占用堆内存
  • 适合 GB 级大文件传输

测试验证:

@Test
public void whenDownloadZipAsStream_thenSuccess() throws IOException {
    ResponseEntity<byte[]> response = restTemplate.getForEntity("/zip-as-stream", byte[].class);
    
    assertEquals(HttpStatus.OK, response.getStatusCode());
    assertEquals("attachment; filename=files.zip", 
        response.getHeaders().getFirst("Content-Disposition"));
    
    File zipFile = new File("downloaded-stream.zip");
    Files.write(zipFile.toPath(), response.getBody());
    
    List<String> fileNames = fetchFileNamesFromArchive(zipFile);
    assertTrue(fileNames.contains("test-file1.txt"));
    assertTrue(fileNames.contains("test-file2.txt"));
}

4. 压缩级别控制

ZipOutputStream 默认启用压缩,我们可以通过 setLevel() 调整压缩强度:

@GetMapping(value = "/zip-with-compression", produces = "application/zip")
public void getZipWithCompression(HttpServletResponse response) throws IOException {
    response.setContentType("application/zip");
    response.setHeader("Content-Disposition", "attachment; filename=compressed.zip");
    
    ZipOutputStream zipOutputStream = new ZipOutputStream(response.getOutputStream());
    zipOutputStream.setLevel(9); // 最高压缩级别
    
    addFilesToArchive(zipOutputStream);
    
    zipOutputStream.close();
}

压缩级别对照表: | 级别 | 压缩率 | 速度 | 适用场景 | |------|--------|------|----------| | 0 | 无压缩 | 最快 | 临时文件 | | 1-3 | 低 | 快 | 实时传输 | | 4-6 | 中 | 平衡 | 通用场景 | | 7-9 | 高 | 慢 | 长期存储 |

简单粗暴原则:网络带宽紧张用高压缩,CPU 紧张用低压缩。

5. 密码保护 ZIP

需要添加密码保护时,引入 zip4j 依赖:

<dependency>
    <groupId>net.lingala.zip4j</groupId>
    <artifactId>zip4j</artifactId>
    <version>2.9.1</version>
</dependency>

创建加密接口:

@GetMapping(value = "/zip-with-password", produces = "application/zip")
public void getZipWithPassword(HttpServletResponse response) throws IOException {
    response.setContentType("application/zip");
    response.setHeader("Content-Disposition", "attachment; filename=secured.zip");
    
    ZipParameters zipParameters = new ZipParameters();
    zipParameters.setEncryptFiles(true);
    zipParameters.setEncryptionMethod(EncryptionMethod.AES);
    
    ZipOutputStream zipOutputStream = new ZipOutputStream(response.getOutputStream());
    zipOutputStream.setPassword("secret123".toCharArray());
    
    addFilesToArchive(zipOutputStream, zipParameters);
    
    zipOutputStream.close();
}

实现加密文件添加:

private void addFilesToArchive(ZipOutputStream zipOutputStream, ZipParameters zipParameters) throws IOException {
    Resource resource1 = new ClassPathResource("test-file1.txt");
    Resource resource2 = new ClassPathResource("test-file2.txt");
    
    addToZipFile(resource1, zipOutputStream, zipParameters);
    addToZipFile(resource2, zipOutputStream, zipParameters);
}

private void addToZipFile(Resource resource, ZipOutputStream zipOutputStream, ZipParameters zipParameters) throws IOException {
    zipParameters.setFileNameInZip(resource.getFilename());
    zipOutputStream.putNextEntry(zipParameters);
    
    StreamUtils.copy(resource.getInputStream(), zipOutputStream);
    
    zipOutputStream.closeEntry();
}

测试加密包:

@Test
public void whenDownloadZipWithPassword_thenSuccess() throws IOException {
    ResponseEntity<byte[]> response = restTemplate.getForEntity("/zip-with-password", byte[].class);
    
    File zipFile = new File("downloaded-secured.zip");
    Files.write(zipFile.toPath(), response.getBody());
    
    // 使用密码解压
    List<String> fileNames = fetchFileNamesFromArchive(zipFile, "secret123");
    assertTrue(fileNames.contains("test-file1.txt"));
    assertTrue(fileNames.contains("test-file2.txt"));
}

private List<String> fetchFileNamesFromArchive(File zipFile, String password) throws IOException {
    List<String> fileNames = new ArrayList<>();
    
    try (ZipInputStream zipInputStream = new ZipInputStream(Files.newInputStream(zipFile.toPath()))) {
        zipInputStream.setPassword(password.toCharArray());
        
        ZipEntry entry;
        while ((entry = zipInputStream.getNextEntry()) != null) {
            fileNames.add(entry.getName());
        }
    }
    
    return fileNames;
}

🔑 关键提醒:解压时必须提供相同密码,否则会抛出 ZipException

6. 总结

在 Spring Boot 中实现 ZIP 文件下载有两种核心方案:

方案 优点 缺点 适用场景
字节数组方式 实现简单 内存占用高 小文件 (<10MB)
流式传输 内存占用低 代码稍复杂 大文件/高并发场景

其他优化点:

  • 通过 setLevel() 平衡压缩率与性能
  • 使用 zip4j 实现密码保护
  • 始终设置 Content-Disposition 头触发下载

根据实际需求选择合适方案,避免内存溢出这个大坑!


原始标题:How to Serve a Zip File With Spring Boot @RequestMapping | Baeldung