Spring Boot 二進制數據傳輸:從內存優化到流式處理與斷點續傳
環境:SpringBoot3.4.2
1. 簡介
在 Spring Boot 中高效傳輸二進制數據(如文件、圖片、動態生成內容)需關注 Content-Type 設置、流式處理、緩存控制及分塊下載。直接讀取文件到內存(如 byte[])適用于小文件,但大文件應使用 InputStreamResource 或 StreamingResponseBody 避免內存溢出。動態內容可通過 HttpServletResponse 直接寫入響應流,壓縮文件則可用 ZipOutputStream 實時生成。為避免緩存問題,可通過 ETag 或 Last-Modified 實現條件請求。此外,支持 HTTP 范圍請求(ResourceRegion)可實現斷點續傳,顯著提升大文件傳輸體驗。本篇文章將結合代碼示例,系統化講解二進制數據傳輸的核心技術與優化策略。
2.實戰案例
2.1 正確設置Content-Type
在響應二進制內容時,Content-Type頭部告知客戶端即將接收的數據類型。若缺少此信息,瀏覽器可能會嘗試將二進制數據作為文本顯示,或在不知如何處理的情況下直接下載。如下示例:
@GetMapping("/download1")
public ResponseEntity<byte[]> download1() throws IOException {
Path path = Paths.get("E:/技術架構.pdf");
byte[] pdfData = Files.readAllBytes(path);
String fileName = "技術架構.pdf";
fileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8)
.replaceAll("\\+", "%20");
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileName + "\"")
.contentType(MediaType.APPLICATION_PDF)
.body(pdfData);
}首先將整個文件讀取到內存中,通過Content-Disposition頭指示瀏覽器提示下載而非嘗試內聯渲染。對于不適合直接顯示的格式(如歸檔文件或加密數據),這將非常有用。
示例2:
@GetMapping("/images/logo")
public ResponseEntity<byte[]> fetchLogo() throws IOException {
ClassPathResource resource = new ClassPathResource("static/7.png") ;
byte[] bytes;
try (InputStream in = resource.getInputStream()) {
bytes = in.readAllBytes();
}
return ResponseEntity.ok()
.contentType(MediaType.IMAGE_PNG)
.contentLength(resource.contentLength())
.body(bytes);
}該示例將直接在瀏覽器中展示圖片。
2.2 流式處理大文件
當文件超過幾兆時,在發送時將內容全部加載到內存中這將帶來很大的風險。首先,這種做法會消耗不必要的內存,并在等待文件讀取完成時導致響應卡頓。流式傳輸允許數據以較小塊的形式傳輸,無需一次性存儲全部內容。Spring Boot可通過InputStreamResource、FileSystemResource控制流式傳輸。如下示例:
@GetMapping("/download2")
public ResponseEntity<InputStreamResource> download2() throws IOException {
File file = new File("E:/技術架構.pdf");
String fileName = "技術架構.pdf";
fileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8)
.replaceAll("\\+", "%20");
InputStreamResource resource = new InputStreamResource(new FileInputStream(file));
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileName + "\"")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.contentLength(file.length())
.body(resource);
}此方法實現了直接流式傳輸至客戶端,不會被復制到內存中。同時客戶端可以統計設置的Content-Length判斷文件是否接收完成。
使用StreamingResponseBody實時生成響應內容
@GetMapping("/export/csv")
public ResponseEntity<StreamingResponseBody> exportCsv() {
StreamingResponseBody stream = outputStream -> {
String header = "姓名,年齡,郵箱\n";
outputStream.write(header.getBytes(java.nio.charset.StandardCharsets.UTF_8));
outputStream.write("pack,33,pack@gmail.com\n".getBytes(java.nio.charset.StandardCharsets.UTF_8));
outputStream.write("xg,32,xg@qq.com\n".getBytes(java.nio.charset.StandardCharsets.UTF_8));
outputStream.write("pack_xg,40,pack_xg@163.com\n".getBytes(java.nio.charset.StandardCharsets.UTF_8));
};
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"users.csv\"")
.contentType(MediaType.parseMediaType("text/csv; charset=UTF-8"))
.body(stream) ;
}這避免了臨時文件的使用,即使在數據生成過程中也能保持響應流暢。對于需要保持低內存占用的大型報告或日志導出而言,此方法非常實用。
2.3 響應動態生成的文件
并非所有文件都來自磁盤。許多服務會在內存中生成內容,例如PDF、圖像或壓縮數據集。當內容在運行時創建時,可直接寫入響應流而無需預先保存。如下示例:
@GetMapping("/generate/report")
public void generateReport(HttpServletResponse response) throws IOException {
response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE) ;
String filename = "報告.txt";
filename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replaceAll("\\+", "%20");
response.setHeader("Content-Disposition", "attachment; filename=\"" + filename + "\"");
try (OutputStream out = response.getOutputStream()) {
byte[] reportBytes = createText(); // 假設這個方法生成文本內容
out.write(reportBytes);
out.flush();
}
}
private byte[] createText() {
return "這是報告內容\n第二行內容".getBytes(StandardCharsets.UTF_8);
}如上實現沒有文件IO操作,這在處理動態生成的文檔或分析導出時,這種做法很常見。輸出流直接連接到HTTP響應,既降低了延遲,又減少了磁盤訪問。
如果你需要同時下載多個文件并且是希望生成一個壓縮文件進行下載,那么你可以采用如下的方式:
@GetMapping("/generate/zip")
public ResponseEntity<StreamingResponseBody> generateZip() {
StreamingResponseBody stream = output -> {
try (ZipOutputStream zipOut = new ZipOutputStream(output)) {
ZipEntry entry = new ZipEntry("summary.txt");
zipOut.putNextEntry(entry);
zipOut.write("這里是摘要內容".getBytes(java.nio.charset.StandardCharsets.UTF_8));
entry = new ZipEntry("content.txt");
zipOut.putNextEntry(entry);
zipOut.write("主體內容".getBytes(java.nio.charset.StandardCharsets.UTF_8));
zipOut.closeEntry();
zipOut.finish() ;
}
};
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"export.zip\"")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(stream);
}最終生成的壓縮文件如下:
圖片
2.4 避免瀏覽器緩存
瀏覽器傾向于保留已下載的文件。這對圖像或靜態資源很有幫助,但對于經常更新的數據(如每日報告或導出文件)則不然。如果響應未告知瀏覽器更新,用戶可能在不知情的情況下下載過時文件。為確保始終提供最新數據,可在響應中直接添加緩存標頭。如下示例:
@GetMapping("/download3")
public ResponseEntity<byte[]> download3() throws IOException {
Path path = Paths.get("E:/技術架構.pdf");
byte[] data = Files.readAllBytes(path);
String fileName = "技術架構.pdf";
fileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8)
.replaceAll("\\+", "%20");
ZonedDateTime expiresTime = ZonedDateTime.now(ZoneId.systemDefault()).plusSeconds(30);
String expiresHeader = expiresTime.format(DateTimeFormatter.RFC_1123_DATE_TIME);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileName + "\"")
.header(HttpHeaders.EXPIRES, expiresHeader)
.contentType(MediaType.APPLICATION_PDF)
.body(data);
}通過expires設置有效期為30s,運行該接口瀏覽器查看執行情況:
圖片
過期后又會從服務器進行下載文件。在有效期時間內并不會請求我們的實際接口。
當我們需要更加精確的控制緩存時,可通過更具選擇性的方法。條件緩存標頭(如ETag或Last-Modified)會指示瀏覽器在復用文件前重新驗證其有效性。如下示例:
@GetMapping("/getConfig")
public ResponseEntity<Resource> getConfigFile(HttpServletRequest request) throws IOException {
FileSystemResource resource = new FileSystemResource("E:/config.json");
long lastModified = resource.lastModified();
String eTag = "\"" + lastModified + "\"";
String ifNoneMatch = request.getHeader(HttpHeaders.IF_NONE_MATCH);
boolean tagMatches = ifNoneMatch != null && ifNoneMatch.contains(eTag);
if (tagMatches) {
return ResponseEntity.status(HttpStatus.NOT_MODIFIED)
.eTag(eTag)
.lastModified(lastModified)
.build();
}
return ResponseEntity.ok()
.eTag(eTag)
.lastModified(lastModified)
.contentType(MediaType.APPLICATION_JSON)
.body(resource);
}如上實現,在不影響文件更新性的前提下減少了不必要的帶寬消耗。當文件未變更時,客戶端將收到304未修改響應;而更新后的內容則正常發送。上面接口運行結果:
圖片
當文件config.json發生變化后才會再次讀取文件內容發送。
對于靜態資源,我們可以通過如下配置進行全局設置:
@Configuration
public class CacheConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/resources/**")
.addResourceLocations("classpath:/public/")
.setCacheControl(CacheControl.maxAge(Duration.ofDays(1)));
}
}2.5 范圍分塊下載
當需要高效傳輸大文件(如視頻、音頻、大型軟件包)或支持斷點續傳時,HTTP范圍請求可按需下載文件片段。例如視頻拖動進度條、下載工具暫停后恢復,或僅傳輸客戶端未緩存的部分,顯著節省帶寬和時間。如下示例:
@GetMapping("/download4")
public ResponseEntity<ResourceRegion> download4(@RequestHeader HttpHeaders headers) throws IOException {
// 1.獲取系統資源文件
FileSystemResource resource = new FileSystemResource("e:/doubao.exe");
if (!resource.exists()) {
return ResponseEntity.notFound().build();
}
long contentLength = resource.contentLength();
MediaType mediaType = MediaTypeFactory.getMediaType(resource)
.orElse(MediaType.APPLICATION_OCTET_STREAM);
// 2.處理范圍請求(支持多范圍請求,但僅返回第一個范圍)
List<HttpRange> ranges = headers.getRange();
if (ranges.isEmpty()) {
// 完整文件下載
return ResponseEntity.ok()
.header("Accept-Ranges", "bytes")
.contentType(mediaType)
.contentLength(contentLength)
.body(new ResourceRegion(resource, 0, contentLength));
}
// 3.嚴格校驗范圍有效性(避免越界)
HttpRange range = ranges.get(0);
long start = range.getRangeStart(contentLength);
long end = range.getRangeEnd(contentLength);
if (start > end || start < 0 || end >= contentLength) {
return ResponseEntity.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
.header("Content-Range", "bytes */" + contentLength)
.build();
}
// 4.優化分塊大小
long rangeLength = end - start + 1;
long chunkSize = Math.min(10 * 1024 * 1024, rangeLength); // 默認10MB,適應大文件
ResourceRegion region = new ResourceRegion(resource, start, chunkSize);
return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
.header("Content-Range", "bytes " + start + "-" + (start + chunkSize - 1) + "/" + contentLength)
.header("Content-Disposition", "attachment; filename=\"" + resource.getFilename() + "\"")
.contentType(mediaType)
.body(region);
}注意,這里的返回值類型不能使用ResponseEntity<?> 通配符,否則將會報錯。
























