断点续传结合MinIO在业务中的体现

本文讲解了断点续传结合MinIO在后端的实现,并在业务中进行了简单体现。

前置内容:


演示demo: 六花i/breakpoint-transmission_minio_demo (gitee.com)

一、什么是断点续传?

通常视频文件都比较大,所以对于视频等文件上传的需求要满足大文件的上传需求。HTTP协议本身对上传文件大小没有限制,但是客户的网络环境之类、电脑硬件环境等参差不齐,如果一个大文件快上传完了,但是突然断网了,没有上传完成,需要客户重新上传,那么用户体验就非常差。所以对于大文件上传的最基本要求就是断点续传。

简单点说,断点续传就是解决用户上传视频等大文件时遇到网络波动情况下,避免重复下载的解决方案。

流程如下:

  1. 前端上传文件前,请求媒资接口层检查文件是否存在
    • 若存在,则不再上传
    • 若不存在,则开始上传,首先对视频文件进行分块
  2. 前端分块进行上传,上传前首先检查分块是否已经存在
    • 若分块已存在,则不再上传
    • 若分块不存在,则开始上传分块
  3. 前端请求媒资管理接口层,请求上传分块
  4. 接口层请求服务层上传分块
  5. 服务端将分块信息上传到MinIO
  6. 前端将分块上传完毕,请求接口层合并分块
  7. 接口层请求服务层合并分块
  8. 服务层根据文件信息找到MinIO中的分块文件,下载到本地临时目录,将所有分块下载完毕后开始合并
  9. 合并完成后,将合并后的文件上传至MinIO

二、准备工作

1.准备工作

创建项目(必做)手摸手带你SpringBoot集成MinIO入门-CSDN博客

在上面的基础上添加依赖:

        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.3.1</version>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <version>8.0.32</version>
        </dependency>

 统一结果返回类:

@Data
public class RestResponse<T> {
    /**
     * 相应编码 0为正常 -1为错误
     */
    private int code;
    /**
     * 响应提示信息
     */
    private String msg;
    /**
     * 响应内容
     */
    private T result;

    public RestResponse(int code, String msg) {
        this.code = code;
        this.msg = msg;
    }

    public RestResponse() {
        this(0, "success");
    }

    /**
     * 错误信息的封装
     */
    public static <T> RestResponse<T> validfail() {
        RestResponse<T> response = new RestResponse<>();
        response.setCode(-1);
        return response;
    }

    public static <T> RestResponse<T> validfail(String msg) {
        RestResponse<T> response = new RestResponse<>();
        response.setCode(-1);
        response.setMsg(msg);
        return response;
    }

    public static <T> RestResponse<T> validfail(String msg, T result) {
        RestResponse<T> response = new RestResponse<>();
        response.setCode(-1);
        response.setMsg(msg);
        response.setResult(result);
        return response;
    }

    /**
     * 正常信息的封装
     */
    public static <T> RestResponse<T> success() {
        return new RestResponse<>();
    }

    public static <T> RestResponse<T> success(T result) {
        RestResponse<T> response = new RestResponse<>();
        response.setResult(result);
        return response;
    }

    public static <T> RestResponse<T> success(String msg, T result) {
        RestResponse<T> response = new RestResponse<>();
        response.setMsg(msg);
        response.setResult(result);
        return response;
    }
}

YML配置:

spring:
  # 配置文件上传大小限制
  servlet:
    multipart:
      max-file-size: 200MB
      max-request-size: 200MB

  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:msql://localhost:8080/demo1?useUnicode=true&characterEncoding=utf8
    username: root
    password: root

mybatis-plus:
  configuration:
    map-underscore-to-camel-case: true

minio:
  accessKey: 填你的
  secretKey: 填你的
  bucket: test
  endpoint: http://你的ip:端口
  readPath: http://你的ip:端口

 创建表、实体类和对应mapper(请自行创建):

2. 编写接口、服务类

定义Controller层接口 :

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;

@RestController
public class BigFilesController {

    @Autowired
    private MediaFileService mediaFileService;

    /**
     * 文件检查
     * @param fileMd5
     * @return
     */
    @PostMapping("/upload/checkfile")
    public RestResponse<Boolean> checkFile(@RequestParam("fileMd5") String fileMd5) {
        return mediaFileService.checkFile(fileMd5);
    }


    /**
     * 分片检查
     * @param fileMd5
     * @param chunk
     * @return
     */
    @PostMapping("/upload/checkchunk")
    public RestResponse<Boolean> checkChunk(@RequestParam("fileMd5") String fileMd5, @RequestParam("chunk") int chunk) {
        return mediaFileService.checkChunk(fileMd5, chunk);
    }

    /**
     * 上传分片
     * @param file
     * @param fileMd5
     * @param chunk
     * @return
     */
    @PostMapping("/upload/uploadchunk")
    public RestResponse uploadChunk(@RequestParam("file") MultipartFile file, @RequestParam("fileMd5") String fileMd5, @RequestParam("chunk") int chunk) throws IOException {
        return mediaFileService.uploadChunk(fileMd5, chunk, file.getBytes());
    }

    /**
     * 合并分片
     * @param fileMd5
     * @param fileName
     * @param chunkTotal
     * @return
     */
    @PostMapping("/upload/mergechunks")
    public RestResponse mergeChunks(@RequestParam("fileMd5") String fileMd5, @RequestParam("fileName") String fileName, @RequestParam("chunkTotal") int chunkTotal) {
        return null;
    }
}

编写service层服务 :

import com.baomidou.mybatisplus.extension.service.IService;
public interface MediaFileService extends IService<MediaFiles> {
    /**
     * 检查文件是否存在
     *
     * @param fileMd5 文件的md5
     * @return
     */
    RestResponse checkFile(String fileMd5);

    /**
     * 检查分块是否存在
     * @param fileMd5       文件的MD5
     * @param chunkIndex    分块序号
     * @return
     */
    RestResponse checkChunk(String fileMd5, int chunkIndex);

    /**
     * 上传分片
     * @param fileMd5
     * @param chunk
     * @param bytes
     * @return
     */
    RestResponse uploadChunk(String fileMd5, int chunk, byte[] bytes);

    /**
     * 合并分块
     * @param companyId
     * @param fileMd5
     * @param chunkTotal
     * @param uploadFileParamsDto
     * @return
     */
    RestResponse mergeChunks(Long companyId, String fileMd5, int chunkTotal, UploadFileParamsDto uploadFileParamsDto);
}

 service实现类:



import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.google.errorprone.annotations.Var;
import io.minio.*;
import io.minio.errors.*;
import lombok.extern.slf4j.Slf4j;
import org.apache.tomcat.util.http.fileupload.IOUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.DigestUtils;

import java.io.*;
import java.nio.file.Files;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

@Service
@Slf4j
public class MediaFileServiceImpl extends ServiceImpl<MediaFilesMapper, MediaFiles> implements MediaFileService {

    @Autowired
    private MinioClient minioClient;

    @Autowired
    private MediaFilesMapper mediaFilesMapper;

    @Autowired
    private MinIOConfigProperties minIOConfigProperties;

    @Autowired
    private MinIOUtil minIOUtil;

    /**
     * 首先判断数据库中是否存在该文件,其次判断minio的bucket中是否存在该文件
     * @param fileMd5 文件的md5
     * @return
     */
    @Override
    public RestResponse<Boolean> checkFile(String fileMd5) {
        MediaFiles mediaFiles = mediaFilesMapper.selectById(fileMd5);
        // 数据库中不存在,则直接返回false 表示不存在
        if (mediaFiles == null) {
            return RestResponse.success(false);
        }
        // 若数据库中存在,根据数据库中的文件信息,则继续判断bucket中是否存在
        try {
            InputStream inputStream = minioClient.getObject(GetObjectArgs
                    .builder()
                    .bucket(mediaFiles.getBucket())
                    .object(mediaFiles.getFilePath())
                    .build());
            if (inputStream == null) {
                return RestResponse.success(false);
            }
        } catch (Exception e) {
            return RestResponse.success(false);
        }
        return RestResponse.success(true);
    }

    /**
     * 分块是否存在,只需要判断minio对应的目录下是否存在分块文件
     * @param fileMd5       文件的MD5
     * @param chunkIndex    分块序号
     * @return
     */
    @Override
    public RestResponse<Boolean> checkChunk(String fileMd5, int chunkIndex) {
        // 获取分块目录
        String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);
        String chunkFilePath = chunkFileFolderPath + chunkIndex;
        try {
            // 判断分块是否存在
            InputStream inputStream = minioClient.getObject(GetObjectArgs
                    .builder()
                    .bucket(minIOConfigProperties.getBucket())
                    .object(chunkFilePath)
                    .build());
            // 不存在返回false
            if (inputStream == null) {
                return RestResponse.success(false);
            }
        } catch (Exception e) {
            // 出异常也返回false
            return RestResponse.success(false);
        }
        // 否则返回true
        return RestResponse.success();
    }

    /**
     * 上传分片
     * @param fileMd5
     * @param chunk
     * @param bytes
     * @return
     */
    public RestResponse uploadChunk(String fileMd5, int chunk, byte[] bytes) {
        // 分块文件路径
        String chunkFilePath = getChunkFileFolderPath(fileMd5) + chunk;
        try {
            minIOUtil.upload(minIOConfigProperties.getBucket(), null, chunkFilePath);
            return RestResponse.success(true);
        } catch (Exception e) {
            log.debug("上传分块文件:{}失败:{}", chunkFilePath, e.getMessage());
        }
        return RestResponse.validfail("上传文件失败", false);
    }

    /**
     * 合并分块
     * @param companyId
     * @param fileMd5 前端先对完整视频的流进行md5处理
     * @param chunkTotal
     * @param uploadFileParamsDto
     * @return
     * @throws IOException
     */
    @Override
    public RestResponse mergeChunks(Long companyId, String fileMd5, int chunkTotal, UploadFileParamsDto uploadFileParamsDto) throws IOException {

        //分块文件所在目录
        String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);
        //找到分快文件调用minio的sdk合并
        List<ComposeSource> sources = Stream.iterate(0, i -> i++).limit(chunkTotal).map(i -> ComposeSource
                .builder()
                .bucket(minIOConfigProperties.getBucket())
                .object(chunkFileFolderPath + i).build()).collect(Collectors.toList());

        // 合并后文件的名
        String fileName = uploadFileParamsDto.getFileName();
        String extension = fileName.substring(fileName.lastIndexOf("."));
        String objectName = getFilePathByMd5(fileMd5, extension);
        //指定合并后的objectName等信息
        ComposeObjectArgs composeObjectArgs = ComposeObjectArgs.builder()
                .bucket(minIOConfigProperties.getBucket())
                .object(objectName)
                .sources(sources)
                .build();

        //合并文件,单个分片文件大小必须为5m
        try {
            minioClient.composeObject(composeObjectArgs);
        } catch (Exception e) {
            e.printStackTrace();
            log.error("合并文件出错了,bucket:{},objectName:{},错误信息:{}",minIOConfigProperties.getBucket(), objectName, e.getMessage());
            return RestResponse.validfail("合并文件出错!");
        }

        //下载文件,用于比较md5
        File file = File.createTempFile("minio", ".merge");
        file= downloadFileFromMinio(file, minIOConfigProperties.getBucket(), objectName);

        // 对文件进行校验,通过MD5值比较
        FileInputStream mergeInputStream = new FileInputStream(file);
        String mergeMd5 = DigestUtils.md5DigestAsHex(mergeInputStream);
        if (!fileMd5.equals(mergeMd5)) {
            log.error("合并文件校验失败");
            return RestResponse.validfail("合并文件校验失败");
        }
        file.delete();
        // 将文件信息写入数据库
        MediaFiles mediaFiles = new MediaFiles();
        mediaFiles.setId(fileMd5);
        mediaFiles.setBucket(minIOConfigProperties.getBucket());
        mediaFiles.setFilePath(objectName);
        int insert = mediaFilesMapper.insert(mediaFiles);
        if (insert == 0) {
            return RestResponse.validfail();
        }
        return RestResponse.success();
    }

    /**
     * 从Minio中下载文件
     * @param file          目标文件
     * @param bucket        桶
     * @param objectName    桶内文件路径
     * @return
     */
    private File downloadFileFromMinio(File file, String bucket, String objectName) {
        try (FileOutputStream fileOutputStream = new FileOutputStream(file);
             InputStream inputStream = minioClient.getObject(GetObjectArgs
                     .builder()
                     .bucket(bucket)
                     .object(objectName)
                     .build())) {
            IOUtils.copy(inputStream, fileOutputStream);
            return file;
        } catch (Exception e) {
            log.error("下载文件出错");
        }
        return null;
    }
    /**
     * minio存储路径以md5密文前两个数字做目录
     * @param fileMd5
     * @return
     */
    private String getChunkFileFolderPath(String fileMd5) {
        return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/" + "chunk" + "/";
    }

    /**
     * 合并后完整视频路径
     * @param fileMd5
     * @param fileExt
     * @return
     */
    private String getFilePathByMd5(String fileMd5, String fileExt) {
        return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/" + fileMd5 + fileExt;
    }
}

总结

了解更多可以查看这篇文章:后端视频大文件分片处理-CSDNzhe

在我看来断点续传的重点不是合并,反而是对文件和分片是否存在的检查,因为这个检查才体现了断点续传的理念。

值得注意的是:

  1. 在整个流程中,前端负责了对大文件流的md5加密并发送给后端,还对大文件进行了分片处理
  2. 使用minio提供的sdk合并的话,分片文件必须为5m大小。否则只能自行用临时文件的方式合并。

  • 22
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值