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

Spring Boot 文件上傳最佳實踐:單文件、多文件、大文件斷點續傳

     編輯:Java 綜合教學

Spring Boot 文件上傳最佳實踐:單文件、多文件、大文件斷點續傳

文件上傳是 Web 開發高頻需求。本文基於 Spring Boot 3.x / Servlet 6.0 規範,系統講解單文件/多文件上傳、配置調優、儲存策略、安全防護、大文件分片上傳與斷點續傳完整方案。

一、核心依賴與自動配置

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 內嵌 Tomcat 自動配置 MultipartResolver -->

application.yml 關鍵配置

spring:
  servlet:
    multipart:
      enabled: true
      max-file-size: 100MB          # 單文件最大值
      max-request-size: 500MB       # 整個請求最大值 (含多文件)
      file-size-threshold: 10MB     # 閾值:超過寫入臨時檔,否則記憶體
      location: /tmp/spring-upload  # 臨時檔目錄 (需可寫、足夠空間)
      resolve-lazily: true          # 延遲解析 (按需讀取),降低記憶體佔用

server:
  tomcat:
    max-swallow-size: -1            # 防止大文件上傳被截斷 (Tomcat 9+)
    connection-timeout: 300s        # 長連線超時
    max-threads: 200

二、單文件上傳 Controller

@RestController
@RequestMapping("/api/upload")
@Slf4j
public class FileUploadController {
    
    private final FileStorageService storageService;
    private final VirusScanner virusScanner; // 介面,可接入 ClamAV
    
    @PostMapping("/single")
    public Result uploadSingle(
            @RequestParam("file") MultipartFile file,
            @RequestParam(value = "bizType", defaultValue = "default") String bizType,
            HttpServletRequest request) {
        
        // 1. 基礎校驗
        if (file.isEmpty()) return Result.fail("文件為空");
        validateFile(file);
        
        // 2. 安全掃描 (異步非阻塞)
        CompletableFuture.runAsync(() -> virusScanner.scan(file.getInputStream()));
        
        // 3. 儲存
        FileVO vo = storageService.store(file, bizType, getCurrentUserId());
        
        // 4. 审計日誌
        log.info("文件上傳: user={}, file={}, size={}, type={}, ip={}", 
            getCurrentUserId(), vo.getOriginalName(), vo.getSize(), vo.getContentType(), getClientIp(request));
        
        return Result.success(vo);
    }
    
    private void validateFile(MultipartFile file) {
        // 檯面類型白名單
        String contentType = file.getContentType();
        if (!ALLOWED_TYPES.contains(contentType)) {
            throw new BizException("FILE_TYPE_NOT_ALLOWED", "不支援的文件類型: " + contentType);
        }
        // 檔名安全:防止路徑遍歷
        String filename = Paths.get(file.getOriginalFilename()).getFileName().toString();
        if (filename.contains("..") || filename.contains(":") || filename.contains("\\")) {
            throw new BizException("INVALID_FILENAME", "非法文件名");
        }
        // 魔數驗證 (可選):讀取前幾位元組確認真實類型
        // if (!MagicNumberUtil.match(file.getInputStream(), contentType)) ...
    }
}

三、多文件上傳

@PostMapping("/multiple")
public Result> uploadMultiple(
        @RequestParam("files") MultipartFile[] files,
        @RequestParam(value = "bizType", defaultValue = "default") String bizType) {
    
    if (files == null || files.length == 0) return Result.fail("無文件");
    if (files.length > 20) return Result.fail("單次上傳文件數不能超過 20");
    
    List results = new ArrayList<>();
    List> futures = new ArrayList<>();
    
    for (MultipartFile file : files) {
        if (!file.isEmpty()) {
            futures.add(CompletableFuture.supplyAsync(() -> {
                validateFile(file);
                return storageService.store(file, bizType, getCurrentUserId());
            }, uploadExecutor)); // 自定義執行緒池隔離 IO
        }
    }
    
    // 等待所有完成
    for (CompletableFuture f : futures) {
        results.add(f.join());
    }
    return Result.success(results);
}

四、儲存服務抽象與多後端實現

public interface FileStorageService {
    FileVO store(MultipartFile file, String bizType, Long userId);
    Resource load(String storageKey);
    void delete(String storageKey);
}

@Service
@Primary
@ConditionalOnProperty(name = "storage.type", havingValue = "local", matchIfMissing = true)
public class LocalFileStorageService implements FileStorageService {
    
    @Value("${storage.local.base-path:/data/upload}")
    private String basePath;
    
    @Override
    public FileVO store(MultipartFile file, String bizType, Long userId) {
        // 1. 生成唯一儲存鍵: bizType/year/month/day/uuid.ext
        String ext = FilenameUtils.getExtension(file.getOriginalFilename());
        String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
        String storageKey = String.format("%s/%s/%s.%s", bizType, datePath, IdUtil.fastUUID(), ext);
        
        Path targetPath = Paths.get(basePath, storageKey);
        try {
            Files.createDirectories(targetPath.getParent());
            // 直接流式寫入,避免記憶體佔用
            Files.copy(file.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING);
        } catch (IOException e) {
            throw new BizException("STORAGE_FAILED", "文件儲存失敗", e);
        }
        
        // 2. 返回元資料
        return FileVO.builder()
            .storageKey(storageKey)
            .originalName(file.getOriginalFilename())
            .contentType(file.getContentType())
            .size(file.getSize())
            .storageType("LOCAL")
            .url("/api/file/" + storageKey) // 下載介面
            .build();
    }
    
    @Override
    public Resource load(String storageKey) {
        Path path = Paths.get(basePath, storageKey);
        if (!Files.exists(path)) throw new BizException("FILE_NOT_FOUND", "文件不存在");
        try {
            return new UrlResource(path.toUri());
        } catch (MalformedURLException e) {
            throw new BizException("LOAD_FAILED", "文件讀取失敗", e);
        }
    }
    
    @Override
    public void delete(String storageKey) {
        try {
            Files.deleteIfExists(Paths.get(basePath, storageKey));
        } catch (IOException e) {
            log.error("刪除失敗: {}", storageKey, e);
        }
    }
}

// S3/MinIO 實現同理,注入 S3Client,使用 PutObjectRequest/GetObjectRequest
@Service
@ConditionalOnProperty(name = "storage.type", havingValue = "s3")
public class S3FileStorageService implements FileStorageService { ... }

五、大文件分片上傳與斷點續傳 (核心難點)

1. 前端切片邏輯 (Web Worker)

// 概念:文件 → 切片 (5MB/片) → 計算每片 MD5 (SparkMD5) → 並行上傳
// 關鍵點:斷點續傳需「文件唯一標識 (文件 MD5) + 切片索引 + 切片 MD5」

2. 後端介面設計

// 2.1 檢查秒傳/已上傳切片
@GetMapping("/check")
public Result checkChunk(
        @RequestParam String fileMd5,
        @RequestParam String fileName,
        @RequestParam long fileSize,
        @RequestParam(required = false) Integer chunkIndex) {
    // 查 Redis/DB:是否已合併完成、已上傳哪些切片
    // 返回 { uploaded: true/false, uploadedChunks: [1,3,5] }
}

// 2.2 上傳單一切片
@PostMapping("/chunk")
public Result uploadChunk(
        @RequestParam("file") MultipartFile chunk,
        @RequestParam String fileMd5,
        @RequestParam int chunkIndex,
        @RequestParam int totalChunks,
        @RequestParam String chunkMd5) {
    // 1. 驗證切片 MD5
    // 2. 儲存到臨時目錄: /tmp/chunks/{fileMd5}/{chunkIndex}
    // 3. 記錄切片完成狀態到 Redis (Set/ Bitmap)
    // 4. 返回成功
}

// 2.3 合併切片
@PostMapping("/merge")
public Result mergeChunks(
        @RequestParam String fileMd5,
        @RequestParam String fileName,
        @RequestParam long fileSize,
        @RequestParam int totalChunks,
        @RequestParam String bizType) {
    // 1. 校驗所有切片是否齊全
    // 2. 異步合併 (大文件合併耗時長,返回受理狀態,WebSocket/輪詢通知結果)
    mergeExecutor.submit(() -> {
        try {
            Path finalPath = storageService.assembleChunks(fileMd5, fileName, totalChunks, bizType);
            // 更新 DB 狀態、發送完成事件
        } catch (Exception e) {
            // 標記失敗、清理臨時切片
        }
    });
    return Result.success("合併任務已受理");
}

3. 切片合併核心實現 (流式、零拷貝優化)

public Path assembleChunks(String fileMd5, String fileName, int totalChunks, String bizType) {
    Path tempDir = Paths.get(chunkTempBase, fileMd5);
    Path finalPath = generateFinalPath(bizType, fileName);
    
    try (FileChannel outChannel = FileChannel.open(finalPath, 
            StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
        
        for (int i = 0; i < totalChunks; i++) {
            Path chunkPath = tempDir.resolve(String.valueOf(i));
            try (FileChannel inChannel = FileChannel.open(chunkPath, StandardOpenOption.READ)) {
                // 零拷貝:直接在核心態傳輸,極快
                long transferred = 0;
                long size = inChannel.size();
                while (transferred < size) {
                    transferred += inChannel.transferTo(transferred, size - transferred, outChannel);
                }
            }
            Files.deleteIfExists(chunkPath); // 即時清理
        }
        Files.deleteIfExists(tempDir);
        return finalPath;
    } catch (IOException e) {
        throw new BizException("MERGE_FAILED", "切片合併失敗", e);
    }
}

六、安全防護清單

風險防護措施
惡意文件上傳 (WebShell)1. 白名單 Content-Type + 檔名後綴雙重校驗
2. 魔數檢測真實格式
3. 儲存路徑與 Web 根目錄隔離 (不可直接訪問)
4. 病毒掃描 (ClamAV 集成)
5. 圖片重繪 (ImageIO 讀寫去除腳本)
路徑遍歷攻擊檔名正規化:Paths.get(filename).getFileName().toString()
DoS (大文件佔滿磁碟/記憶體)max-file-size、max-request-size、file-size-threshold、磁碟配額監控
文件覆蓋/刪除儲存鍵使用 UUID、資料庫記錄所有權、軟刪除
未授權下載下載介面驗證權限、簽名 URL 時效性、防盜鏈 Referer 檢查

七、效能調優建議

  • 異步非阻塞:Controller 返回 CompletableFuture,Tomcat 線程快速釋放
  • 零拷貝合併:FileChannel.transferTo() 避免用戶態/核心態拷貝
  • 對象儲存直傳:前端獲取預簽名 URL 直傳 S3/MinIO/OSS,後端僅驗證回調,流量不經應用伺服器
  • CDN 加速下載:靜態資源走 CDN,簽名 URL 帶過期時間
  • 資料庫索引:file_md5 + chunk_index 聯合唯一索引,加速查重

八、總結

Spring Boot 文件上傳看似簡單,實則涉及流式處理、安全防護、分散式儲存、大文件斷點續傳等多個工程難點。掌握 MultipartFile 流式 API、FileChannel 零拷貝、預簽名直傳、切片 MD5 校驗等核心技術,配合分層儲存抽象 (Local/S3/HDFS)、完善的安全白名單與審計日誌,即可構建企業級、高效能、安全合規的文件上傳服務。

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