首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> Java 程式設計 >> Java 相關知識

Commons FileUpload 進階實戰:文件上傳、下載與線上預覽統一實現

     編輯:Java 相關知識

在企業級應用中,文件管理往往需要「上傳、下載、線上預覽」三位一體的能力。本文基於 Commons FileUpload 組件,構建統一的文件處理 Servlet,實現多文件上傳、斷點續傳預備、安全下載與瀏覽器內聯預覽功能,並解決中文文件名編碼、大文件流式傳輸等工程化難題。

統一入口 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();
    }
}

關鍵工程化細節

1. 文件名安全清洗

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;
}

2. 前端頁面:上傳與文件列表

<!-- 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>

生產環境擴展建議

  1. 元數據持久化:引入數據庫表 sys_file 記錄文件 MD5、業務關聯、訪問權限、版本號
  2. 分佈式存儲:接入 MinIO/S3/OSS,Servlet 僅作為網關轉發簽名 URL
  3. 圖片處理管線:上傳後異步生成縮略圖、WebP 轉換、水印添加
  4. 權限控制:集成 Spring Security / Shiro,實現文件級 ACL、簽名 URL 時效性
  5. 審計日誌:記錄上傳/下載/預覽操作日誌,含用戶、IP、文件、耗時
  6. 病毒掃描:上傳完成發送異步消息觸發 ClamAV 掃描,未通過隔離處理

總結

基於 Commons FileUpload 的統一文件 Servlet,通過 Content-Disposition 控制下載/預覽行為,Range 支援媒體流式傳輸,sanitizeFilename 防禦路徑遍歷,streaming copy 實現大文件低內存傳輸。這套方案無需重型框架,部署輕量,適合微服務網關層、嵌入式設備、遺留系統改造等場景。配合前端 Uppy/Resumable.js 可快速構建具備斷點續傳、分片上傳、進度條的現代化文件上傳體驗。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved