在企業級應用中,文件管理往往需要「上傳、下載、線上預覽」三位一體的能力。本文基於 Commons FileUpload 組件,構建統一的文件處理 Servlet,實現多文件上傳、斷點續傳預備、安全下載與瀏覽器內聯預覽功能,並解決中文文件名編碼、大文件流式傳輸等工程化難題。
採用 REST 風格單一 Servlet 處理三種操作,通過 HTTP Method 與參數區分:
POST /file — 上傳(multipart/form-data)GET /file?filename=xxx — 下載(attachment)GET /file?filename=xxx&inline=true — 預覽@WebServlet("/file")
public class FileServlet extends HttpServlet {
private static final String STORAGE_ROOT = "D:/data/files";
private static final long MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
private static final long MAX_REQUEST_SIZE = 500 * 1024 * 1024; // 500MB
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
// 上傳邏輯
handleUpload(req, resp);
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
String filename = req.getParameter("filename");
boolean inline = "true".equals(req.getParameter("inline"));
if (filename == null || filename.isEmpty()) {
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "缺少 filename 參數");
return;
}
// 防止路徑遍歷攻擊
filename = sanitizeFilename(filename);
Path filePath = Paths.get(STORAGE_ROOT, filename).normalize();
// 安全檢查:確保文件在存儲根目錄下
if (!filePath.startsWith(Paths.get(STORAGE_ROOT).normalize())) {
resp.sendError(HttpServletResponse.SC_FORBIDDEN, "非法文件路徑");
return;
}
if (!Files.exists(filePath)) {
resp.sendError(HttpServletResponse.SC_NOT_FOUND, "文件不存在");
return;
}
serveFile(resp, filePath, filename, inline);
}
// 上傳處理核心...
// 文件服務核心...
// 文件名清洗...
}
private void handleUpload(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
// 1. 校驗 Content-Type
if (!ServletFileUpload.isMultipartContent(req)) {
writeJsonError(resp, "請求必須為 multipart/form-data");
return;
}
// 2. 配置解析器
DiskFileItemFactory factory = new DiskFileItemFactory();
factory.setRepository(Files.createDirectories(Paths.get(STORAGE_ROOT, "tmp")).toFile());
factory.setSizeThreshold(1024 * 1024); // 1MB 內存閾值
ServletFileUpload upload = new ServletFileUpload(factory);
upload.setFileSizeMax(MAX_FILE_SIZE);
upload.setSizeMax(MAX_REQUEST_SIZE);
upload.setHeaderEncoding("UTF-8"); // 解決文件名中文亂碼
// 3. 解析請求
List<FileItem> items;
try {
items = upload.parseRequest(req);
} catch (FileUploadException e) {
writeJsonError(resp, "解析失敗: " + e.getMessage());
return;
}
// 4. 處理每個表單項
List<Map<String, Object>> results = new ArrayList<>();
for (FileItem item : items) {
if (item.isFormField()) {
// 普通字段
String value = item.getString("UTF-8");
results.add(Map.of("type", "field", "name", item.getFieldName(), "value", value));
} else {
// 文件字段
String originalName = item.getName();
String cleanName = sanitizeFilename(originalName);
String savedName = UUID.randomUUID() + "_" + cleanName;
Path target = Paths.get(STORAGE_ROOT, savedName);
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);
}
}
results.add(Map.of(
"type", "file",
"field", item.getFieldName(),
"originalName", originalName,
"savedName", savedName,
"size", item.getSize(),
"url", req.getContextPath() + "/file?filename=" + URLEncoder.encode(savedName, "UTF-8")
));
}
}
writeJson(resp, Map.of("success", true, "files", results));
}
private void serveFile(HttpServletResponse resp, Path filePath, String filename, boolean inline)
throws IOException {
// 1. 確定 Content-Type
String mimeType = Files.probeContentType(filePath);
if (mimeType == null) mimeType = "application/octet-stream";
resp.setContentType(mimeType);
// 2. 設置 Content-Disposition
String disposition = inline ? "inline" : "attachment";
// RFC 5987 編碼支援中文文件名
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8)
.replace("+", "%20");
resp.setHeader("Content-Disposition",
disposition + "; filename=\"" + encodedFilename + "\"; filename*=UTF-8''" + encodedFilename);
// 3. 支援 Range 請求(視頻/音頻拖拽、斷點續傳基礎)
String rangeHeader = req.getHeader("Range");
long fileSize = Files.size(filePath);
long start = 0, end = fileSize - 1;
if (rangeHeader != null && rangeHeader.startsWith("bytes=")) {
resp.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); // 206
String[] ranges = rangeHeader.substring(6).split("-");
start = Long.parseLong(ranges[0]);
if (ranges.length > 1 && !ranges[1].isEmpty()) {
end = Long.parseLong(ranges[1]);
}
resp.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + fileSize);
resp.setHeader("Accept-Ranges", "bytes");
resp.setContentLengthLong(end - start + 1);
} else {
resp.setContentLengthLong(fileSize);
}
// 4. 緩衝控制(防止大文件佔用過多內存)
resp.setBufferSize(64 * 1024); // 64KB 輸出緩衝區
// 5. 流式傳輸(零拷貝優化:JDK 9+ transferTo)
try (InputStream in = Files.newInputStream(filePath);
OutputStream out = resp.getOutputStream()) {
if (start > 0) in.skip(start);
long remaining = end - start + 1;
byte[] buffer = new byte[16384]; // 16KB 讀取緩衝區
int len;
while (remaining > 0 && (len = in.read(buffer, 0, (int)Math.min(buffer.length, remaining))) != -1) {
out.write(buffer, 0, len);
remaining -= len;
}
out.flush();
}
}
private String sanitizeFilename(String filename) {
// 1. 移除路徑分隔符
filename = filename.replaceAll("[\\\\/:*?\"<>|]", "_");
// 2. 截取過長文件名(保留擴展名)
if (filename.length() > 200) {
int dot = filename.lastIndexOf('.');
String ext = dot > 0 ? filename.substring(dot) : "";
String base = dot > 0 ? filename.substring(0, dot) : filename;
filename = base.substring(0, 200 - ext.length()) + ext;
}
// 3. 防止保留名
String[] reserved = {"CON", "PRN", "AUX", "NUL", "COM1", "LPT1"};
String upper = filename.toUpperCase();
for (String r : reserved) {
if (upper.startsWith(r + ".") || upper.equals(r)) {
filename = "_" + filename;
break;
}
}
return filename;
}
<!-- upload.html -->
<form id="uploadForm" enctype="multipart/form-data">
<input type="file" name="files" multiple accept="image/*,.pdf,.doc,.docx" />
<button type="submit">上傳</button>
</form>
<div id="fileList"></div>
<script>
document.getElementById('uploadForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const res = await fetch('/file', {method: 'POST', body: formData});
const data = await res.json();
renderFileList(data.files);
});
function renderFileList(files) {
const container = document.getElementById('fileList');
container.innerHTML = files.map(f => `
<div class="file-item">
<span>${f.originalName} (${formatSize(f.size)})</span>
<a href="${f.url}" target="_blank">預覽</a>
<a href="${f.url}&inline=false" download>下載</a>
</div>
`).join('');
}
</script>
sys_file 記錄文件 MD5、業務關聯、訪問權限、版本號基於 Commons FileUpload 的統一文件 Servlet,通過 Content-Disposition 控制下載/預覽行為,Range 支援媒體流式傳輸,sanitizeFilename 防禦路徑遍歷,streaming copy 實現大文件低內存傳輸。這套方案無需重型框架,部署輕量,適合微服務網關層、嵌入式設備、遺留系統改造等場景。配合前端 Uppy/Resumable.js 可快速構建具備斷點續傳、分片上傳、進度條的現代化文件上傳體驗。