如何使用 Spring Boot @RequestMapping 提供 Zip 文件
1. 概述
有時我們可能需要允許 REST API 下載 ZIP 檔案。這對於減少網路負載很有用。但是,我們可能會在端點上使用預設配置下載檔案時遇到困難。
在本文中,我們將了解如何使用@RequestMapping
註解從端點產生 ZIP 文件,並將探索一些從中提供 ZIP 檔案的方法。
2. 將 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註解的捷徑。在produces
屬性中,我們選擇application/zip
,它是 ZIP 存檔的 MIME 類型。然後我們用ZipOutputStream
包裝ByteArrayOutputStream
並在其中加入所有需要的檔案。最後,我們設定帶有attachment
值的[Content-Disposition](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/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 檔案保存在記憶體中。
3. 將 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");
}
我們成功檢索了檔案並在那裡找到了所有文件。
4. 控制存檔壓縮
當我們使用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
之間的值。較低的壓縮等級可以加快處理速度,而較高的壓縮等級會產生較小的輸出,但會減慢歸檔速度。
5.新增存檔密碼保護
我們也可以為 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
。
六、結論
在本教程中,我們探索了在 Spring Boot 應用程式中提供 ZIP 檔案的兩種方法。我們可以將位元組數組用於中小型檔案。對於較大的文件,我們應該考慮直接在 HTTP 回應中串流 ZIP 存檔,以保持較低的記憶體使用量。透過調整壓縮級別,我們可以控製網路負載和端點的延遲。
與往常一樣,程式碼可以在 GitHub 上取得。