基于springboot + minio 实现的断点续传和秒传功能

Controller

    /**
     * 断点续传
     * @param chunk 文件块对象(分片除了最后一片,必须大于等于5M,minio固定要求)
     * @return 文件块信息
     */
    @RequestMapping(value  = "/fileUpload", method = {RequestMethod.GET,RequestMethod.POST})
    public R<FileChunkVO> fileUpload(@ModelAttribute FileChunkDTO chunk){
        return sysFileService.fileUpload(chunk);
    }

Service

    /**
     * 断点续传
     * @param chunk 文件块对象
     * @return 文件块信息
     */
    R<FileChunkVO> fileUpload(FileChunkDTO chunk);

ServiceImpl

    
    private final RedisTemplate redisTemplate;
    private final MinioTemplate minioTemplate;
    private final MinioProperties minioProperties;

    @Value("${minio.defualt-bucket}")
    private String minioDefaultBucket;
    @Value("${minio.tmp-bucket}")
    private String minioTmpBucket;

    @Override
    public R<FileChunkVO> fileUpload(FileChunkDTO dto) {
        // 返回对象
        FileChunkVO vo = BeanUtil.copyProperties(dto, FileChunkVO.class);
        // 设置桶名
        vo.setBucketName(StrUtil.isNotBlank(dto.getBucketName())?dto.getBucketName():minioDefaultBucket);
        // 设置已上传文件块下标(用于断点续传)
        vo.setUploaded(this.getUploadedFileChunkIdx(dto.getIdentifier()));
        try{
            // 上传文件块
            String objectName = this.writeToMinio(dto);
            // 设置上传状态
            vo.setStatus(StrUtil.isNotBlank(objectName)?FileChunkVO.FileChunkStatus.ALL.getStatus():FileChunkVO.FileChunkStatus.SINGLE.getStatus());
            if(StrUtil.isNotBlank(objectName)){
                // 设置对象名
                vo.setObjectName(objectName);
                // 设置访问连接
                vo.setUrl(StrUtil.concat(true, minioProperties.getUrl(), "/", dto.getBucketName(), "/", objectName));
            }
        }catch (Exception ex){
            ex.printStackTrace();
            return R.failed("文件上传异常");
        }
        return R.ok(vo);
    }

    /**
     * 分片写入minio
     * @param dto 分块文件信息
     */
    private String writeToMinio(FileChunkDTO dto) {
        try{
            // 文件块对象
            MultipartFile file = dto.getFile();
            // 桶名
            String bucketName = StrUtil.isNotBlank(dto.getBucketName()) ? dto.getBucketName() : minioDefaultBucket;
            // 对象名
            String objName = StrUtil.concat(true, dto.getIdentifier(), StrUtil.DOT, FileUtil.extName(dto.getFilename()));
            // redis保存key
            String redisKey = StrUtil.concat(true, FileChunkVO.FileChunkConstant.REDIS_DIR, dto.getIdentifier());
            // 创建桶
            minioTemplate.createBucket(bucketName);

            try{
                // 秒传,未查询到数据会报异常,不用处理
                ObjectStat objectInfo = minioTemplate.getObjectInfo(bucketName, objName);
                if(objectInfo!=null){
                    // 复制一份新的对象
                    String newObjName = StrUtil.concat(true, dto.getIdentifier(), "_", IdUtil.simpleUUID(), StrUtil.DOT, FileUtil.extName(dto.getFilename()));
                    minioTemplate.copyObject(bucketName, objName, bucketName, newObjName);
                    return newObjName;
                }
            }catch (Exception ignore){
            }

            // 上传分块文件
            if(file!=null){
                // 当前文件块下标
                Integer chunkNumber = dto.getChunkNumber();
                // 临时文件块对象名
                String tempFileChunkObjName = StrUtil.concat(true, dto.getIdentifier(), "_", chunkNumber.toString());
                // 上传临时文件块
                // 创建临时桶
                minioTemplate.createBucket(minioTmpBucket);
                minioTemplate.putObject(minioTmpBucket, tempFileChunkObjName, file.getInputStream());

                // 将当前分块信息存入redis,后续断点续传使用
                Object oldCacheVal = redisTemplate.opsForValue().get(redisKey);
                String cacheVal = oldCacheVal==null?chunkNumber.toString():StrUtil.concat(true, oldCacheVal.toString(), ",", chunkNumber.toString());
                redisTemplate.opsForValue().set(redisKey, cacheVal, FileChunkVO.FileChunkConstant.REDIS_TIMEOUT, FileChunkVO.FileChunkConstant.REDIS_TIMEOUT_UNIT);

                // 判断是否是最后一次上传
                if(dto.getChunkNumber().equals(dto.getTotalChunks())){
                    // 获取临时对象名称集合
                    List<String> tempNameList = Stream.iterate(1, i -> ++i)
                            .limit(dto.getTotalChunks())
                            .map(i -> StrUtil.concat(true, dto.getIdentifier(), "_", i.toString()))
                            .collect(Collectors.toList());
                    // 合并文件
                    this.merge(bucketName, objName, tempNameList);
                    // 删除临时文件
                    minioTemplate.removeObjects(minioTmpBucket, tempNameList);
                    // 删除redis信息
                    redisTemplate.delete(redisKey);
                    return objName;
               }
            }
        }catch (Exception ex){
            throw new RuntimeException("文件上传异常", ex);
        }
        return null;
    }

    /**
     * 合并文件
     * @param bucketName 桶名
     * @param objName 对象名
     * @param tempNameList 临时文件名列表
     */
    private void merge(String bucketName, String objName, List<String> tempNameList) {
        // 转换对象
        List<ComposeSource> sourceObjectList = tempNameList.stream().map(data -> {
            try {
                return new ComposeSource(minioTmpBucket, data);
            } catch (InvalidArgumentException e) {
                throw new RuntimeException("创建组成源异常", e);
            }
        }).collect(Collectors.toList());

        try {
            // 合并文件
            minioTemplate.composeObject(bucketName, objName, sourceObjectList);
        }catch (Exception e) {
            throw new RuntimeException("合并文件异常", e);
        }
    }

    /**
     * 获取已上传文件块下标列表
     * @param identifier 文件标识
     * @return 已上传下标列表
     */
    private List<Integer> getUploadedFileChunkIdx(String identifier){
        Object data = redisTemplate.opsForValue().get(StrUtil.concat(true, FileChunkVO.FileChunkConstant.REDIS_DIR, identifier));
        return data==null?new ArrayList<>():Arrays.stream(data.toString().split(",")).map(Integer::parseInt).collect(Collectors.toList());
    }

DTO

package com.amc.admin.api.dto;

import lombok.Data;
import org.springframework.web.multipart.MultipartFile;

import java.io.Serializable;

/**
 * 文件块传输对象
 * @author yt
 */
@Data
public class FileChunkDTO implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * minio 桶名
     */
    private String bucketName;

    /**
     * 当前文件块,从1开始
     */
    private Integer chunkNumber;
    /**
     * 分块大小
     */
    private Long chunkSize;
    /**
     * 当前分块大小
     */
    private Long currentChunkSize;
    /**
     * 总大小
     */
    private Long totalSize;
    /**
     * 文件标识
     */
    private String identifier;
    /**
     * 文件名
     */
    private String filename;
    /**
     * 相对路径
     */
    private String relativePath;
    /**
     * 总块数
     */
    private Integer totalChunks;

    /**
     * 二进制文件
     */
    private MultipartFile file;
}


VO


package com.amc.admin.api.vo;

import io.swagger.annotations.ApiModel;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Getter;

import java.io.Serializable;
import java.util.List;
import java.util.concurrent.TimeUnit;

/**
 * 文件块返回信息
 * @author yt
 */
@Data
@ApiModel(value = "文件块返回信息")
public class FileChunkVO implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * minio 桶名
     */
    private String bucketName;
    /**
     * minio 对象名
     */
    private String objectName;
    /**
     * 访问路径
     */
    private String url;
    /**
     * 已上传文件块下标集合
     */
    private List<Integer> uploaded;
    /**
     * 上传状态:1 单块文件上传完成,2 全部分块上传完成
     */
    private Integer status;

    /**
     * 当前文件块,从1开始
     */
    private Integer chunkNumber;
    /**
     * 分块大小
     */
    private Long chunkSize;
    /**
     * 当前分块大小
     */
    private Long currentChunkSize;
    /**
     * 总大小
     */
    private Long totalSize;
    /**
     * 文件标识
     */
    private String identifier;
    /**
     * 文件名
     */
    private String filename;
    /**
     * 相对路径
     */
    private String relativePath;
    /**
     * 总块数
     */
    private Integer totalChunks;

    /**
     * 文件分块上传状态枚举
     */
    @Getter
    @AllArgsConstructor
    public enum FileChunkStatus {
        /**
         * 单块上传完成状态
         */
        SINGLE(1, "单块上传完成状态"),

        /**
         * 全部分块上传完成状态
         */
        ALL(2, "全部分块上传完成状态");

        /**
         * 类型
         */
        private final Integer status;
        /**
         * 描述
         */
        private final String descr;
    }

    /**
     * 文件分块上传常量类
     */
    @Getter
    public static class FileChunkConstant {
        /**
         * redis文件块上传目录
         */
        public static String REDIS_DIR = "fileChunk:";
        /**
         * redis过期时间
         */
        public static Integer REDIS_TIMEOUT = 3;
        /**
         * redis过期时间单位
         */
        public static TimeUnit REDIS_TIMEOUT_UNIT = TimeUnit.DAYS;
    }
}

MinioTemplate

package com.amc.minio.service;

import com.amc.minio.vo.MinioItem;
import io.minio.ComposeSource;
import io.minio.MinioClient;
import io.minio.ObjectStat;
import io.minio.Result;
import io.minio.messages.Bucket;
import io.minio.messages.Item;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.util.Assert;

import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

/**
 * minio 交互类
 */
@RequiredArgsConstructor
public class MinioTemplate implements InitializingBean {
    private final String endpoint;
    private final String accessKey;
    private final String secretKey;
    private MinioClient client;

    /**
     * 创建bucket
     *
     * @param bucketName bucket名称
     */
    @SneakyThrows
    public void createBucket(String bucketName) {
        if (!client.bucketExists(bucketName)) {
            client.makeBucket(bucketName);
        }
    }

    /**
     * 获取全部bucket
     * <p>
     * https://docs.minio.io/cn/java-client-api-reference.html#listBuckets
     */
    @SneakyThrows
    public List<Bucket> getAllBuckets() {
        return client.listBuckets();
    }

    /**
     * @param bucketName bucket名称
     */
    @SneakyThrows
    public Optional<Bucket> getBucket(String bucketName) {
        return client.listBuckets().stream().filter(b -> b.name().equals(bucketName)).findFirst();
    }

    /**
     * @param bucketName bucket名称
     */
    @SneakyThrows
    public void removeBucket(String bucketName) {
        client.removeBucket(bucketName);
    }

    /**
     * 根据文件前置查询文件
     *
     * @param bucketName bucket名称
     * @param prefix     前缀
     * @param recursive  是否递归查询
     * @return MinioItem 列表
     */
    @SneakyThrows
    public List<MinioItem> getAllObjectsByPrefix(String bucketName, String prefix, boolean recursive) {
        List<MinioItem> objectList = new ArrayList<>();
        Iterable<Result<Item>> objectsIterator = client
                .listObjects(bucketName, prefix, recursive);

        for (Result<Item> itemResult : objectsIterator) {
            objectList.add(new MinioItem(itemResult.get()));
        }
        return objectList;
    }

    /**
     * 获取文件外链
     *
     * @param bucketName bucket名称
     * @param objectName 文件名称
     * @param expires    过期时间 <=7
     * @return url
     */
    @SneakyThrows
    public String getObjectURL(String bucketName, String objectName, Integer expires) {
        return client.presignedGetObject(bucketName, objectName, expires);
    }

    /**
     * 获取文件
     *
     * @param bucketName bucket名称
     * @param objectName 文件名称
     * @return 二进制流
     */
    @SneakyThrows
    public InputStream getObject(String bucketName, String objectName) {
        return client.getObject(bucketName, objectName);
    }


    /**
     * 复制文件
     *
     * @param srcBucketName Source bucket name
     * @param srcObjectName Source object name
     * @param bucketName    目标 bucket名称
     * @param objectName    目标 文件名称
     * @return 二进制流
     */
    @SneakyThrows
    public void copyObject(String srcBucketName, String srcObjectName, String bucketName, String objectName) {
        client.copyObject(bucketName, objectName, null, null, srcBucketName, srcObjectName, null, null);
    }

    /**
     * 上传文件
     *
     * @param bucketName bucket名称
     * @param objectName 文件名称
     * @param stream     文件流
     * @throws Exception https://docs.minio.io/cn/java-client-api-reference.html#putObject
     */
    public void putObject(String bucketName, String objectName, InputStream stream) throws Exception {
        client.putObject(bucketName, objectName, stream, (long) stream.available(), null, null, "application/octet-stream");
    }

    /**
     * 上传文件
     *
     * @param bucketName  bucket名称
     * @param objectName  文件名称
     * @param stream      文件流
     * @param size        大小
     * @param contextType 类型
     * @throws Exception https://docs.minio.io/cn/java-client-api-reference.html#putObject
     */
    public void putObject(String bucketName, String objectName, InputStream stream, long size, String contextType) throws Exception {
        client.putObject(bucketName, objectName, stream, size, null, null, contextType);
    }

    /**
     * 合并文件
     *
     * @param bucketName  bucket名称
     * @param objectName  文件名称
     * @param sources     需要合并的文件列表
     * @throws Exception https://docs.minio.io/cn/java-client-api-reference.html#putObject
     */
    public void composeObject(String bucketName, String objectName, List<ComposeSource> sources) throws Exception {
        client.composeObject(bucketName, objectName, sources, null, null);
    }

    /**
     * 获取文件信息
     *
     * @param bucketName bucket名称
     * @param objectName 文件名称
     * @throws Exception https://docs.minio.io/cn/java-client-api-reference.html#statObject
     */
    public ObjectStat getObjectInfo(String bucketName, String objectName) throws Exception {
        return client.statObject(bucketName, objectName);
    }

    /**
     * 删除文件
     *
     * @param bucketName bucket名称
     * @param objectName 文件名称
     * @throws Exception https://docs.minio.io/cn/java-client-api-reference.html#removeObject
     */
    public void removeObject(String bucketName, String objectName) throws Exception {
        client.removeObject(bucketName, objectName);
    }
    public void removeObjects(String bucketName, List<String> objectNames) throws Exception {
        for (String objectName : objectNames) {
            client.removeObject(bucketName, objectName);
        }
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        Assert.hasText(endpoint, "Minio url 为空");
        Assert.hasText(accessKey, "Minio accessKey为空");
        Assert.hasText(secretKey, "Minio secretKey为空");
        this.client = new MinioClient(endpoint, accessKey, secretKey);
    }

}

MinioProperties

package com.amc.minio;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;

/**
 * minio 配置信息
 * @author rio
 */
@Data
@ConfigurationProperties(prefix = "minio")
public class MinioProperties {
    /**
     * minio 服务地址 http://ip:port
     */
    private String url;

    /**
     * 用户名
     */
    private String accessKey;

    /**
     * 密码
     */
    private String secretKey;

}

Logo

助力广东及东莞地区开发者,代码托管、在线学习与竞赛、技术交流与分享、资源共享、职业发展,成为松山湖开发者首选的工作与学习平台

更多推荐