文件上傳核心原理
HTTP 請求結構 (multipart/form-data)
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="username"
zhangsan
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file1"; filename="photo.jpg"
Content-Type: image/jpeg
<二進制數據...>
------WebKitFormBoundary7MA4YWxkTrZu0gW--
- 邊界字符串分隔各 Part
- 每 Part 含
Content-Disposition 標識字段名/文件名
- 文件 Part 含
Content-Type、空行、二進制體
- 結束邊界以
-- 結尾
手工解析流程 (教學用)
InputStream is = request.getInputStream();
BufferedReader br = new BufferedReader(new InputStreamReader(is));
String boundary = br.readLine(); // 讀取邊界
// 循環解析各 Part...
// 注意:首行邊界、文件頭、空行、二進制體、結束邊界
Commons FileUpload 框架封裝
核心類
DiskFileItemFactory:臨時文件/內存閾值、存儲目錄
ServletFileUpload:解析入口、大小限制、進度監聽、編碼設置
FileItem:單個表單項,isFormField() 區分類型
標準上傳流程
DiskFileItemFactory factory = new DiskFileItemFactory();
factory.setSizeThreshold(1024 * 1024); // 1MB 閾值
factory.setRepository(new File("/tmp"));
ServletFileUpload upload = new ServletFileUpload(factory);
upload.setHeaderEncoding("UTF-8");
upload.setFileSizeMax(10 * 1024 * 1024);
upload.setSizeMax(50 * 1024 * 1024);
List items = upload.parseRequest(request);
for (FileItem item : items) {
if (item.isFormField()) {
String value = item.getString("UTF-8");
} else {
String fileName = item.getName().substring(item.getName().lastIndexOf("\\") + 1);
try (InputStream in = item.getInputStream();
FileOutputStream out = new FileOutputStream(savePath + fileName)) {
IOUtils.copy(in, out);
}
item.delete(); // 清理臨時文件
}
}
文件下載核心原理
HTTP 響應關鍵頭
HTTP/1.1 200 OK
Content-Type: application/octet-stream
Content-Disposition: attachment; filename="download.jpg"
Content-Length: 102400
<二進制數據流>
Content-Disposition: attachment 強制瀏覽器下載而非預覽
filename 指定下載文件名,需編碼處理中文
Content-Length 支持進度條、斷點續傳
標準下載流程
File file = new File(realPath, fileName);
response.setContentType("application/octet-stream");
response.setHeader("Content-Disposition",
"attachment; filename=" + URLEncoder.encode(fileName, "UTF-8"));
response.setContentLengthLong(file.length());
try (FileInputStream fis = new FileInputStream(file);
BufferedInputStream bis = new BufferedInputStream(fis);
OutputStream os = response.getOutputStream()) {
byte[] buffer = new byte[8192];
int len;
while ((len = bis.read(buffer)) > 0) {
os.write(buffer, 0, len);
}
}
主流框架整合對比
| 框架 | 上傳核心 | 關鍵配置 | Controller 寫法 |
| Struts 2 |
Commons FileUpload 內建 |
struts.multipart.maxSize
struts.multipart.saveDir |
private File file; String fileFileName; 自動注入暫存文件 |
| Spring MVC |
CommonsMultipartResolver / StandardServletMultipartResolver |
spring.servlet.multipart.max-file-size
max-request-size |
@RequestParam MultipartFile file |
| Spring Boot |
自動配置 StandardServletMultipartResolver (Tomcat) / MultipartResolver (Jetty/Undertow) |
application.yml 同 Spring MVC |
同 Spring MVC,零配置啟用 |
Struts 2 完整示例
Action
public class FileUploadAction extends ActionSupport {
private File file; // 暫存文件
private String fileFileName; // 原始文件名
private String fileContentType;
private String username;
@Override
public String execute() {
String saveDir = ServletActionContext.getServletContext()
.getRealPath("/upload");
File dest = new File(saveDir, fileFileName);
Files.copy(file.toPath(), dest.toPath(), StandardCopyOption.REPLACE_EXISTING);
return SUCCESS;
}
// getter/setter...
}
struts.xml
<action name="fileUpload" class="com.example.FileUploadAction">
<result>/success.jsp</result>
<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"/>
</action>
Spring MVC / Boot 完整示例
配置 (application.yml)
spring:
servlet:
multipart:
max-file-size: 10MB
max-request-size: 50MB
enabled: true
file-size-threshold: 1MB # 閾值,超過寫磁盤
location: /tmp # 臨時目錄
上傳 Controller
@RestController
@RequestMapping("/api/file")
public class FileController {
@Value("${file.upload-dir:./uploads}")
private String uploadDir;
@PostMapping("/upload")
public Result upload(@RequestParam("file") MultipartFile file,
@RequestParam(value = "username", required = false) String username) {
if (file.isEmpty()) return Result.error("文件為空");
// 安全文件名
String original = file.getOriginalFilename();
String ext = original.substring(original.lastIndexOf("."));
String savedName = UUID.randomUUID() + ext;
Path path = Paths.get(uploadDir, savedName);
try {
Files.createDirectories(path.getParent());
Files.copy(file.getInputStream(), path, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
return Result.error("保存失敗: " + e.getMessage());
}
return Result.ok(Map.of("url", "/files/" + savedName, "name", original));
}
@GetMapping("/download/{fileName}")
public void download(@PathVariable String fileName,
HttpServletRequest request, HttpServletResponse response) {
// 安全校驗
String safeName = Paths.get(fileName).getFileName().toString();
File file = new File(uploadDir, safeName);
if (!file.exists()) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
// 編碼兼容
String ua = request.getHeader("User-Agent");
String encoded = ua.contains("MSIE") || ua.contains("Trident") ?
URLEncoder.encode(safeName, StandardCharsets.UTF_8) :
new String(safeName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1);
response.reset();
response.setContentType("application/octet-stream");
response.setHeader("Content-Disposition", "attachment; filename=\"" + encoded + "\"");
response.setContentLengthLong(file.length());
try (FileInputStream fis = new FileInputStream(file);
OutputStream os = response.getOutputStream()) {
byte[] buffer = new byte[8192];
int len;
while ((len = fis.read(buffer)) > 0) {
os.write(buffer, 0, len);
}
} catch (IOException e) {
log.error("下載失敗", e);
}
}
}
安全與最佳實踐清單
- 🔒 **文件名安全**:UUID 重命名、路徑遍歷防護 (`Paths.get(fileName).getFileName()`)
- 🔒 **類型驗證**:副檔名白名單 + Magic Number 檢測 (如
Files.probeContentType())
- 🔒 **大小限制**:框架層 + 業務層雙重限制
- 🔒 **存儲隔離**:上傳目錄置於 Web 根目錄外、或對象存儲 (OSS/S3/MinIO)
- 🔒 **病毒掃描**:生產環境集成 ClamAV、或上傳至對象存儲觸發掃描
- 🔒 **權限控制**:下載介面校驗用戶權限、防止越權訪問
- ⚡ **效能優化**:大文件分片上傳、斷點續傳、NIO 零拷貝下載、CDN 加速
- 📝 **審計日誌**:記錄上傳/下載用戶、文件、時間、IP、結果
常見問題速查
| 現象 | 原因 | 解決 |
Spring Boot MultipartFile 為 null | 缺少 MultipartResolver / 表單 enctype 錯誤
| 檢查依賴、配置、form enctype=multipart/form-data |
| Struts 2 文件名亂碼 | 未設置編碼 / Tomcat URIEncoding
| struts.multipart.encoding=UTF-8 + Tomcat URIEncoding="UTF-8" |
| 上傳大文件報 413/連接重置 | 超過框架/服務器/代理限制
| 調整 Spring max-request-size、Tomcat maxPostSize、Nginx client_max_body_size |
| 下載文件名亂碼 | User-Agent 編碼分支缺失
| 雙重 filename/filename* 或 UA 判斷 |
| 文件無法下載/損壞 | 響應流被 Filter/Interceptor 污染
| 返回 void、response.reset()、確保無其他輸出 |