文件上傳下載是 Java Web 開發的核心技能。本文從 HTTP 協議層面剖析 multipart 解析原理,構建生產級文件處理體系:涵蓋前端表單規範、Servlet 解析器配置、存儲目錄設計、文件名去重、大文件流式處理、下載響應頭控制、安全防護清單,並對比 SmartUpload 等替代方案,提供 SSH 整合指南。
當 enctype="multipart/form-data" 時,瀏覽器按 RFC 7578 格式構造請求體:
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Length: 12345
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="username"
zhangsan
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="avatar"; filename="photo.jpg"
Content-Type: image/jpeg
[二進制圖片數據...]
------WebKitFormBoundary7MA4YWxkTrZu0gW--
DiskFileItemFactory (工廠)
├── sizeThreshold: 內存緩衝閾值 (默認 10KB)
├── repository: 臨時文件目錄 (默認 java.io.tmpdir)
└── createItem(): 產出 FileItem 實例
ServletFileUpload (解析器)
├── fileSizeMax: 單文件最大值
├── sizeMax: 整個請求最大值
├── headerEncoding: 文件名編碼 (關鍵!)
├── parseRequest(): 返回 List
└── getItemIterator(): 迭代器模式 (低內存模式)
FileItem (統一抽象)
├── isFormField(): 區分普通字段/文件
├── getInputStream(): 獲取數據流
├── write(File): 直接保存 (自動處理臨時文件移動)
├── delete(): 清理臨時文件
└── getName()/getFieldName()/getString()
@WebServlet("/upload")
public class UploadServlet extends HttpServlet {
private ServletFileUpload uploader;
@Override
public void init() throws ServletException {
DiskFileItemFactory factory = new DiskFileItemFactory();
// 1. 臨時目錄:項目外部、非 Web 可訪問路徑
File tempDir = new File("/var/tmp/myapp_upload");
tempDir.mkdirs();
factory.setRepository(tempDir);
factory.setSizeThreshold(1024 * 1024); // 1MB
uploader = new ServletFileUpload(factory);
uploader.setHeaderEncoding("UTF-8"); // 解決中文文件名亂碼
uploader.setFileSizeMax(50 * 1024 * 1024); // 50MB
uploader.setSizeMax(200 * 1024 * 1024); // 200MB
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
req.setCharacterEncoding("UTF-8");
resp.setContentType("application/json;charset=UTF-8");
PrintWriter writer = resp.getWriter();
if (!ServletFileUpload.isMultipartContent(req)) {
writeError(writer, "INVALID_CONTENT_TYPE", "必須使用 multipart/form-data");
return;
}
try {
List<FileItem> items = uploader.parseRequest(req);
Map<String, Object> result = new HashMap<>();
List<Map<String, Object>> uploaded = new ArrayList<>();
for (FileItem item : items) {
if (item.isFormField()) {
// 普通表單字段
result.put(item.getFieldName(), item.getString("UTF-8"));
} else {
// 文件處理
String originalName = item.getName();
if (originalName == null || originalName.isEmpty()) continue;
// 1. 文件名安全處理
String cleanName = sanitizeFilename(originalName);
String savedName = generateUniqueFilename(cleanName);
// 2. 分級存儲目錄:按日期 + 業務類型分層
String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
String bizType = "common"; // 可從請求參數獲取
Path saveDir = Paths.get("/data/uploads", bizType, datePath);
Files.createDirectories(saveDir);
Path target = saveDir.resolve(savedName);
// 3. 流式寫入 (避免大文件佔用內存)
try (InputStream in = item.getInputStream();
OutputStream out = new BufferedOutputStream(Files.newOutputStream(target))) {
byte[] buffer = new byte[8192];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
}
uploaded.add(Map.of(
"originalName", originalName,
"savedName", savedName,
"path", target.toString(),
"url", "/download?file=" + URLEncoder.encode(savedName, "UTF-8"),
"size", item.getSize()
));
}
}
result.put("files", uploaded);
writeSuccess(writer, result);
} catch (FileUploadException e) {
if (e instanceof SizeLimitExceededException) {
writeError(writer, "FILE_TOO_LARGE", "文件大小超過限制");
} else {
writeError(writer, "PARSE_FAILED", e.getMessage());
}
} catch (Exception e) {
writeError(writer, "SERVER_ERROR", e.getMessage());
}
}
// 工具方法:sanitizeFilename, generateUniqueFilename, writeSuccess, writeError...
}
location /uploads { internal; } 僅允許內部轉發訪問| 策略 | 實現 | 優點 | 缺點 |
|---|---|---|---|
| UUID + 原名 | UUID.randomUUID() + "_" + originalName | 可讀性好、極低衝突 | 文件名較長 |
| UUID + 擴展名 | UUID.randomUUID() + ext | 長度固定、無特殊字符 | 丟失原名信息 |
| MD5 + 擴展名 | DigestUtils.md5Hex(inputStream) + ext | 自動去重、同一文件單存 | 需先讀取全量計算 MD5 |
| 雪花算法 ID | IdWorker.nextId() + ext | 有序、可反推時間、分佈式友好 | 需引入依賴 |
// 方案 A:日期分層(推薦)
/data/uploads/avatar/2024/01/15/uuid.jpg
// 方案 B:Hash 分桶(極大量級)
String hash = Hashing.murmur3_32().hashString(filename, StandardCharsets.UTF_8).toString();
Path path = Paths.get(baseDir, hash.substring(0,2), hash.substring(2,4), filename);
// 生成 256x256 = 65536 個子目錄,每目錄平均文件數可控
@WebServlet("/download")
public class DownloadServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
String filename = req.getParameter("file");
if (filename == null) {
resp.sendError(HttpServletResponse.SC_BAD_REQUEST);
return;
}
// 安全檢查
filename = sanitizeFilename(filename);
Path filePath = Paths.get("/data/uploads", filename).normalize();
if (!filePath.startsWith(Paths.get("/data/uploads").normalize()) || !Files.exists(filePath)) {
resp.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
// 1. Content-Type
String mimeType = Files.probeContentType(filePath);
resp.setContentType(mimeType != null ? mimeType : "application/octet-stream");
// 2. Content-Disposition: attachment 強制下載
String encodedName = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
resp.setHeader("Content-Disposition",
"attachment; filename=\"" + encodedName + "\"; filename*=UTF-8''" + encodedName);
// 3. Content-Length
resp.setContentLengthLong(Files.size(filePath));
// 4. 緩衝控制 + 流式傳輸
resp.setBufferSize(64 * 1024);
try (InputStream in = Files.newInputStream(filePath);
OutputStream out = resp.getOutputStream()) {
byte[] buffer = new byte[16384];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
}
}
}
<!-- upload.jsp -->
<form action="/upload" method="post" enctype="multipart/form-data">
<input type="text" name="username" placeholder="用戶名" required /><br/>
<input type="file" name="file1" accept="image/*" /><br/>
<input type="file" name="file2" accept=".pdf,.doc,.docx" /><br/>
<button type="submit">上傳</button>
</form>
<!-- download.jsp -->
<c:forEach items="${fileList}" var="f">
<form action="/download" method="get">
<input type="hidden" name="file" value="${f.savedName}" />
<button type="submit">下載: ${f.originalName} (${f.size} bytes)</button>
</form>
</c:forEach>
Struts2 內建 FileUploadInterceptor,配置 struts.xml 即可:
<action name="upload" class="uploadAction">
<interceptor-ref name="fileUpload">
<param name="maximumSize">10485760</param> <!-- 10MB -->
<param name="allowedTypes">image/jpeg,image/png,application/pdf</param>
</interceptor-ref>
<interceptor-ref name="defaultStack"/>
<result name="success">/success.jsp</result>
<result name="input">/upload.jsp</result>
</action>
Action 類只需定義 File myFile; String myFileFileName; String myFileContentType;,框架自動注入。SmartUpload 等老牌組件已停止維護,不建議新項目使用。
Files.probeContentType() + 擴展名白名單Path.normalize() + startsWith(rootPath) 雙重校驗client_max_body_size → Servlet sizeMax → 業務邏輯校驗noexec、Nginx 禁用腳本執行、存儲服務單獨域名JavaEE 文件上傳下載的核心在於:正確解析 Multipart 請求 → 安全的文件名與路徑處理 → 流式傳輸避免內存溢出 → 完整的 HTTP 響應頭控制 → 多層安全防護。Commons FileUpload 作為成熟組件,配合 Servlet 3.0+ 原生 API 或 Spring MVC 抽象,能構建穩健的文件服務。現代架構下,建議將文件服務剝離為獨立微服務,對接對象存儲(S3/MinIO/OSS)、CDN 加速、病毒掃描、元數據索引,實現存算分離與彈性擴展。