HTTP 文件上傳本質
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryXYZ
------WebKitFormBoundaryXYZ
Content-Disposition: form-data; name="file"; filename="test.jpg"
Content-Type: image/jpeg
<二進制文件內容>
------WebKitFormBoundaryXYZ
Content-Disposition: form-data; name="desc"
用戶輸入的描述文本
------WebKitFormBoundaryXYZ--
- 表單必須
enctype="multipart/form-data" 且 method="post"
- 每個部分由
boundary 分隔,包含 Header + Body
Commons FileUpload 核心三大類
| 類 | 職責 | 關鍵方法 |
DiskFileItemFactory | 創建 FileItem 工廠,控制內存閾值、臨時目錄 | setSizeThreshold(1024*1024)、setRepository(File) |
ServletFileUpload | 解析 Request,返回 List | parseRequest(req)、setFileSizeMax()、setSizeMax()、setProgressListener() |
FileItem | 代表一個表單項 (文件或普通字段) | isFormField()、getInputStream()、getString(encoding)、write(File)、delete() |
標準上傳 Servlet 完整代碼
@WebServlet("/upload")
@MultipartConfig(
fileSizeThreshold = 1024 * 1024, // 1MB 寫臨時文件
maxFileSize = 10 * 1024 * 1024, // 10MB 單文件
maxRequestSize = 50 * 1024 * 1024 // 50MB 總請求
)
public class UploadServlet extends HttpServlet {
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
req.setCharacterEncoding("UTF-8");
resp.setContentType("application/json;charset=UTF-8");
String uploadBase = getServletContext().getRealPath("/upload");
File baseDir = new File(uploadBase);
if (!baseDir.exists()) baseDir.mkdirs();
try {
// 1. 解析請求
List items = new ServletFileUpload(new DiskFileItemFactory()).parseRequest(req);
Map result = new HashMap<>();
List savedFiles = new ArrayList<>();
for (FileItem item : items) {
if (item.isFormField()) {
// 普通字段
result.put(item.getFieldName(), item.getString("UTF-8"));
} else {
// 文件字段
if (item.getSize() == 0) continue; // 空文件域
String originalName = item.getName();
String ext = originalName.substring(originalName.lastIndexOf(".")).toLowerCase();
// 安全校驗:白名單
if (!ALLOWED_EXTS.contains(ext)) {
throw new IllegalArgumentException("禁止的文件類型: " + ext);
}
// 唯一文件名:UUID + 原始後綴
String saveName = UUID.randomUUID().toString().replace("-", "") + ext;
// Hash 目錄分佈:避免單目錄文件過多
String dir1 = Integer.toHexString(saveName.hashCode() & 0xF);
String dir2 = Integer.toHexString((saveName.hashCode() & 0xF0) >> 4);
File saveDir = new File(baseDir, dir1 + "/" + dir2);
if (!saveDir.exists()) saveDir.mkdirs();
File dest = new File(saveDir, saveName);
item.write(dest); // 內部自動處理內存/臨時文件寫入
savedFiles.add(dir1 + "/" + dir2 + "/" + saveName);
}
}
result.put("code", 0);
result.put("files", savedFiles);
result.put("msg", "上傳成功");
resp.getWriter().write(new ObjectMapper().writeValueAsString(result));
} catch (Exception e) {
log.error("上傳失敗", e);
resp.getWriter().write("{\"code\":-1,\"msg\":\"" + e.getMessage() + "\"}");
}
}
}
進階專題:大文件分片上傳與斷點續傳
前端分片邏輯
// 文件切片:5MB/片
const CHUNK_SIZE = 5 * 1024 * 1024;
const chunks = [];
for (let start = 0; start < file.size; start += CHUNK_SIZE) {
chunks.push(file.slice(start, start + CHUNK_SIZE));
}
// 並發上傳 (限制 3 個並發)
async function uploadChunks(file, chunks) {
const fileHash = await calculateHash(file); // SparkMD5 計算文件唯一標識
for (let i = 0; i < chunks.length; i += 3) {
await Promise.all(chunks.slice(i, i+3).map((chunk, idx) =>
uploadChunk(fileHash, i+idx, chunks.length, chunk)
));
}
// 通知後端合併
await fetch('/upload/merge', {method:'POST', body: JSON.stringify({hash: fileHash, name: file.name})});
}
後端合併邏輯
@PostMapping("/upload/chunk")
public void uploadChunk(@RequestParam String hash, @RequestParam int chunkIndex,
@RequestParam int totalChunks, @RequestParam MultipartFile chunk) {
File chunkDir = new File(TEMP_DIR, hash);
if (!chunkDir.exists()) chunkDir.mkdirs();
chunk.transferTo(new File(chunkDir, String.valueOf(chunkIndex)));
}
@PostMapping("/upload/merge")
public void mergeChunks(@RequestParam String hash, @RequestParam String fileName) {
File chunkDir = new File(TEMP_DIR, hash);
File[] chunks = chunkDir.listFiles((d, n) -> n.matches("\\d+"));
Arrays.sort(chunks, Comparator.comparingInt(f -> Integer.parseInt(f.getName())));
String ext = fileName.substring(fileName.lastIndexOf("."));
String saveName = hash + ext;
File dest = new File(STORAGE_DIR, saveName);
try (FileOutputStream fos = new FileOutputStream(dest)) {
for (File chunk : chunks) {
Files.copy(chunk.toPath(), fos);
chunk.delete(); // 合併後刪除分片
}
}
chunkDir.delete(); // 刪除臨時目錄
// 入庫文件元數據...
}
生產級避坑指南 (必讀)
| 風險點 | 後果 | 防護措施 |
| 文件類型繞過 | 上傳 .jsp/.php 獲取 Shell | 1) 白名單校驗後綴 2) Magic Number 校驗文件頭 3) 存儲目錄禁用腳本執行權限 |
| 文件名注入 | 路徑遍歷 ../ 覆蓋關鍵文件 | 不使用原始文件名,一律 UUID 重命名 |
| 大文件 OOM | 內存溢出、服務不可用 | DiskFileItemFactory 設置閾值、分片上傳、流式寫入 |
| DoS 攻擊 | 佔滿磁盤/帶寬 | 限制單文件/總大小、單 IP 並發上傳數、啟用防毒掃描 |
| 併發衝突 | 同名文件覆蓋 | UUID/雪花算法命名、分佈式鎖 |
| 臨時文件洩漏 | 磁盤佔用持續增長 | 定時清理任務、FileItem.delete()、JVM 關閉鉤子 |
| 中文文件名亂碼 | 下載/預覽失敗 | 原始名單獨存數據庫,物理文件用 UUID |
現代替代方案對比
| 方案 | 優點 | 缺點 | 適用場景 |
| Commons FileUpload | 成熟穩定、Servlet 標準 | API 較老舊、無原生分片支持 | 傳統 Servlet/JSP 項目 |
Servlet 3.0 @MultipartConfig | 零依賴、容器原生支持 | 功能較簡單、配置靈活性低 | Spring Boot/Servlet 容器 |
Spring MultipartFile | 封裝優雅、集成驗證、自動綁定 | 依賴 Spring MVC | Spring 生態項目首選 |
| 對象存儲直傳 (OSS) | 後端無壓力、CDN 加速、天然分佈式 | 需簽名授權流程、成本較高 | 生產環境、大文件、高併發 |
總結
Java 文件上傳的核心在於 multipart 解析、臨時文件管理、安全校驗、存儲策略。從 Commons FileUpload 到 Servlet 3.0 再到 OSS 直傳,技術演進的本質是 將文件 IO 壓力從應用服務器剝離。無論用哪種框架,白名單校驗、UUID 命名、Hash 目錄、分片合併、臨時文件清理 這五大工程實踐永遠有效。