文件上傳下載是 Web 應用的基礎功能。本文以 Apache Commons FileUpload 為核心,構建一個支援多文件、大文件、進度監控、安全限制的上傳組件,並實現斷點續傳友好、內存高效的下載方案。
<!-- Maven -->
<dependency>
<groupId>commons-fileupload</groupId>
<artifactId>commons-fileupload</artifactId>
<version>1.4</version> <!-- 或更新版 -->
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.11.0</version>
</dependency>
public class UploadConfig {
private String repositoryPath = System.getProperty("java.io.tmpdir"); // 暫存目錄
private String uploadPath = "/data/uploads"; // 最終存儲目錄
private long maxFileSize = 100 * 1024 * 1024; // 100MB 單文件上限
private long maxRequestSize = 500 * 1024 * 1024; // 500MB 總請求上限
private int sizeThreshold = 1024 * 1024; // 1MB 閾值,超過寫暫存文件
// getter/setter 省略
}
@WebServlet("/upload")
@MultipartConfig // Servlet 3.0+ 可選,配合 FileUpload 使用更靈活
public class FileUploadServlet extends HttpServlet {
private final UploadConfig config = new UploadConfig();
private final DiskFileItemFactory factory;
private final ServletFileUpload upload;
public FileUploadServlet() {
factory = new DiskFileItemFactory();
factory.setRepository(new File(config.getRepositoryPath()));
factory.setSizeThreshold(config.getSizeThreshold());
upload = new ServletFileUpload(factory);
upload.setFileSizeMax(config.getMaxFileSize());
upload.setSizeMax(config.getMaxRequestSize());
upload.setHeaderEncoding("UTF-8"); // 解決文件名中文亂碼
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
resp.setContentType("application/json;charset=UTF-8");
PrintWriter writer = resp.getWriter();
Map result = new HashMap<>();
try {
List items = upload.parseRequest(req);
List
<form id="uploadForm" enctype="multipart/form-data" method="post">
<input type="file" name="files" multiple />
<button type="submit">上傳</button>
<progress id="progress" value="0" max="100"></progress>
</form>
<script>
// 使用 FormData + XMLHttpRequest 實現進度條
document.getElementById('uploadForm').addEventListener('submit', e => {
e.preventDefault();
const formData = new FormData(e.target);
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = e => {
if (e.lengthComputable) document.getElementById('progress').value = (e.loaded / e.total) * 100;
};
xhr.onload = () => console.log(xhr.responseText);
xhr.open('POST', '/upload');
xhr.send(formData);
});</script>
public class FileDownloadUtil {
public static void download(HttpServletRequest req, HttpServletResponse resp, File file, String downloadName)
throws IOException {
if (!file.exists() || !file.isFile()) {
resp.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
// 安全檢查:防止目錄遍歷
String canonicalBase = new File("/data/uploads").getCanonicalPath();
String canonicalFile = file.getCanonicalPath();
if (!canonicalFile.startsWith(canonicalBase)) {
resp.sendError(HttpServletResponse.SC_FORBIDDEN);
return;
}
// Content-Type 推斷
String mimeType = URLConnection.guessContentTypeFromName(file.getName());
if (mimeType == null) mimeType = "application/octet-stream";
resp.setContentType(mimeType);
// Content-Disposition: 文件名編碼 (RFC 5987)
String encodedName = StandardCharsets.UTF_8.encode(downloadName).toString(); // 簡化,實際用 UrlUtils
resp.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encodedName);
// 支援 Range 斷點續傳
long fileLength = file.length();
String rangeHeader = req.getHeader("Range");
long start = 0, end = fileLength - 1;
if (rangeHeader != null && rangeHeader.startsWith("bytes=")) {
String[] ranges = rangeHeader.substring(6).split("-");
start = Long.parseLong(ranges[0]);
if (ranges.length > 1 && !ranges[1].isEmpty()) end = Long.parseLong(ranges[1]);
resp.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
resp.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + fileLength);
}
resp.setHeader("Accept-Ranges", "bytes");
resp.setHeader("Content-Length", String.valueOf(end - start + 1));
resp.setHeader("Last-Modified", new Date(file.lastModified()).toString());
// 核心傳輸:使用 NIO 零拷貝 (transferTo) 或 BufferedStream
try (RandomAccessFile raf = new RandomAccessFile(file, "r");
OutputStream out = resp.getOutputStream()) {
raf.seek(start);
long remaining = end - start + 1;
byte[] buffer = new byte[8192];
int len;
while (remaining > 0 && (len = raf.read(buffer, 0, (int)Math.min(buffer.length, remaining))) != -1) {
out.write(buffer, 0, len);
remaining -= len;
}
out.flush();
}
}
}
@WebServlet("/download/*")
public class FileDownloadServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
String pathInfo = req.getPathInfo(); // /download/uuid_filename.ext
if (pathInfo == null || pathInfo.length() <= 1) {
resp.sendError(HttpServletResponse.SC_BAD_REQUEST); return;
}
String savedName = pathInfo.substring(1);
File file = new File("/data/uploads", savedName);
// 從資料庫查詢原始文件名,此處簡化
String originalName = savedName.substring(savedName.indexOf('_') + 1);
FileDownloadUtil.download(req, resp, file, originalName);
}
}
| 項目 | 建議 |
|---|---|
| 文件名過濾 | 僅保留基礎名稱,UUID 重命名存儲,資料庫映射原名 |
| 類型驗證 | 檢查 Magic Number(文件頭),而非只信 Content-Type |
| 大小限制 | Servlet 層 + FileUpload 雙重限制 |
| 暫存目錄 | 獨立分區,定期清理,禁止腳本執行權限 |
| 下載授權 | 結合業務權限校驗,不可直接暴露存儲路徑 |
| 大文件傳輸 | 啟用 transferTo 零拷貝或 NIO DirectBuffer |
| 並發控制 | 下載限流、上傳併發數限制 |
基於 Commons FileUpload 的流式解析配合 DiskFileItemFactory,能以常數內存處理任意大小文件上傳;下載端利用 RandomAccessFile 支援 Range 請求,實現高效斷點續傳。核心原則:**不信任客戶端輸入、不阻塞業務線程、不佔用過量堆內存**。