檔案下載功能在 web 應用裡很常見,但實作得不夠好的話,會導致記憶體爆炸。我之前有個專案就因為沒注意這點,結果下載一個 1GB 的檔案直接把 Java heap 炸了。
今天就來介紹怎麼用 Spring Boot 安全地實作檔案下載,尤其是大檔案。
問題:傳統做法為什麼不行?
傳統的做法可能是這樣的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| @GetMapping("/download/{fileId}") public ResponseEntity<byte[]> downloadFile(@PathVariable String fileId) { byte[] fileContent = fileService.getFileContent(fileId); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); headers.setContentDisposition(ContentDisposition.builder("attachment") .filename("document.pdf") .build()); return ResponseEntity.ok() .headers(headers) .body(fileContent); }
|
問題是 getFileContent(fileId) 會把整個檔案讀進記憶體。如果檔案 1GB,Java heap 就爆了。
解決方案:StreamingResponseBody
Spring 提供了 StreamingResponseBody,可以邊讀邊寫,不會把整個檔案都載進記憶體。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| @GetMapping("/download/{fileId}") public ResponseEntity<StreamingResponseBody> downloadFile(@PathVariable String fileId) { StreamingResponseBody body = outputStream -> { byte[] data = new byte[4096]; int bytesRead; InputStream inputStream = fileService.getFileInputStream(fileId); while ((bytesRead = inputStream.read(data)) != -1) { outputStream.write(data, 0, bytesRead); } inputStream.close(); }; HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); headers.setContentDisposition(ContentDisposition.builder("attachment") .filename("document.pdf") .build()); return ResponseEntity.ok() .headers(headers) .body(body); }
|
看到了沒,我們用一個 4KB 的 buffer 逐段讀寫,這樣即使是 10GB 的檔案也不會有問題。
正確的 header 很重要,要讓瀏覽器知道這是一個下載。
Content-Type
根據檔案類型設定:
1 2 3 4 5
| headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
headers.setContentType(MediaType.APPLICATION_PDF); headers.setContentType(MediaType.parseMediaType("application/vnd.ms-excel")); headers.setContentType(MediaType.parseMediaType("application/zip"));
|
Content-Disposition
告訴瀏覽器這是一個 attachment(附件),而不是直接顯示:
1 2 3 4 5 6 7
| headers.setContentDisposition(ContentDisposition.builder("attachment") .filename("document.pdf") .build());
headers.set("Content-Disposition", "attachment; filename=\"document.pdf\"");
|
檔案名稱編碼
如果檔案名稱含有中文,要特別處理。不同的瀏覽器對編碼的支援不同:
1 2 3 4 5 6 7 8 9 10 11
| String filename = "報表.pdf";
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8); headers.set("Content-Disposition", "attachment; filename*=UTF-8''" + encodedFilename);
headers.setContentDisposition(ContentDisposition.builder("attachment") .filename(filename, StandardCharsets.UTF_8) .build());
|
Spring 的 ContentDisposition 會自動幫你處理編碼問題。
完整的實作例子
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| @RestController @RequestMapping("/api/files") public class FileDownloadController { @Autowired private FileService fileService; @GetMapping("/download/{fileId}") public ResponseEntity<StreamingResponseBody> downloadFile( @PathVariable String fileId, @RequestParam(defaultValue = "false") boolean preview) { String disposition = preview ? "inline" : "attachment"; StreamingResponseBody body = outputStream -> { try (InputStream inputStream = fileService.getFileInputStream(fileId)) { byte[] buffer = new byte[4096]; int bytesRead; while ((bytesRead = inputStream.read(buffer)) != -1) { outputStream.write(buffer, 0, bytesRead); } } catch (IOException e) { throw new RuntimeException("檔案下載失敗: " + e.getMessage()); } }; String filename = fileService.getFileName(fileId); String contentType = fileService.getContentType(filename); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.parseMediaType(contentType)); headers.setContentDisposition(ContentDisposition.builder(disposition) .filename(filename, StandardCharsets.UTF_8) .build()); return ResponseEntity.ok() .headers(headers) .body(body); } }
|
處理大檔案的超時問題
下載大檔案時可能會超過預設的超時時間。在 application.yml 裡面增加超時設定:
1 2 3 4 5 6 7 8 9 10
| spring: mvc: async: request-timeout: 600000
server: tomcat: threads: max: 200 connection-timeout: 600000
|
也可以在 controller 裡面用 @RequestMapping 指定超時:
1 2 3 4
| @GetMapping(value = "/download/{fileId}", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE) public ResponseEntity<StreamingResponseBody> downloadFile(@PathVariable String fileId) { }
|
inline vs attachment
Content-Disposition 有兩種值:
- attachment - 瀏覽器會下載檔案,跳出「另存新檔」對話框
- inline - 瀏覽器會嘗試直接顯示檔案(如果支援的話)
1 2 3 4 5 6 7 8 9
| headers.setContentDisposition(ContentDisposition.builder("attachment") .filename("report.pdf") .build());
headers.setContentDisposition(ContentDisposition.builder("inline") .filename("report.pdf") .build());
|
加上進度追蹤(選擇性)
如果要顯示下載進度,可以加 Content-Length header:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| @GetMapping("/download/{fileId}") public ResponseEntity<StreamingResponseBody> downloadFile(@PathVariable String fileId) { long fileSize = fileService.getFileSize(fileId); StreamingResponseBody body = outputStream -> { try (InputStream inputStream = fileService.getFileInputStream(fileId)) { byte[] buffer = new byte[4096]; int bytesRead; while ((bytesRead = inputStream.read(buffer)) != -1) { outputStream.write(buffer, 0, bytesRead); outputStream.flush(); } } }; HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); headers.setContentLength(fileSize); headers.setContentDisposition(ContentDisposition.builder("attachment") .filename(fileService.getFileName(fileId)) .build()); return ResponseEntity.ok() .headers(headers) .body(body); }
|
有了 Content-Length,瀏覽器就能算出下載進度。
錯誤處理
要妥善處理可能的異常:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| @GetMapping("/download/{fileId}") public ResponseEntity<StreamingResponseBody> downloadFile(@PathVariable String fileId) { if (!fileService.exists(fileId)) { throw new FileNotFoundException("檔案不存在: " + fileId); } if (!fileService.hasPermission(getCurrentUser(), fileId)) { throw new AccessDeniedException("無權限下載此檔案"); } StreamingResponseBody body = outputStream -> { try (InputStream inputStream = fileService.getFileInputStream(fileId)) { byte[] buffer = new byte[4096]; int bytesRead; while ((bytesRead = inputStream.read(buffer)) != -1) { outputStream.write(buffer, 0, bytesRead); } } catch (IOException e) { logger.error("檔案下載失敗: " + fileId, e); throw e; } }; return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileService.getFileName(fileId) + "\"") .body(body); }
|
Spring Data 的更簡潔寫法
如果用 Spring Data,可以直接返回 Resource:
1 2 3 4 5 6 7 8 9
| @GetMapping("/download/{fileId}") public ResponseEntity<Resource> downloadFile(@PathVariable String fileId) { Resource resource = fileService.getFileResource(fileId); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + resource.getFilename() + "\"") .body(resource); }
|
Spring 會自動用 streaming 的方式傳輸 Resource。
重點整理
- 大檔案下載用
StreamingResponseBody,不要把整個檔案讀進記憶體
- 設定正確的
Content-Type 和 Content-Disposition header
- 中文檔案名稱要正確編碼
- 加上
Content-Length header 讓瀏覽器顯示進度
- 要注意超時設定,尤其是大檔案
- 先驗證檔案存在和使用者權限
下次要實作檔案下載,就用 StreamingResponseBody,不會踩坑。
你的鼓勵將被轉換為我明天繼續加班的動力(真的)。 ❤️