本文讲解了断点续传结合MinIO在后端的实现,并在业务中进行了简单体现。
前置内容:
- MinIO :手摸手带你SpringBoot集成MinIO入门-CSDN博客
- 分块处理:后端视频大文件分片处理-CSDN博客目录
演示demo: 六花i/breakpoint-transmission_minio_demo (gitee.com)
一、什么是断点续传?
通常视频文件都比较大,所以对于视频等文件上传的需求要满足大文件的上传需求。HTTP协议本身对上传文件大小没有限制,但是客户的网络环境之类、电脑硬件环境等参差不齐,如果一个大文件快上传完了,但是突然断网了,没有上传完成,需要客户重新上传,那么用户体验就非常差。所以对于大文件上传的最基本要求就是断点续传。
简单点说,断点续传就是解决用户上传视频等大文件时遇到网络波动情况下,避免重复下载的解决方案。
流程如下:
- 前端上传文件前,请求媒资接口层检查文件是否存在
- 若存在,则不再上传
- 若不存在,则开始上传,首先对视频文件进行分块
- 前端分块进行上传,上传前首先检查分块是否已经存在
- 若分块已存在,则不再上传
- 若分块不存在,则开始上传分块
- 前端请求媒资管理接口层,请求上传分块
- 接口层请求服务层上传分块
- 服务端将分块信息上传到MinIO
- 前端将分块上传完毕,请求接口层合并分块
- 接口层请求服务层合并分块
- 服务层根据文件信息找到MinIO中的分块文件,下载到本地临时目录,将所有分块下载完毕后开始合并
- 合并完成后,将合并后的文件上传至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
在我看来,断点续传的重点不是合并,反而是对文件和分片是否存在的检查,因为这个检查才体现了断点续传的理念。
值得注意的是:
- 在整个流程中,前端负责了对大文件流的md5加密并发送给后端,还对大文件进行了分片处理。
- 使用minio提供的sdk合并的话,分片文件必须为5m大小。否则只能自行用临时文件的方式合并。