《Minio 分片上传实现(基于Spring Boot)》

分片上传(Multipart Upload)是大文件上传的常用方案,可以解决大文件上传超时、网络不稳定等问题。以下是基于Spring Boot和Minio实现分片上传的完整方案。

1. 分片上传原理

  1. 初始化上传:创建分片上传任务,获取唯一上传ID
  2. 上传分片:将文件分成多个分片依次上传
  3. 完成上传:所有分片上传完成后合并文件
  4. 取消上传:可中途取消并删除已上传分片

2. 工具类扩展

在原有MinioUtil基础上增加分片上传相关方法:

java 复制代码
import io.minio.messages.Part;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Component
public class MinioUtil {
    // ... 原有代码 ...

    // 保存上传ID的缓存
    private final Map<String, String> uploadIdCache = new ConcurrentHashMap<>();

    /**
     * 初始化分片上传
     * @param bucketName 存储桶名称
     * @param objectName 对象名称
     * @return 上传ID
     */
    public String initMultiPartUpload(String bucketName, String objectName) throws Exception {
        String uploadId = minioClient.initiateMultipartUpload(
                InitiateMultipartUploadArgs.builder()
                        .bucket(bucketName)
                        .object(objectName)
                        .build()).uploadId();
        
        uploadIdCache.put(objectName, uploadId);
        return uploadId;
    }

    /**
     * 上传分片
     * @param bucketName 存储桶名称
     * @param objectName 对象名称
     * @param uploadId   上传ID
     * @param partNumber 分片序号(1-based)
     * @param inputStream 分片数据流
     * @param partSize   分片大小
     * @return 分片信息
     */
    public Part uploadPart(String bucketName, String objectName, String uploadId,
                         int partNumber, InputStream inputStream, long partSize) throws Exception {
        String etag = minioClient.uploadPart(
                UploadPartArgs.builder()
                        .bucket(bucketName)
                        .object(objectName)
                        .uploadId(uploadId)
                        .partNumber(partNumber)
                        .stream(inputStream, partSize, -1)
                        .build()).etag();
        
        return new Part(partNumber, etag);
    }

    /**
     * 完成分片上传
     * @param bucketName 存储桶名称
     * @param objectName 对象名称
     * @param uploadId   上传ID
     * @param parts      分片列表
     */
    public void completeMultiPartUpload(String bucketName, String objectName, 
                                      String uploadId, Part[] parts) throws Exception {
        minioClient.completeMultipartUpload(
                CompleteMultipartUploadArgs.builder()
                        .bucket(bucketName)
                        .object(objectName)
                        .uploadId(uploadId)
                        .parts(parts)
                        .build());
        
        uploadIdCache.remove(objectName);
    }

    /**
     * 取消分片上传
     * @param bucketName 存储桶名称
     * @param objectName 对象名称
     * @param uploadId   上传ID
     */
    public void abortMultiPartUpload(String bucketName, String objectName, String uploadId) throws Exception {
        minioClient.abortMultipartUpload(
                AbortMultipartUploadArgs.builder()
                        .bucket(bucketName)
                        .object(objectName)
                        .uploadId(uploadId)
                        .build());
        
        uploadIdCache.remove(objectName);
    }

    /**
     * 获取上传ID(从缓存)
     * @param objectName 对象名称
     * @return 上传ID
     */
    public String getUploadId(String objectName) {
        return uploadIdCache.get(objectName);
    }
}

3. 控制器实现

java 复制代码
@RestController
@RequestMapping("/multipart")
public class MultipartUploadController {

    @Autowired
    private MinioUtil minioUtil;

    @Value("${minio.bucketName}")
    private String bucketName;

    // 保存各分片上传进度
    private final Map<String, List<Part>> partProgressMap = new ConcurrentHashMap<>();

    /**
     * 初始化分片上传
     */
    @PostMapping("/init")
    public ResponseEntity<?> initUpload(@RequestParam String fileName) {
        try {
            String uploadId = minioUtil.initMultiPartUpload(bucketName, fileName);
            partProgressMap.put(fileName, new ArrayList<>());
            return ResponseEntity.ok(Map.of(
                    "uploadId", uploadId,
                    "code", 200,
                    "message", "初始化成功"
            ));
        } catch (Exception e) {
            return ResponseEntity.status(500).body(Map.of(
                    "code", 500,
                    "message", "初始化失败: " + e.getMessage()
            ));
        }
    }

    /**
     * 上传分片
     */
    @PostMapping("/upload")
    public ResponseEntity<?> uploadPart(
            @RequestParam String fileName,
            @RequestParam String uploadId,
            @RequestParam int partNumber,
            @RequestParam MultipartFile file) {
        try {
            // 获取或创建分片列表
            List<Part> parts = partProgressMap.computeIfAbsent(fileName, k -> new ArrayList<>());

            // 上传分片
            Part part = minioUtil.uploadPart(
                    bucketName, fileName, uploadId,
                    partNumber, file.getInputStream(), file.getSize());

            // 保存分片信息
            parts.add(part);

            return ResponseEntity.ok(Map.of(
                    "code", 200,
                    "message", "分片上传成功",
                    "partNumber", partNumber,
                    "etag", part.etag()
            ));
        } catch (Exception e) {
            return ResponseEntity.status(500).body(Map.of(
                    "code", 500,
                    "message", "分片上传失败: " + e.getMessage()
            ));
        }
    }

    /**
     * 完成分片上传
     */
    @PostMapping("/complete")
    public ResponseEntity<?> completeUpload(
            @RequestParam String fileName,
            @RequestParam String uploadId) {
        try {
            List<Part> parts = partProgressMap.get(fileName);
            if (parts == null || parts.isEmpty()) {
                throw new RuntimeException("没有找到分片信息");
            }

            // 完成上传
            minioUtil.completeMultiPartUpload(
                    bucketName, fileName, uploadId,
                    parts.toArray(new Part[0]));

            // 清理缓存
            partProgressMap.remove(fileName);

            return ResponseEntity.ok(Map.of(
                    "code", 200,
                    "message", "文件上传完成",
                    "fileName", fileName,
                    "url", minioUtil.getFileUrl(bucketName, fileName)
            ));
        } catch (Exception e) {
            return ResponseEntity.status(500).body(Map.of(
                    "code", 500,
                    "message", "完成上传失败: " + e.getMessage()
            ));
        }
    }

    /**
     * 取消上传
     */
    @PostMapping("/abort")
    public ResponseEntity<?> abortUpload(
            @RequestParam String fileName,
            @RequestParam String uploadId) {
        try {
            minioUtil.abortMultiPartUpload(bucketName, fileName, uploadId);
            partProgressMap.remove(fileName);
            return ResponseEntity.ok(Map.of(
                    "code", 200,
                    "message", "上传已取消"
            ));
        } catch (Exception e) {
            return ResponseEntity.status(500).body(Map.of(
                    "code", 500,
                    "message", "取消上传失败: " + e.getMessage()
            ));
        }
    }
}

4. 前端实现示例

前端可以使用以下逻辑实现分片上传:

js 复制代码
// 配置
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB分片大小

async function uploadFile(file) {
    // 1. 初始化上传
    const initResponse = await fetch(`/multipart/init?fileName=${file.name}`);
    const initData = await initResponse.json();
    const uploadId = initData.uploadId;
    
    // 2. 分片上传
    const chunks = Math.ceil(file.size / CHUNK_SIZE);
    const parts = [];
    
    for (let i = 0; i < chunks; i++) {
        const start = i * CHUNK_SIZE;
        const end = Math.min(file.size, start + CHUNK_SIZE);
        const chunk = file.slice(start, end);
        
        const formData = new FormData();
        formData.append('file', chunk);
        formData.append('fileName', file.name);
        formData.append('uploadId', uploadId);
        formData.append('partNumber', i + 1);
        
        const uploadResponse = await fetch('/multipart/upload', {
            method: 'POST',
            body: formData
        });
        
        const uploadData = await uploadResponse.json();
        console.log(`分片 ${i + 1}/${chunks} 上传完成`, uploadData);
    }
    
    // 3. 完成上传
    const completeResponse = await fetch('/multipart/complete', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
        },
        body: `fileName=${encodeURIComponent(file.name)}&uploadId=${uploadId}`
    });
    
    const completeData = await completeResponse.json();
    console.log('文件上传完成', completeData);
    return completeData.url;
}

5. 断点续传实现

要实现断点续传功能,可以增加以下方法:

java 复制代码
/**
 * 列出已上传的分片
 */
@GetMapping("/listParts")
public ResponseEntity<?> listParts(
        @RequestParam String fileName,
        @RequestParam String uploadId) {
    try {
        List<Part> existingParts = minioUtil.listParts(bucketName, fileName, uploadId);
        return ResponseEntity.ok(Map.of(
                "code", 200,
                "data", existingParts.stream()
                        .map(p -> Map.of(
                                "partNumber", p.partNumber(),
                                "etag", p.etag()
                        ))
                        .collect(Collectors.toList())
        ));
    } catch (Exception e) {
        return ResponseEntity.status(500).body(Map.of(
                "code", 500,
                "message", "查询分片失败: " + e.getMessage()
        ));
    }
}

然后在MinioUtil中添加:

java 复制代码
/**
 * 列出已上传的分片
 */
public List<Part> listParts(String bucketName, String objectName, String uploadId) throws Exception {
    return minioClient.listParts(
            ListPartsArgs.builder()
                    .bucket(bucketName)
                    .object(objectName)
                    .uploadId(uploadId)
                    .build()).result().partList();
}

前端可以首先查询已上传的分片,然后只上传缺失的分片:

js 复制代码
async function resumeUpload(file, uploadId) {
    // 查询已上传的分片
    const partsResponse = await fetch(`/multipart/listParts?fileName=${file.name}&uploadId=${uploadId}`);
    const partsData = await partsResponse.json();
    const uploadedParts = new Set(partsData.data.map(p => p.partNumber));
    
    // 继续上传剩余分片
    const chunks = Math.ceil(file.size / CHUNK_SIZE);
    
    for (let i = 0; i < chunks; i++) {
        if (!uploadedParts.has(i + 1)) {
            // 上传缺失的分片...
        }
    }
    
    // 完成上传...
}

6. 注意事项

  1. 分片大小:建议设置为5MB-15MB,过小会增加请求次数,过大会增加失败风险
  2. 并发上传:可以并行上传多个分片以提高速度,但需要注意服务器压力
  3. 超时设置:为分片上传操作设置合理的超时时间
  4. 清理机制:实现定时任务清理未完成的分片上传,避免存储空间浪费
  5. 安全性:验证上传ID和分片序号,防止恶意请求
相关推荐
dualven_in_csdn1 小时前
搞了两天的win7批处理脚本问题
java·linux·前端
你的人类朋友2 小时前
✍️【Node.js程序员】的数据库【索引优化】指南
前端·javascript·后端
小超爱编程2 小时前
纯前端做图片压缩
开发语言·前端·javascript
应巅3 小时前
echarts 数据大屏(无UI设计 极简洁版)
前端·ui·echarts
Jimmy3 小时前
CSS 实现描边文字效果
前端·css·html
islandzzzz4 小时前
HMTL+CSS+JS-新手小白循序渐进案例入门
前端·javascript·css·html
Senar4 小时前
网页中如何判断用户是否处于闲置状态
前端·javascript
很甜的西瓜4 小时前
typescript软渲染实现类似canvas的2d矢量图形引擎
前端·javascript·typescript·图形渲染·canvas
诺浅4 小时前
AWS S3 SDK FOR JAVA 基本使用及如何兼容七牛云
java·spring boot·aws
Allen Bright4 小时前
【CSS-9】深入理解CSS字体图标:原理、优势与最佳实践
前端·css