文件上傳三要素
- form method="post":GET 請求長度受限,無法傳輸二進制文件
- enctype="multipart/form-data":指定請求體為多部分編碼,邊界分隔各字段
- input type="file":提供文件選擇控件
依賴引入
<dependency>
<groupId>commons-fileupload</groupId>
<artifactId>commons-fileupload</artifactId>
1.4
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
2.11.0
</dependency>
核心類職責
| 類別 | 職責 | 關鍵方法 |
DiskFileItemFactory | 磁盤存儲工廠,配置閾值與臨時目錄 | setSizeThreshold(), setRepository() |
ServletFileUpload | 解析請求核心類 | isMultipartContent(), parseRequest(), setFileSizeMax() |
FileItem | 單個表單項 (普通字段或文件) | isFormField(), getFieldName(), getString(), getInputStream(), getName() |
基礎上傳 Servlet 完整實現
@WebServlet("/upload")
@MultipartConfig // Servlet 3.0+ 可選,配合 Commons FileUpload 需手動解析
public class UploadServlet extends HttpServlet {
private static final String UPLOAD_DIR = "WEB-INF/upload";
private static final int MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
private static final int MAX_REQUEST_SIZE = 50 * 1024 * 1024; // 50MB
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
req.setCharacterEncoding("UTF-8");
resp.setContentType("text/html;charset=UTF-8");
// 1. 獲取真實存儲路徑
String storePath = getServletContext().getRealPath(UPLOAD_DIR);
File storeDir = new File(storePath);
if (!storeDir.exists()) storeDir.mkdirs();
// 2. 配置工廠
DiskFileItemFactory factory = new DiskFileItemFactory();
factory.setSizeThreshold(1024 * 1024); // 1MB 閾值,超過寫臨時文件
factory.setRepository(new File(System.getProperty("java.io.tmpdir")));
// 3. 創建解析器
ServletFileUpload upload = new ServletFileUpload(factory);
upload.setHeaderEncoding("UTF-8"); // 解決文件名中文亂碼
upload.setFileSizeMax(MAX_FILE_SIZE);
upload.setSizeMax(MAX_REQUEST_SIZE);
// 4. 校驗請求類型
if (!ServletFileUpload.isMultipartContent(req)) {
resp.getWriter().println("請求類型錯誤,必須為 multipart/form-data");
return;
}
// 5. 解析並處理
try {
List items = upload.parseRequest(req);
for (FileItem item : items) {
if (item.isFormField()) {
// 普通表單字段
String fieldName = item.getFieldName();
String fieldValue = item.getString("UTF-8");
System.out.println(fieldName + " = " + fieldValue);
} else {
// 文件字段
processFileItem(item, storePath, resp);
}
}
resp.getWriter().println("上傳成功!");
} catch (FileUploadException e) {
handleUploadException(e, resp);
} catch (Exception e) {
log.error("上傳處理異常", e);
resp.getWriter().println("上傳失敗:" + e.getMessage());
}
}
private void processFileItem(FileItem item, String storePath, HttpServletResponse resp)
throws Exception {
String originalName = item.getName();
if (originalName == null || originalName.trim().isEmpty()) return;
// 只保留文件名 (去除路徑)
String fileName = originalName.substring(originalName.lastIndexOf("\\") + 1);
fileName = fileName.substring(fileName.lastIndexOf("/") + 1); // Unix 路徑兼容
// 生成唯一文件名防衝突
String savedName = UUID.randomUUID().toString() + "_" + fileName;
File savedFile = new File(storePath, savedName);
// 流式寫入
try (InputStream in = item.getInputStream();
FileOutputStream out = new FileOutputStream(savedFile)) {
byte[] buffer = new byte[8192];
int len;
while ((len = in.read(buffer)) > 0) {
out.write(buffer, 0, len);
}
}
// 刪除臨時文件 (Commons FileUpload 大文件會產生臨時文件)
item.delete();
System.out.println("文件保存:" + savedFile.getAbsolutePath());
}
private void handleUploadException(FileUploadException e, HttpServletResponse resp)
throws IOException {
if (e instanceof FileSizeLimitExceededException) {
resp.getWriter().println("單文件超過大小限制 (10MB)");
} else if (e instanceof SizeLimitExceededException) {
resp.getWriter().println("總請求超過大小限制 (50MB)");
} else {
resp.getWriter().println("上傳解析失敗:" + e.getMessage());
}
}
}
JSP 上傳表單
<%@ page pageEncoding="UTF-8" %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>文件上傳</title>
</head>
<body>
<form action="${pageContext.request.contextPath}/upload" method="post"
enctype="multipart/form-data">
用戶名:<input type="text" name="username" required><br/><br/>
文件 1:<input type="file" name="file1"><br/>
文件 2:<input type="file" name="file2"><br/><br/>
<input type="submit" value="上傳">
</form>
</body>
</html>
進階功能擴展
1. 文件類型驗證 (白名單)
private static final Set ALLOWED_EXT = Set.of("jpg", "jpeg", "png", "gif", "pdf", "doc", "docx");
private boolean isAllowed(String fileName) {
int dot = fileName.lastIndexOf('.');
if (dot <= 0) return false;
String ext = fileName.substring(dot + 1).toLowerCase();
return ALLOWED_EXT.contains(ext);
}
2. 進度監聽
upload.setProgressListener(new ProgressListener() {
@Override
public void update(long bytesRead, long contentLength, int items) {
int percent = (int) (bytesRead * 100.0 / contentLength);
// 可存入 Session 或 Redis,前端 AJAX 輪詢顯示進度
System.out.printf("上傳進度: %d%% (%d/%d bytes)%n", percent, bytesRead, contentLength);
}
});
3. 迭代器模式 (大文件/高性能)
FileItemIterator iter = upload.getItemIterator(request);
while (iter.hasNext()) {
FileItemStream item = iter.next();
String fileName = item.getName();
try (InputStream is = item.openStream()) {
// 直接流式處理,不緩存 FileItem 對象
Files.copy(is, Paths.get(storePath, makeFileName(fileName)));
}
}
Spring MVC / Spring Boot 整合
配置 MultipartResolver (Spring MVC)
@Bean
public CommonsMultipartResolver multipartResolver() {
CommonsMultipartResolver resolver = new CommonsMultipartResolver();
resolver.setMaxUploadSize(50 * 1024 * 1024);
resolver.setMaxUploadSizePerFile(10 * 1024 * 1024);
resolver.setDefaultEncoding("UTF-8");
return resolver;
}
Controller 直接接收 MultipartFile
@PostMapping("/upload")
public String upload(@RequestParam("username") String username,
@RequestParam("file1") MultipartFile file1,
@RequestParam("file2") MultipartFile file2) {
if (file1.isEmpty() && file2.isEmpty()) return "error";
saveFile(file1);
saveFile(file2);
return "success";
}
private void saveFile(MultipartFile file) throws IOException {
if (file.isEmpty()) return;
String savedName = UUID.randomUUID() + "_" + file.getOriginalFilename();
Path path = Paths.get(uploadDir, savedName);
Files.copy(file.getInputStream(), path, StandardCopyOption.REPLACE_EXISTING);
}
安全與最佳實踐清單
- ✅ **存儲目錄置於 `WEB-INF` 或應用外部**,防止直接 URL 訪問
- ✅ **文件名重命名**:UUID + 原名,防止覆蓋、路徑遍歷攻擊 (`../../etc/passwd`)
- ✅ **限制大小**:單文件、總請求雙重限制
- ✅ **類型驗證**:副檔名白名單 + 可選 Magic Number (文件頭) 驗證
- ✅ **臨時文件清理**:`item.delete()` / `Files.delete()` 釋放磁盤空間
- ✅ **編碼統一**:`setHeaderEncoding("UTF-8")` + `item.getString("UTF-8")`
- ✅ **異步處理**:大文件上傳返回任務 ID,後台處理,前端輪詢狀態
- ✅ **防病毒掃描**:生產環境集成 ClamAV 等掃描引擎
常見問題排查
| 現象 | 原因 | 解決 |
| request.getParameter 為 null
| 未設置 enctype 或未解析 multipart
| 檢查 form enctype、確保 Filter/Servlet 正確解析 |
| 中文文件名亂碼
| Header 編碼未設置
| `upload.setHeaderEncoding("UTF-8")` |
| 大文件上傳失敗
| 超過默認大小限制
| 設置 `setFileSizeMax` / `setSizeMax` / Spring `maxUploadSize` |
| 臨時文件佔滿磁盤
| 未調用 item.delete()
| finally 塊中調用 delete(),或配置定時清理任務 |
| Spring Boot 註解不生效
| 缺少 MultipartResolver Bean
| 添加 CommonsMultipartResolver 或使用 StandardServletMultipartResolver |