minio分片上传

minio分片上传

前言

为什么要选择将一个大文件拆分成许多小文件来上传?

  • 对于许多服务器和应用框架来说,单次HTTP请求的大小有严格的限制。
  • 上传一个10GB的大文件可能需要数个小时,单一的http请求连接不稳定,造成上传失败。
  • 降低服务器的压力
  • 其次分片上传可以实现断点续传、秒上传等功能

分片上传的技术选择

分片上传分为两种,一种是经过后端服务器来上传,另外一种是前端直传。

  • 第一种是由后端接受分片文件流,在调用minio的api上传
  • 第二种就是有后端生成预签名url返回给前端,前端上传到minio。
    一般我们选择后者,第二种利用客户端宽带,减轻了服务器的压力

实现分片上传

流程图

第一步自定义客户端

java 复制代码
package com.petlife.base.config;

import com.google.common.collect.Multimap;
import io.minio.CreateMultipartUploadResponse;
import io.minio.ListPartsResponse;
import io.minio.MinioClient;
import io.minio.ObjectWriteResponse;
import io.minio.errors.*;
import io.minio.messages.Part;

import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;

public class BigFileClient extends MinioClient{
    public BigFileClient(MinioClient client) {
        super(client);
    }

    /**
     * 创建分片上传任务 - 在MinIO服务器上初始化一个分片上传会话
     * 获取uploadId - 返回一个唯一的uploadId,用于标识这次分片上传会话
     * @param bucket 存储桶名称
     * @param region 区域 (null 表示默认区域)
     * @param object 对象名称 (文件路径)
     * @param headers 额外的请求头
     * @param extraQueryParams 额外的查询参数
     * @return 上传ID
     */
    public String initMultiPartUpload(String bucket, String region, String object, Multimap<String, String> headers, Multimap<String, String> extraQueryParams)  {
        try {
            CreateMultipartUploadResponse response = this.createMultipartUpload(bucket, region, object, headers, extraQueryParams);
            return response.result().uploadId();
        } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | InsufficientDataException | 
                 ServerException | InternalException | XmlParserException | InvalidResponseException | 
                 ErrorResponseException e) {
            throw new RuntimeException("初始化分片上传失败", e);
        }
    }
    /**
     * 合并分片上传 - 将之前初始化的分片上传任务合并为一个完整的对象
     * @param bucketName 存储桶名称
     * @param region 区域 (null 表示默认区域)
     * @param objectName 对象名称 (文件路径)
     * @param uploadId 上传ID (由initMultiPartUpload返回)
     * @param parts 分片信息数组
     * @param extraHeaders 额外的请求头
     * @param extraQueryParams 额外的查询参数
     * @return 合并后的对象写入响应
     */
    public ObjectWriteResponse mergeMultipartUpload(String bucketName, String region, String objectName, String uploadId, Part[] parts, Multimap<String, String> extraHeaders, Multimap<String, String> extraQueryParams)  {
         try {
            return this.completeMultipartUpload(bucketName, region, objectName, uploadId, parts, extraHeaders, extraQueryParams);
        } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | InsufficientDataException |
                 ServerException | InternalException | XmlParserException | InvalidResponseException |
                 ErrorResponseException e) {
            throw new RuntimeException("合并分片上传失败", e);
        }
    }
    /**
     * 列出分片上传 - 获取已上传的分片信息
     * @param bucketName 存储桶名称
     * @param region 区域 (null 表示默认区域)
     * @param objectName 对象名称 (文件路径)
     * @param maxParts 最大返回分片数量 (null 表示默认值)
     * @param partNumberMarker 分片编号标记 (null 表示从第一个分片开始)
     * @param uploadId 上传ID (由initMultiPartUpload返回)
     * @param extraHeaders 额外的请求头
     * @param extraQueryParams 额外的查询参数
     * @return ListPartsResponse 包含的重要信息是:List<Part> 分片信息列表 可以通过part.partNumber()获取分片编号,part.size()获取分片大小,part.etag()获取分片的ETag
     * 可以通过listPartsResponse.parts()获取分片信息列表
     */
    public ListPartsResponse listMultipart(String bucketName, String region, String objectName, Integer maxParts, Integer partNumberMarker, String uploadId, Multimap<String, String> extraHeaders, Multimap<String, String> extraQueryParams)  {
        try {
            return this.listParts(bucketName, region, objectName, maxParts, partNumberMarker, uploadId, extraHeaders, extraQueryParams);
        } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | InsufficientDataException |
                 ServerException | InternalException | XmlParserException | InvalidResponseException |
                 ErrorResponseException e) {
            throw new RuntimeException("列出分片上传失败", e);
        }
    }



    /**
     * 简化版初始化分片上传
     * @param bucketName 存储桶名称
     * @param objectName 对象名称
     * @return 上传ID
     */
    public String initMultiPartUpload(String bucketName, String objectName) {
        return initMultiPartUpload(bucketName, null, objectName, null, null);
    }

    /**
     * 简化版合并分片上传
     * @param bucketName 存储桶名称
     * @param objectName 对象名称
     * @param uploadId 上传ID
     * @param parts 分片数组
     * @return 合并结果
     */
    public ObjectWriteResponse mergeMultipartUpload(String bucketName, String objectName, 
                                                  String uploadId, Part[] parts) {
        return mergeMultipartUpload(bucketName, null, objectName, uploadId, parts, null, null);
    }

    public boolean cancelMultiPartUpload(String bucketName, String objectName, String uploadId) {
        try {
            this.abortMultipartUpload(bucketName, null, objectName, uploadId, null, null);
            return true;
        } catch (Exception e) {
            return false;
        }
    }
}

初始化分片上传

对于分片上传任务的数据库设计,当前的版本比较粗糙。主要是对于已上传文件和分片任务的记录。

sql 复制代码
create table if not exists `pet-life`.upload_task
(
    id           int auto_increment comment '主键id'
        primary key,
    chunk_index  int           null comment '分片索引',
    chunk_etag   int           null comment '分片标签',
    status       int default 0 not null comment '上传状态(1:已经上传 0 : 未上传)',
    src          varchar(50)   null comment '上传路径',
    expired_time datetime      null comment '过期时间'
)
    comment '文件上传任务';

create table if not exists `pet-life`.file_record
(
    id           bigint auto_increment
        primary key,
    file_name    varchar(100) null comment '文件名',
    file_hash    varchar(50)  null comment '文件唯一标识',
    src          varchar(50)  null comment '文件在minio中的存储路径',
    file_size    int          null comment '文件大小',
    is_merged    tinyint      null comment '是否合并 0 否 1 是',
    upload_id    varchar(30)  null comment '上传id',
    expired_time datetime     null comment '过期时间'
)
    comment '文件上传记录';
获取一个上传分片的url共前端使用
java 复制代码
/**
     * 获取分一个分片的上传的url
     * @param uploadId 上传ID
     * @param chunkIndex 分片索引
     * @return 上传URL
     */

    private String generateUploadFileURL(String uploadId, int chunkIndex) {
        try{
            Map<String,String> extraQueryParams = new HashMap<>();
            extraQueryParams.put("uploadId",uploadId);
            extraQueryParams.put("partNumber",String.valueOf(chunkIndex + 1));
            return minioClient.getPresignedObjectUrl(
                    GetPresignedObjectUrlArgs.builder()
                            .method(Method.PUT)
                            .bucket(bucketName)
                            .object(uploadId + "/" + chunkIndex)
                            .extraQueryParams(extraQueryParams)
                            .expiry(1, TimeUnit.DAYS)
                            .build()
            );
        }catch (Exception e){
            throw new RuntimeException("生成上传URL失败:" + e.getMessage());
        }
    }

初始化分上传任务逻辑:

要考虑该文件是否已经上传过了和断点续传的情况。

  • 根据文件唯一标识通过数据库查询,如果已经上传过了,则直接返回访问路径
  • 如果是断电续传的情况则返回完成上传的分片
  • 如果从未上传过,则直接创建一个新的分片上传任务。
java 复制代码
public ResponseDTO<UploadDTO> upload(FileForm fileForm) {
        // 1. 校验文件是否已上传
        String fileHash = fileForm.getFileHash();

        UploadDTO result = new UploadDTO();

        FileRecord fileRecord = fileRecordService.getBaseMapper().selectOne(new LambdaQueryWrapper<FileRecord>().eq(FileRecord::getFileHash,fileHash).ge(FileRecord::getExpiredTime,LocalDateTime.now()));
        //1.1 文件记录不存在,开启新的分片上传任务
        if(fileRecord == null){
            // 1.1.1 文件记录不存在,开启新的分片上传任务
            Map<String,Object> result1 = initChunkUpload02(fileForm.getFileName(), fileForm.getChunkCount());
            String uploadId = (String) result1.get("uploadId");
            List<UploadDTO.chunkInfo> chunkList = (List<UploadDTO.chunkInfo>) result1.get("chunkList");
            // 1.1.2 创建上传任务记录
            synchronized (fileHash){
                CompletableFuture.runAsync(() -> {
                    for(UploadDTO.chunkInfo chunkInfo : chunkList){
                        UploadTask uploadTask = new UploadTask()
                                .setChunkIndex(chunkInfo.getChunkIndex())
                                .setSrc(chunkInfo.getSrc())
                                .setUploadId(uploadId)
                                .setStatus(0)
                                .setExpiredTime(LocalDateTime.now().plusDays(1));
                        uploadTaskService.getBaseMapper().insert(uploadTask);
                    }
                }, threadPoolTaskExecutor);
                // 1.1.3 保存文件记录
                fileRecord = new FileRecord()
                        .setFileHash(fileHash)
                        .setFileName(fileForm.getFileName())
                        .setFileSize(fileForm.getFileSize())
                        .setSrc(null)
                        .setIsMerged(0)
                        .setUploadId(uploadId)
                        .setExpiredTime(LocalDateTime.now().plusDays(1));
                fileRecordService.getBaseMapper().insert(fileRecord);
            }

            //1.1.3 保存分片上传人物
            result.setUploadId(uploadId)
                    .setExist( false)
                    .setChunkList(chunkList)
                    .setObjectName(fileRecord.getFileName());


            return ResponseDTO.success(result);
        }
        if(fileRecord.getIsMerged() == 1){
            // 1.1文件已上传,返回已上传的URL
            result.setExist(true);
            result.setSrc(fileRecord.getSrc());
            return ResponseDTO.success(result);
        }
        //1.2 文件未上传 判断是否是断点上传

        List<UploadTask> uploadTasks = uploadTaskService.getBaseMapper().selectList(new LambdaQueryWrapper<UploadTask>().eq(UploadTask::getUploadId, fileRecord.getUploadId()).eq(UploadTask::getStatus, 0));
        if(!uploadTasks.isEmpty()){
            List<UploadDTO.chunkInfo> chunkList = new ArrayList<>();
            for(UploadTask uploadTask : uploadTasks){
                    UploadDTO.chunkInfo chunkInfo = new UploadDTO.chunkInfo();
                    chunkInfo.setChunkIndex(uploadTask.getChunkIndex());
                    chunkInfo.setSrc(uploadTask.getSrc());
                    chunkList.add(chunkInfo);
            }
            result.setExist(true);
            result.setChunkList(chunkList);
            return ResponseDTO.success(result);
        }
        // 1.2.2 不是断点上传,返回空列表
        result.setExist(false);
        return ResponseDTO.fail("服务器数据库异常");
    }

确认每一个分片的上传

每一个分片完成上传,前端向后端发送一个请求,确认分片上传的状态。

java 复制代码
 public ResponseDTO<Boolean> confirmChunkUpload(ConfirmChunkForm confirmChunkForm) {
        UploadTask uploadTask = new UploadTask();
        uploadTask.setUploadId(confirmChunkForm.getUploadId());
        uploadTask.setChunkEtag(confirmChunkForm.getChunkEtag());
        uploadTask.setChunkIndex(confirmChunkForm.getChunkIndex());
        if(confirmChunkForm.getSuccess()){
            uploadTask.setStatus(1);
        }else{
            uploadTask.setStatus(2);
        }
        int update = uploadTaskService.getBaseMapper().update(uploadTask, new LambdaUpdateWrapper<UploadTask>().eq(UploadTask::getUploadId, confirmChunkForm.getUploadId()).eq(UploadTask::getChunkIndex, confirmChunkForm.getChunkIndex()));
        return ResponseDTO.success(update > 0);
    }

合并分片文件

可以加上对分片任务的校验和数据库的更改,这里就不加了。

java 复制代码
 public ResponseDTO<String> mergeChunk(String uploadID, String objectName) {
        try {
            Part[] parts = bigFileClient.listMultipart(bucketName, null, objectName, null, null, uploadID, null, null)
                    .result().partList().toArray(new Part[0]);

            ObjectWriteResponse objectWriteResponse = bigFileClient.mergeMultipartUpload(bucketName, objectName, uploadID, parts);

            // 构建访问URL
            String finalObjectName = objectWriteResponse.object();
            String src = endpoint + "/" + bucketName + "/" + finalObjectName;

            return ResponseDTO.success(src);
        } catch (Exception e) {
            throw new RuntimeException("合并分片失败:" + e.getMessage());
        }
    }
相关推荐
我爱学习_zwj12 小时前
前端设计模式:轻量级实战指南
设计模式·前端框架·状态模式
LSL666_12 小时前
9 前后端数据处理格式的注意事项
状态模式·请求·响应
仪***沿1 天前
Fluent中颗粒流模拟的门道
状态模式
GDAL1 天前
前端保存用户登录信息 深入全面讲解
前端·状态模式
柯南二号1 天前
【大前端】【Android】把 Activity 重构成 MVVM 的对比示例
android·状态模式
William_cl2 天前
【CSDN 专栏】C# ASP.NET控制器过滤器:自定义 ActionFilterAttribute 实战(避坑 + 图解)
c#·asp.net·状态模式
柯南二号2 天前
【后端】【Java】一文详解Spring Boot RESTful 接口统一返回与异常处理实践
java·spring boot·状态模式·restful
张毫洁3 天前
将后端resources中的文件返给前端下载的方法
前端·状态模式
阿里巴啦4 天前
从零搭建移动端数字人生成应用:React + Go + D‑ID 实战
react.js·golang·状态模式·数字人·did·ai移动端数字人