檔案下載功能在 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

正確的 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
// 方式一:使用 ContentDisposition builder
headers.setContentDisposition(ContentDisposition.builder("attachment")
.filename("document.pdf")
.build());

// 方式二:直接設定 header(比較舊的寫法)
headers.set("Content-Disposition", "attachment; filename=\"document.pdf\"");

檔案名稱編碼

如果檔案名稱含有中文,要特別處理。不同的瀏覽器對編碼的支援不同:

1
2
3
4
5
6
7
8
9
10
11
String filename = "報表.pdf";

// 方式一:用 URLEncoder
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8);
headers.set("Content-Disposition",
"attachment; filename*=UTF-8''" + encodedFilename);

// 方式二:用 Spring 的 ContentDisposition(推薦)
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) {

// 根據 preview 參數決定是 inline 還是 attachment
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) {
// log error
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 # 10 分鐘(毫秒)

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
// 下載 PDF
headers.setContentDisposition(ContentDisposition.builder("attachment")
.filename("report.pdf")
.build());

// 預覽 PDF(瀏覽器如果支援就直接顯示)
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-TypeContent-Disposition header
  • 中文檔案名稱要正確編碼
  • 加上 Content-Length header 讓瀏覽器顯示進度
  • 要注意超時設定,尤其是大檔案
  • 先驗證檔案存在和使用者權限

下次要實作檔案下載,就用 StreamingResponseBody,不會踩坑。