Spring Boot中用@RequestMapping 提供 Zip 文件

有时我们可能需要允许 REST API 下载 ZIP 档案。这对于减少网络负载很有用。但是,使用终端上的默认配置下载文件时,我们可能会遇到困难。

在本教程中,我们探讨了在 Spring Boot 应用程序中提供 ZIP 文件的两种方法。

  • 对于中小型档案,我们可以使用字节数组。
  • 对于较大的文件,我们应该考虑在 HTTP 响应中直接流式传输 ZIP 档案,以保持较低的内存使用率。

通过调整压缩级别,我们可以控制网络负载和端点的延迟。

在本文中,我们将看到如何使用@RequestMapping注释从我们的端点生成 ZIP 文件,并且我们将探索一些从它们提供 ZIP 档案的方法。

将 Zip 存档压缩为字节数组
提供 ZIP 文件的第一种方法是将其创建为字节数组并在 HTTP 响应中返回。让我们使用返回存档字节的端点创建 REST 控制器:

@RestController
public class ZipArchiveController {
    @GetMapping(value = "/zip-archive", produces = "application/zip")
    public ResponseEntity<byte[]> getZipBytes() throws IOException {
        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
        BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(byteArrayOutputStream);
        ZipOutputStream zipOutputStream = new ZipOutputStream(bufferedOutputStream);
        addFilesToArchive(zipOutputStream);
        IOUtils.closeQuietly(bufferedOutputStream);
        IOUtils.closeQuietly(byteArrayOutputStream);
        return ResponseEntity
          .ok()
          .header(
"Content-Disposition", "attachment; filename=\"files.zip\"")
          .body(byteArrayOutputStream.toByteArray());
    }
}

我们使用@GetMapping作为@RequestMapping 注释的快捷方式。在produce属性中,我们选择application/zip,这是 ZIP 档案的 MIME 类型。然后我们用  ZipOutputStream包装ByteArrayOutputStream并在其中添加所有需要的文件。最后,我们用附件值设置Content-Disposition标头,这样我们就可以在调用后下载档案。

现在,让我们实现addFilesToArchive()方法:

void addFilesToArchive(ZipOutputStream zipOutputStream) throws IOException {
    List<String> filesNames = new ArrayList<>();
    filesNames.add("first-file.txt");
    filesNames.add(
"second-file.txt");
    for (String fileName : filesNames) {
        File file = new File(ZipArchiveController.class.getClassLoader()
          .getResource(fileName).getFile());
        zipOutputStream.putNextEntry(new ZipEntry(file.getName()));
        FileInputStream fileInputStream = new FileInputStream(file);
        IOUtils.copy(fileInputStream, zipOutputStream);
        fileInputStream.close();
        zipOutputStream.closeEntry();
    }
    zipOutputStream.finish();
    zipOutputStream.flush();
    IOUtils.closeQuietly(zipOutputStream);
}

在这里,我们只需用资源文件夹中的几个文件填充档案。

最后,让我们调用我们的端点并检查是否返回了所有文件:

@WebMvcTest(ZipArchiveController.class)
public class ZipArchiveControllerUnitTest {
    @Autowired
    MockMvc mockMvc;
    @Test
    void givenZipArchiveController_whenGetZipArchiveBytes_thenExpectedArchiveShouldContainExpectedFiles() throws Exception {
        MvcResult result = mockMvc.perform(get("/zip-archive"))
          .andReturn();
        MockHttpServletResponse response = result.getResponse();
        byte[] content = response.getContentAsByteArray();
        List<String> fileNames = fetchFileNamesFromArchive(content);
        assertThat(fileNames)
          .containsExactly(
"first-file.txt", "second-file.txt");
    }
    List<String> fetchFileNamesFromArchive(byte[] content) throws IOException {
        InputStream byteStream = new ByteArrayInputStream(content);
        ZipInputStream zipStream = new ZipInputStream(byteStream);
        List<String> fileNames = new ArrayList<>();
        ZipEntry entry;
        while ((entry = zipStream.getNextEntry()) != null) {
            fileNames.add(entry.getName());
            zipStream.closeEntry();
        }
        return fileNames;
    }
}

正如响应中所预期的那样,我们从终端获得了 ZIP 存档。我们从那里解压了所有文件,并仔细检查了所有预期文件是否都已到位。

对于较小的文件,我们可以使用此方法,但较大的文件可能会导致堆消耗问题。这是因为ByteArrayInputStream将整个 ZIP 文件保存在内存中。

将 Zip 存档作为流
对于较大的档案,我们应避免将所有内容加载到内存中。相反,我们可以在创建 ZIP 文件时将其直接传输到客户端。这可以减少内存消耗,并使我们能够高效地提供大型文件。

让我们在控制器上创建另一个端点:

@GetMapping(value = "/zip-archive-stream", produces = "application/zip")
public ResponseEntity<StreamingResponseBody> getZipStream() {
    return ResponseEntity
      .ok()
      .header(
"Content-Disposition", "attachment; filename=\"files.zip\"")
      .body(out -> {
          ZipOutputStream zipOutputStream = new ZipOutputStream(out);
          addFilesToArchive(zipOutputStream);
      });
}

我们在这里使用了Servlet 输出流而不是ByteArrayInputStream,因此我们所有的文件都将流式传输到客户端,而无需完全存储在内存中。

让我们调用这个端点并检查它是否返回我们的文件:

@Test
void givenZipArchiveController_whenGetZipArchiveStream_thenExpectedArchiveShouldContainExpectedFiles() throws Exception {
    MvcResult result = mockMvc.perform(get("/zip-archive-stream"))
     .andReturn();
    MockHttpServletResponse response = result.getResponse();
    byte[] content = response.getContentAsByteArray();
    List<String> fileNames = fetchFileNamesFromArchive(content);
    assertThat(fileNames)
      .containsExactly(
"first-file.txt", "second-file.txt");
}

我们成功检索了档案并且所有文件都已找到。

控制档案压缩
当我们使用ZipOutputStream时,它已经提供了压缩功能。我们可以使用zipOutputStream.setLevel()方法调整压缩级别。

让我们修改其中一个端点代码来设置压缩级别:

@GetMapping(value = "/zip-archive-stream", produces = "application/zip")
public ResponseEntity<StreamingResponseBody> getZipStream() {
    return ResponseEntity
      .ok()
      .header(
"Content-Disposition", "attachment; filename=\"files.zip\"")
      .body(out -> {
          ZipOutputStream zipOutputStream = new ZipOutputStream(out);
          zipOutputStream.setLevel(9);
          addFilesToArchive(zipOutputStream);
      });
}

我们将压缩级别设置为9,这是最大压缩级别。我们可以在0到9之间选择一个值。较低的压缩级别可加快处理速度,而较高的压缩级别会产生较小的输出,但会减慢存档速度。

添加存档密码保护
我们还可以为 ZIP 档案设置密码。为此,让我们添加zip4j 依赖项:

<dependency>
    <groupId>net.lingala.zip4j</groupId>
    <artifactId>zip4j</artifactId>
    <version>${zip4j.version}</version>
</dependency>

现在我们将向控制器添加一个新的端点,在那里返回密码加密的存档流:

import net.lingala.zip4j.io.outputstream.ZipOutputStream;
@GetMapping(value = "/zip-archive-stream-secured", produces = "application/zip")
public ResponseEntity<StreamingResponseBody> getZipSecuredStream() {
    return ResponseEntity
      .ok()
      .header(
"Content-Disposition", "attachment; filename=\"files.zip\"")
      .body(out -> {
          ZipOutputStream zipOutputStream = new ZipOutputStream(out,
"password".toCharArray());
          addFilesToArchive(zipOutputStream);
      });
}

这里我们使用了zip4j 库中的ZipOutputStream ,它可以处理密码。

现在让我们实现addFilesToArchive()方法:

import net.lingala.zip4j.model.ZipParameters;
void addFilesToArchive(ZipOutputStream zipOutputStream) throws IOException {
    List<String> filesNames = new ArrayList<>();
    filesNames.add("first-file.txt");
    filesNames.add(
"second-file.txt");
    ZipParameters zipParameters = new ZipParameters();
    zipParameters.setCompressionMethod(CompressionMethod.DEFLATE);
    zipParameters.setEncryptionMethod(EncryptionMethod.ZIP_STANDARD);
    zipParameters.setEncryptFiles(true);
    for (String fileName : filesNames) {
        File file = new File(ZipArchiveController.class.getClassLoader()
          .getResource(fileName).getFile());
        zipParameters.setFileNameInZip(file.getName());
        zipOutputStream.putNextEntry(zipParameters);
        FileInputStream fileInputStream = new FileInputStream(file);
        IOUtils.copy(fileInputStream, zipOutputStream);
        fileInputStream.close();
        zipOutputStream.closeEntry();
    }
    zipOutputStream.flush();
    IOUtils.closeQuietly(zipOutputStream);
}

我们使用ZIP 条目的EncryptionMethod和EncryptFiles参数来加密文件。

最后,让我们调用新的端点并检查响应:

@Test
void givenZipArchiveController_whenGetZipArchiveSecuredStream_thenExpectedArchiveShouldContainExpectedFilesSecuredByPassword() throws Exception {
    MvcResult result = mockMvc.perform(get("/zip-archive-stream-secured"))
      .andReturn();
    MockHttpServletResponse response = result.getResponse();
    byte[] content = response.getContentAsByteArray();
    List<String> fileNames = fetchFileNamesFromArchive(content);
    assertThat(fileNames)
      .containsExactly(
"first-file.txt", "second-file.txt");
}

在fetchFileNamesFromArchive()中,我们将实现从 ZIP 存档中检索数据的逻辑:

import net.lingala.zip4j.io.inputstream.ZipInputStream;
List<String> fetchFileNamesFromArchive(byte[] content) throws IOException {
    InputStream byteStream = new ByteArrayInputStream(content);
    ZipInputStream zipStream = new ZipInputStream(byteStream, "password".toCharArray());
    List<String> fileNames = new ArrayList<>();
    LocalFileHeader entry = zipStream.getNextEntry();
    while (entry != null) {
        fileNames.add(entry.getFileName());
        entry = zipStream.getNextEntry();
    }
    zipStream.close();
    return fileNames;
}

这里我们再次使用zip4j 库中的ZipInputStream并设置我们在加密时使用的密码。否则,我们将遇到ZipException。