在 Spring 中依序傳輸多部分數據
1.概述
在現代 Web 應用程式中,高效傳輸大檔案至關重要。無論是向客戶端發送多個文件還是接收大量上傳數據,我們都必須盡量減少記憶體佔用。然而,Spring 預設的緩衝方法可能會成為大負載的瓶頸。它會在程式碼處理之前將整個檔案儲存在記憶體或磁碟上。這會導致應用程式延遲處理並消耗更多資源。
幸運的是,Spring 支援順序串流傳輸,從而避免了這些限制。本教學說明如何實現多部分資料的串流。具體來說,我們將討論 Spring MVC 和 Reactive WebFlux,並提供上傳和下載的實際範例。
2. Spring 中的預設 Multipart 處理
在 Spring MVC 中, MultipartResolver
通常用於處理多部分請求。它會解析每個傳入的文件,並將其暫時儲存在記憶體或磁碟中,然後再傳遞給控制器。同樣,預設方法通常會將整個回應載入到記憶體中,然後再將其傳送到客戶端。
雖然此方法很簡單且適用於小文件,但它在上傳或下載較大文件時有兩個主要問題:
- 高內存消耗:大檔案會導致我們的應用程式使用過多的內存,這可能會導致效能下降甚至出現
OutOfMemoryError
。 - 延遲處理或傳送:應用程式必須等到請求的所有部分都完全接收後才能開始任何處理或發送數據,這會推遲到達客戶端的第一個字節。
這些限制使得預設方法不適用於大型存檔、大量資料集或即時上傳。串流方法解決了這個問題,它能夠在資料到達時立即處理或發送數據,而無需等待完整的負載。
3. Spring MVC 中的流
在 Spring MVC 應用程式中,流使我們能夠逐步發送或接收大文件,而不是將它們完全緩衝在記憶體或磁碟上。這種方法可以保持記憶體使用量的可預測性,減少延遲,並實現即時處理。
我們將首先研究流文件上傳,然後研究流文件下載,探索每個場景的配置和實現技術。
3.1. 串流檔案上傳
透過這種方式,應用程式可以在資料到達時立即處理,從而實現早期驗證、轉換或持久化。即使上傳資料量達到數 GB,也能確保記憶體使用量的可預測性。
第一步是配置MultipartResolver
以最小化緩衝。在application.properties
中將檔案大小閾值設為0
,可確保上傳的檔案直接從請求串流傳輸,而不是在記憶體中緩衝:
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
spring.servlet.multipart.file-size-threshold=0
設定spring.servlet.multipart.file-size-threshold=0
會停用所有檔案的記憶體緩衝。任何上傳的文件,無論大小,都將直接寫入磁碟或以流的形式處理,而不是保存在記憶體中。此設定對於處理大檔案時可預測的記憶體使用情況至關重要,因為它可以防止堆使用率突然飆升,並允許應用程式在收到資料後立即開始處理。
透過此配置,控制器可以接收上傳的檔案作為MultipartFile
的實例並逐步處理它們:
@PostMapping("/upload")
public ResponseEntity<String> uploadFileStreaming(@RequestPart("filePart") MultipartFile filePart) throws IOException {
Path targetPath = UPLOAD_DIR.resolve(filePart.getOriginalFilename());
Files.createDirectories(targetPath.getParent());
try (InputStream inputStream = filePart.getInputStream(); OutputStream outputStream = Files.newOutputStream(targetPath)) {
inputStream.transferTo(outputStream);
}
return ResponseEntity.ok("Upload successful: " + filePart.getOriginalFilename());
}
由於檔案資料是以流的形式從MultipartFile
讀取的,因此這種方法避免了在記憶體中緩衝整個上傳過程。 transferTo transferTo()
方法以記憶體敏感的方式有效率地將輸入流複製到輸出流。這使得控制器能夠以增量方式處理大文件,保持記憶體使用量的可預測性,並使其能夠輕鬆地整合到現有的 Spring MVC 控制器中。
3.2. 串流檔案下載
Spring MVC 的預設行為是在發送回應之前緩衝整個回應,這不僅浪費內存,還會延遲大負載的傳輸。 StreamingResponseBody API 透過直接寫入StreamingResponseBody
輸出流解決了這個問題,允許在發送第一個檔案的同時仍在處理後續檔案。
對於單一 HTTP 回應中的多個文件,我們可以使用帶有邊界字串的multipart/mixed
內容類型來分隔流中的每個文件:
@GetMapping("/download")
public StreamingResponseBody downloadFiles(HttpServletResponse response) throws IOException {
String boundary = "filesBoundary";
response.setContentType("multipart/mixed; boundary=" + boundary);
List<Path> files = List.of(UPLOAD_DIR.resolve("file1.txt"), UPLOAD_DIR.resolve("file2.txt"));
return outputStream -> {
try (BufferedOutputStream bos = new BufferedOutputStream(outputStream); OutputStreamWriter writer = new OutputStreamWriter(bos)) {
for (Path file : files) {
writer.write("--" + boundary + "\r\n");
writer.write("Content-Type: application/octet-stream\r\n");
writer.write("Content-Disposition: attachment; filename=\"" + file.getFileName() + "\"\r\n\r\n");
writer.flush();
Files.copy(file, bos);
bos.write("\r\n".getBytes());
bos.flush();
}
writer.write("--" + boundary + "--\r\n");
writer.flush();
}
};
}
在此範例中,每個檔案都直接從磁碟串流傳輸到輸出流。明確邊界標記允許客戶端將流解析為不同的文件,並且每次寫入後刷新可確保資料推送到客戶端,避免不必要的延遲。這種方法可以降低記憶體使用率並提升感知效能,因為使用者可以在資料可用時立即接收資料。
4. 使用 WebFlux 實作反應式串流
Spring MVC 有效率地處理文件流,而 Spring WebFlux 透過非阻塞、背壓感知的資料處理提供卓越的可擴展性。它處理檔案流時不會阻塞線程或消耗過多的記憶體。雖然核心的順序流概念仍然保留,但 WebFlux 使用Flux
和Mono
等響應式類型(而非InputStream
和OutputStream
來實現它們。
4.1. 串流檔案上傳
在 WebFlux 中,我們透過將多部分請求處理為Part
物件的回應式串流來處理上傳。關鍵在於使用原生的FilePart
接口,該接口將文件內容以Flux<DataBuffer>
形式提供。這使我們能夠在資料塊通過網路到達時進行處理,並使用非阻塞 I/O 操作將其寫入目的地,從而維護從網路套接字一直到磁碟的響應式處理鏈:
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@ResponseBody
public Mono<String> uploadFileStreaming(@RequestPart("filePart") FilePart filePart) {
return Mono.fromCallable(() -> {
Path targetPath = UPLOAD_DIR.resolve(filePart.filename());
Files.createDirectories(targetPath.getParent());
return targetPath;
}).flatMap(targetPath ->
filePart.transferTo(targetPath)
.thenReturn("Upload successful: " + filePart.filename())
);
}
這將建立一個非阻塞管道,其中FilePart.transferTo()
在內部處理從請求到檔案系統的回應式流。該過程具有背壓感知功能,可自動調節資料流以匹配磁碟速度並防止伺服器過載。
4.2. 串流檔案下載
對於下載,WebFlux 允許我們將檔案內容作為Flux<DataBuffer>
傳回,Spring 會將其直接寫入 HTTP 回應套接字。這種方法會以增量方式將檔案串流傳輸到客戶端,而無需將整個內容載入到記憶體中。它是 MVC 的StreamingResponseBody
的響應式版本,並且對於處理大型資產非常有效率:
@GetMapping(value = "/download", produces = "multipart/mixed")
public ResponseEntity<Flux<DataBuffer>> downloadFiles() {
String boundary = "filesBoundary";
List<Path> files = List.of(
UPLOAD_DIR.resolve("file1.txt"),
UPLOAD_DIR.resolve("file2.txt")
);
// Use concatMap to ensure files are streamed one after another, sequentially.
Flux<DataBuffer> fileFlux = Flux.fromIterable(files)
.concatMap(file -> {
String partHeader = "--" + boundary + "\r\n" +
"Content-Type: application/octet-stream\r\n" +
"Content-Disposition: attachment; filename=\"" + file.getFileName() + "\"\r\n\r\n";
Flux<DataBuffer> fileContentFlux = DataBufferUtils.read(file, new DefaultDataBufferFactory(), 4096);
DataBuffer footerBuffer = new DefaultDataBufferFactory().wrap("\r\n".getBytes());
// Build the flux for this specific part: header + content + footer
return Flux.concat(
Flux.just(new DefaultDataBufferFactory().wrap(partHeader.getBytes())),
fileContentFlux,
Flux.just(footerBuffer)
);
})
// After all parts, concat the final boundary
.concatWith(Flux.just(
new DefaultDataBufferFactory().wrap(("--" + boundary + "--\r\n").getBytes())
));
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_TYPE, "multipart/mixed; boundary=" + boundary)
.body(fileFlux);
}
至關重要的是, concatMap()
透過在開始下一個檔案之前處理一個檔案的整個Flux
來確保真正的順序流傳輸,從而保持多部分順序。這與DataBufferUtils.read()
的高效性相結合,後者使用非阻塞 I/O 以 4KB 的區塊為單位傳輸檔案內容。結果是,整個文件永遠不會加載到記憶體中,客戶端可以立即接收數據,並且記憶體佔用量保持最小。
5. 結論
Spring 中的順序串流傳輸使我們能夠處理大型檔案傳輸,而不會耗盡記憶體或延遲處理。無論我們在 MVC 中使用StreamingResponseBody
或在 WebFlux 中使用Flux<Part>
,關鍵在於在資料到達時進行處理。
對於小文件,預設的緩衝方法效果很好。但當我們處理數 GB 的資料集、大型存檔或即時上傳時,串流媒體可以降低延遲、提供可預測的記憶體使用量以及更好的可擴展性。
與往常一樣,本文中的程式碼可在 GitHub 上取得。