文件上傳是 Web 開發的高頻需求。Apache Commons FileUpload 作為經典組件,雖然 Spring MVC 已內建 MultipartFile 支援,但理解其底層原理對排查疑難雜症、實現自定義解析器、適配非 Spring 環境仍有重要價值。本文從核心原理到生產級最佳實踐,全鏈路解析文件上傳技術棧。
<!-- Maven 坐標(建議使用 1.4+ 版本,修復多個安全漏洞) -->
<dependency>
<groupId>commons-fileupload</groupId>
<artifactId>commons-fileupload</artifactId>
<version>1.5</version><!-- 最新穩定版 -->
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.11.0</version>
</dependency>
<form action="/upload" method="post" enctype="multipart/form-data">
<input type="text" name="username" />
<input type="file" name="avatar" />
<input type="file" name="photos" multiple />
<button type="submit">上傳</button>
</form>
<input> 必須有 name,否則瀏覽器不發送該字段@WebServlet("/upload")
@MultipartConfig(
location = "/tmp/upload", // 臨時文件存儲目錄
maxFileSize = 10 * 1024 * 1024, // 10MB 單文件限制
maxRequestSize = 50 * 1024 * 1024, // 50MB 總請求限制
fileSizeThreshold = 1024 * 1024 // 1MB 內存閾值,超過寫磁盤
)
public class FileUploadServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
req.setCharacterEncoding("UTF-8");
resp.setContentType("application/json;charset=UTF-8");
try {
// 1. 獲取所有 Part(文件與普通字段統一抽象)
Collection<Part> parts = req.getParts();
Map<String, Object> result = new HashMap<>();
List<Map<String, Object>> files = new ArrayList<>();
for (Part part : parts) {
String name = part.getName();
String submittedFileName = part.getSubmittedFileName();
if (submittedFileName != null && !submittedFileName.isEmpty()) {
// 文件字段
String savedPath = saveFile(part, submittedFileName);
files.add(Map.of(
"field", name,
"originalName", submittedFileName,
"savedPath", savedPath,
"size", part.getSize()
));
} else {
// 普通表單字段
String value = new String(part.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
result.put(name, value);
}
}
result.put("files", files);
writeJson(resp, result);
} catch (Exception e) {
writeError(resp, "UPLOAD_FAILED", e.getMessage());
}
}
private String saveFile(Part part, String originalName) throws IOException {
String ext = originalName.substring(originalName.lastIndexOf('.'));
String newName = UUID.randomUUID() + ext;
Path saveDir = Paths.get("/data/uploads", LocalDate.now().toString());
Files.createDirectories(saveDir);
Path target = saveDir.resolve(newName);
part.write(target.toString()); // 自動處理臨時文件移動/刪除
return target.toString();
}
// JSON 輔助方法省略...
}
# application.yml
spring:
servlet:
multipart:
enabled: true
max-file-size: 50MB
max-request-size: 100MB
file-size-threshold: 2MB
location: ${java.io.tmpdir}/multipart # 臨時文件目錄
resolve-lazily: true # 延遲解析,按需讀取
@RestController
@RequestMapping("/api/upload")
@Validated
public class UploadController {
@PostMapping("/avatar")
public R<String> uploadAvatar(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "userId", required = false) Long userId) {
// 1. 基礎校驗
if (file.isEmpty()) return R.fail("文件不能為空");
if (file.getSize() > 10 * 1024 * 1024) return R.fail("文件超過 10MB 限制");
String contentType = file.getContentType();
if (!List.of("image/jpeg", "image/png", "image/webp").contains(contentType)) {
return R.fail("僅支持 JPG/PNG/WEBP 格式");
}
// 2. 業務處理(實際項目中委託 Service 層)
String url = fileStorageService.storeAvatar(file, userId);
return R.ok(url);
}
@PostMapping("/batch")
public R<List<String>> batchUpload(
@RequestParam("files") List<MultipartFile> files) {
// 批量上傳邏輯...
}
}
public interface FileStorageService {
String store(MultipartFile file, String bizType);
void delete(String url);
}
@Service
@ConditionalOnProperty(name = "storage.type", havingValue = "local", matchIfMissing = true)
public class LocalFileStorageService implements FileStorageService {
@Value("${storage.local.base-path:/data/uploads}")
private String basePath;
@Value("${storage.local.domain:https://localhost:8080}")
private String domain;
@Override
public String store(MultipartFile file, String bizType) {
String ext = FilenameUtils.getExtension(file.getOriginalFilename());
String fileName = UUID.randomUUID() + "." + ext;
String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
String relativePath = bizType + "/" + datePath + "/" + fileName;
Path target = Paths.get(basePath, relativePath);
try {
Files.createDirectories(target.getParent());
file.transferTo(target); // 高效零拷貝移動
} catch (IOException e) {
throw new StorageException("文件保存失敗", e);
}
return domain + "/files/" + relativePath;
}
// delete 實現省略...
}
| 維度 | 風險 | 防護措施 |
|---|---|---|
| 文件類型驗證 | 上傳惡意腳本、木馬、WebShell | 雙重驗證:MIME Type + 魔數檢測 + 後綴白名單 |
| 文件名處理 | 路徑遍歷攻擊、覆蓋系統文件 | UUID 重命名、路徑規範化、禁止用戶控制路徑 |
| 大小限制 | DoS 攻擊佔滿磁盤/內存 | 多層限制:Nginx client_max_body_size + Spring maxRequestSize + 業務層校驗 |
| 存儲隔離 | 上傳目錄被直接訪問執行 | 存儲目錄置於 WebRoot 外、Nginx 禁用腳本執行、對象存儲簽名 URL |
| 病毒掃描 | 惡意文件傳播 | 集成 ClamAV/ICAP 掃描服務、異步掃描流程 |
| 斷點續傳 | 大文件上傳失敗重傳浪費帶寬 | 分片上傳、MD5 秒傳、Resumable.js / Uppy 前端庫 |
MultipartCleaner 或定時任務清理 spring.servlet.multipart.location 目錄URIEncoding=UTF-8、Spring spring.mvc.servlet.multipart.standard-servlet-multipart-resolver 正確解析proxy_read_timeout、Tomcat connectionTimeout、負載均衡器閒置超時從 Servlet 3.0 @MultipartConfig 到 Spring Boot 自動配置,文件上傳技術棧已高度成熟。核心原理始終是:解析 multipart/request → 內存/磁盤緩衝 → 業務校驗 → 安全存儲 → 返回訪問路徑。生產系統中,建議封裝統一的存儲抽象層,接入對象存儲(S3/OSS/MinIO)、CDN 加速、病毒掃描、審計日誌等企業級能力,構建安全、可擴展的文件服務平台。