1. 概述
在現代 Web 應用程序中,高效地傳輸大型文件至關重要。無論我們是將多個文件發送到客户端,還是接收大型上傳,都必須最大限度地減少內存使用。然而,Spring 的默認緩衝方式可能會成為大型數據包的瓶頸。它會將整個文件存儲在內存中或磁盤上,然後再由我們的代碼進行處理。結果,應用程序會延遲處理並消耗更多資源。
幸運的是,Spring 允許使用順序流式傳輸來避免這些限制。 本教程將解釋如何實現多部分數據(multipart data)的流式傳輸。具體而言,我們將討論 Spring MVC 和 Reactive WebFlux,並提供上傳和下載的實用示例。
2. Spring 中默認多部分處理
`MultipartResolver 通常在 Spring MVC 中處理多部分請求。它解析每個傳入的文件,並在內存或磁盤上臨時存儲它們,然後再將其傳遞給控制器。 默認情況下,應用程序通常會將整個響應加載到內存中,然後再將其發送到客户端。
雖然這種方法簡單易行,並且適用於小型文件,但它在處理大型上傳或下載時會帶來兩個主要問題:
- 高內存消耗:大型文件會導致我們的應用程序消耗過多的內存,從而可能導致性能下降甚至 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() 方法以內存敏感的方式高效地將輸入流複製到輸出流。 這使得控制器能夠以增量方式處理大型文件,保持內存使用量可預測,並使將流式上傳集成到現有的 Spring MVC 控制器變得簡單。
3.2. 實時文件下載
Spring MVC 的默認行為是在發送響應之前緩衝整個響應,這會浪費內存並延遲大型負載的交付。<em>StreamingResponseBody</em> API 通過直接寫入響應輸出流來解決此問題,允許第一個文件在後續文件仍在處理中時立即發送。
對於單個 HTTP 響應中的多個文件,可以使用 <em>multipart/mixed</em> 內容類型,並使用分隔符字符串將每個文件分割成流:
@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 中,我們通過處理 multipart 請求為 reactive stream 中的 Part 對象進行處理。 關鍵在於使用原生 FilePart 接口,它將文件內容提供為 Flux<DataBuffer>。 這允許我們處理網絡傳輸過程中到達的數據塊,並使用非阻塞 I/O 操作將其寫入目標位置,從而在網絡套接字從網絡到磁盤的過程中保持 reactive 鏈:
@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 中,順序流式傳輸允許我們處理大型文件傳輸,而無需耗盡內存或延遲處理。無論我們是否使用 StreamingResponseBody 在 MVC 中,還是在 WebFlux 中使用 Flux<Part>,關鍵在於以數據到達時進行處理。
對於小型文件,默認的緩衝方式已經足夠。但當處理多 GB 級數據集、大型歸檔文件或實時上傳時,流式傳輸能提供更低的延遲、可預測的內存使用量和更好的可擴展性。