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());
        }
    }
相关推荐
阿珊和她的猫1 天前
IIFE:JavaScript 中的立即调用函数表达式
开发语言·javascript·状态模式
阿珊和她的猫1 天前
CommonJS:Node.js 的模块化基石
node.js·状态模式
零一科技3 天前
然然管理系统-双前端加持!基于Ant Design Vue 4.x的前端正在开发中
前端·状态模式
前端不太难3 天前
RN 列表状态设计 Checklist
react native·list·状态模式
爬点儿啥3 天前
[Ai Agent] 13 用 Streamlit 为 Agents SDK 打造可视化“驾驶舱”
人工智能·ai·状态模式·agent·streamlit·智能体
罗小爬EX4 天前
杂记 - 状态模式 VS. 责任链模式
状态模式·责任链模式
JavaBoy_XJ4 天前
行为型-状态模式
状态模式
星盾网安4 天前
智慧门店系统开发-06-新嘉丽WF质保录入功能实现
状态模式
小北方城市网5 天前
GEO 新生态:跨界融合 + 场景渗透,重构 AI 时代本地商业增长版图
大数据·网络·人工智能·python·状态模式
小园子的小菜5 天前
Token自动续期技术方案全解析:原理、优缺点与生产实践
java·后端·架构·状态模式