在 Web 開發中,提供大量檔案下載時,傳統做法通常是先在伺服器端完整打包成 ZIP 檔,再讓使用者下載。這不僅佔用大量暫存空間,也會讓使用者在打包過程中白白等待。本文介紹一種「邊打包、邊下載」的串流式實作,利用 HTTP Chunked Transfer Encoding,將壓縮資料即時推送給客戶端,實現零緩衝、零等待的下載體驗。
整個流程包含三個關鍵角色:
Transfer-Encoding: chunked,並取得 OutputStream。FilterOutputStream,攔截寫入操作,自動將資料包裝成 Chunk 格式(長度(16進位) + CRLF + 資料 + CRLF)。ZipOutputStream,遞迴遍歷目錄/檔案,將內容寫入上述串流。private void down(File file, String fileName) throws IOException {
OutputStream outputStream = mySocket.getOutputStream();
StringBuffer sb = new StringBuffer("HTTP/1.1 200 OK\r\n");
sb.append("Server: java/1.1\r\n");
sb.append("Content-Type: application/octet-stream;charset=UTF-8\r\n");
sb.append("Content-Disposition: attachment; filename=" + fileName + "\r\n");
sb.append("Transfer-Encoding: chunked\r\n");
sb.append("Connection: Keep-Alive\r\n\r\n");
outputStream.write(sb.toString().getBytes());
outputStream.flush();
// 關鍵:用 MyOutputStream 包裝原始輸出串流
ZipCompressor zipCompressor = new ZipCompressor(new MyOutputStream(outputStream));
zipCompressor.compress(file);
// 發送 Chunked 結束標記:0\r\n\r\n
outputStream.write("0\r\n\r\n".getBytes());
outputStream.flush();
outputStream.close();
// ... 關閉 socket
}
重點:Transfer-Encoding: chunked 告訴瀏覽器資料分塊傳輸,瀏覽器會邊接收邊寫入磁碟,無需等待完整檔案。
public class MyOutputStream extends FilterOutputStream {
private final byte[] rnBytes = "\r\n".getBytes();
public MyOutputStream(OutputStream out) { super(out); }
@Override
public void write(byte[] b) throws IOException {
// 長度用 16 進位
out.write(Integer.toHexString(b.length).getBytes());
out.write(rnBytes);
out.write(b);
out.write(rnBytes);
}
@Override
public void write(byte[] b, int off, int len) throws IOException {
out.write(Integer.toHexString(len).getBytes());
out.write(rnBytes);
out.write(b, off, len);
out.write(rnBytes);
}
/** 重寫 close() 避免關閉底層串流,否則無法發送結束標記 */
@Override
public void close() throws IOException { }
}
public class ZipCompressor {
private static final int BUFFER = 8192;
private final OutputStream outputStream;
public ZipCompressor(OutputStream outputStream) {
this.outputStream = outputStream;
}
public void compress(File file) {
if (!file.exists()) throw new RuntimeException("檔案不存在:" + file.getAbsolutePath());
try (CheckedOutputStream cos = new CheckedOutputStream(outputStream, new CRC32());
ZipOutputStream out = new ZipOutputStream(cos)) {
compress(file, out, "");
// 關閉 ZipOutputStream 會寫入 Central Directory,但因為我們重寫了 close(),這裡需手動 finish
out.finish();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private void compress(File file, ZipOutputStream out, String basedir) throws IOException {
if (file.isDirectory()) {
compressDirectory(file, out, basedir);
} else {
compressFile(file, out, basedir);
}
}
private void compressDirectory(File dir, ZipOutputStream out, String basedir) throws IOException {
File[] files = dir.listFiles();
if (files == null) return;
for (File f : files) {
compress(f, out, basedir + dir.getName() + "/");
}
}
private void compressFile(File file, ZipOutputStream out, String basedir) throws IOException {
try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(file))) {
ZipEntry entry = new ZipEntry(basedir + file.getName());
out.putNextEntry(entry);
byte[] buffer = new byte[BUFFER];
int count;
while ((count = bis.read(buffer)) != -1) {
out.write(buffer, 0, count); // 實際寫入會經由 MyOutputStream 變成 Chunk
}
}
}
}
| 優點 | 缺點/注意事項 |
|---|---|
| 零暫存檔,不佔伺服器磁碟空間 | 若壓縮過程發生錯誤,客戶端已接收部分資料,難以重試 |
| 使用者立即看到下載進度,體驗極佳 | 無法預先得知最終檔案大小,瀏覽器進度條可能顯示「未知大小」 |
| 適用於大檔案、大目錄打包下載 | 需確保網路連線穩定,中斷會導致 ZIP 損毀 |
透過 FilterOutputStream 實作 Chunked 編碼,搭配 ZipOutputStream 的串流特性,即可輕鬆打造「邊打包、邊下載」的高效下載功能。此模式同樣適用於 CSV 匯出、報表產生等需即時串流輸出的場景,是提升使用者體驗的利器。