文章目录
一、简化演示
分块上传、合并分块
前端将完整的视频文件分割成多份文件块,依次上传到后端,后端将其保存到文件系统。前端将文件块上传完毕后,发送合并请求,后端拿取文件块,合并后重新上传到文件系统。
断点续传
前端遍历文件块,每次上传之前,先询问文件块是否存在,只有不存在的情况下,才会上传。
秒传
前端分割视频文件前,先询问此视频是否已经存在,存在则不再上传,后端之间返回视频信息。前端看起来就像是被秒传了。
二、更详细的逻辑和细节问题
- 视频文件和文件块都通过文件本身计算
MD5值
作为唯一标志 - 文件系统使用
Minio
,只要提供buckerName
和path
就可以操作文件 - 后端合并文件块成功后会删除文件块,并以
MD5
值为id
存入数据库 Minio
存储文件块时,依据其md5
值计算path
,比如取前两个字符构建二级文件夹,文件名为md5
值,无后缀。所以只需要提供文件块的md5
值就可以操作文件块。Minio
存储完整视频文件时,依据其md5
值计算path
,同上,文件名为md5
值,携带.mp4
等后缀,所以只需要提供视频文件的md5
值就可以操作视频文件。
- 首先,前端计算视频文件的
MD5
值,记为fileMd5
,传递MD5值来询问后端此视频文件是否存在,后端查询数据库返回结果,如果存在,则前端触发"秒传"。 - 如果不存在,则将视频文件分割成文件块,循环上传,每次循环,首先计算文件块的
md5
值,传递md5值询问后端此文件块是否存在,后端根据md5
判断文件块是否存在,如果存在,前端跳过此文件块上传,直接标记为上传成功,如果不存在,则上传至后端,后端将其保存到minio
。这其实就是"分块上传,断点续传"。 - 最后所有分块文件都上传成功,前端发起合并请求,传递视频文件的
md5
值和所有文件块的md5
值到后端,后端进行文件块合并、文件块的删除、合并文件的上传,将信息存储在mysql
数据库,将执行结果告知前端。这就是"合并分块"
可能存在的隐患
一个视频文件的文件块没有全部上传完成就终止,此时文件块将一直保存在
minio
中,如果之后此视频再也没有发起过上传请求,那么这些文件块都是是一种垃圾。
可以写一个定时任务,遍历Minio
没有后缀的文件块,判断其创建时间距离当前是否足够久,是则删除。
三、代码示例
前端代码
html
<template>
<div class="p-2">
<el-button icon="Plus" plain type="primary" @click="handleAdd">新增</el-button>
<!-- 添加或修改media对话框 -->
<el-dialog v-model="dialog.visible" :title="dialog.title" append-to-body width="500px">
<el-form ref="mediaFormRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="上传视频" prop="originalName" v-show="dialog.title=='添加视频'">
<el-upload
ref="uploadRef"
:http-request="onUpload"
:before-upload="beforeUpload"
:limit="1"
action="#"
class="upload-demo"
>
<template #trigger>
<el-button type="primary">选择视频</el-button>
</template>
<template #tip>
<div class="el-upload__tip">
支持分块上传、端点续传
</div>
</template>
</el-upload>
</el-form-item>
<el-form-item v-show="percentageShow">
<el-progress :percentage="percentage" style="width: 100%"/>
</el-form-item>
</el-form>
</el-dialog>
</div>
</template>
<script lang="ts" name="Media" setup>
import type {UploadInstance, UploadRawFile, UploadRequestOptions, UploadUserFile} from 'element-plus'
import SparkMD5 from "spark-md5";
import {HttpStatus} from "@/enums/RespEnum";
const dialog = reactive<DialogOption>({
visible: false,
title: ''
});
//上传视频
const baseUrl = import.meta.env.VITE_APP_BASE_API;
const uploadImgUrl = ref(baseUrl + "/media/media/image"); // 上传的图片服务器地址
const uploadRef = ref<UploadInstance>()
const needUpload = ref(true)
const chunkSize = 5*1024*1024;
const percentage = ref(0)
const percentageShow = ref(false)
/** 新增按钮操作 */
const handleAdd = () => {
dialog.visible = true;
dialog.title = "添加视频";
percentageShow.value = false;
}
//获取文件的MD5
const getFileMd5 = (file:any) => {
return new Promise((resolve, reject) => {
let fileReader = new FileReader()
fileReader.onload = function (event) {
let fileMd5 = SparkMD5.ArrayBuffer.hash(event.target.result)
resolve(fileMd5)
}
fileReader.readAsArrayBuffer(file)
}
)
}
//在上传之前,使用视频md5判断视频是否已经存在
const beforeUpload = async (rawFile: UploadRawFile) => {
needUpload.value = true;
const fileMd5 = await getFileMd5(rawFile);
form.value.id = fileMd5;
const rsp = await getMedia(fileMd5);
if(!!rsp.data && rsp.data['id'] == fileMd5){
needUpload.value = false;
proxy?.$modal.msgWarning("视频文件已存在,请勿重复上传。文件名为"+rsp.data['originalName'])
}
}
//分块上传、合并分块
const onUpload = async (options: UploadRequestOptions) => {
if(!needUpload.value){
//秒传
percentageShow.value = true;
percentage.value = 100;
dialog.visible = false;
return;
}
percentageShow.value = true;
const file = options.file
const totalChunks = Math.ceil(file.size / chunkSize);
let isUploadSuccess = true;//记录分块文件是否上传成功
//合并文件参数
let mergeVo = {
"chunksMd5": [] as string[],
"videoMd5": undefined as string | undefined,
"videoName": file.name,
"videoSize": file.size,
"remark": undefined as string | undefined
}
//循环切分文件,并上传分块文件
for(let i=0; i<totalChunks; ++i){
const start = i * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
//计算 chunk md5
const md5 = await getFileMd5(chunk);
mergeVo.chunksMd5.push(md5);
// 准备FormData
const formData = new FormData();
formData.append('file', chunk);
formData.append('filename', file.name);
formData.append('chunkIndex', i.toString());
formData.append('totalChunks', totalChunks.toString());
formData.append('md5', md5);
//上传当前分块
try {
//先判断这个分块是否已经存在
const isExistRsp = await isChunkExist({"md5": formData.get("md5")});
const isExist = isExistRsp.data;
//不存在则上传
if (!isExist){
const rsp = await addChunk(formData);
console.log(`Chunk ${i + 1}/${totalChunks} uploaded`, rsp.data);
}else {
console.log(`Chunk ${i + 1}/${totalChunks} is exist`);
}
percentage.value = (i)*100 / totalChunks;
} catch (error) {
isUploadSuccess = false;
console.error(`Error uploading chunk ${i + 1}`, error);
proxy?.$modal.msgError(`上传分块${i + 1}出错`);
break;
}
}
//合并分块文件
if(isUploadSuccess){
proxy?.$modal.msgSuccess("分块文件上传成功")
mergeVo.videoMd5 = form.value.id;//beforeUpload已经计算过视频文件的md5
//合并文件
const rsp = await mergeChunks(mergeVo);
if (rsp.code == HttpStatus.SUCCESS){
//合并文件后,实际上媒资已经插入数据库。
percentage.value = 100;
proxy?.$modal.msgSuccess("文件合并成功")
proxy?.$modal.msgSuccess("视频上传成功")
}else{
proxy?.$modal.msgSuccess("文件合并异常")
}
}else {
proxy?.$modal.msgSuccess("文件未上传成功,请重试或联系管理员")
}
}
</script>
javascript
export const getMedia = (id: string | number): AxiosPromise<MediaVO> => {
return request({
url: '/media/media/' + id,
method: 'get'
});
};
/**
* 分块文件是否存在
* */
export const isChunkExist = (data: any) => {
return request({
url: '/media/media/video/chunk',
method: 'get',
params: data
});
};
/**
* 上传分块文件
* */
export const addChunk = (data: any) => {
return request({
url: '/media/media/video/chunk',
method: 'post',
data: data
});
};
/**
* 合并分块文件
* */
export const mergeChunks = (data: any) => {
return request({
url: '/media/media/video/chunk/merge',
method: 'post',
data: data
});
};
后端代码
java
@RestController
@RequestMapping("/media")
public class MediaFilesController extends BaseController {
/**
* 获取media详细信息
*
* @param id 主键
*/
@GetMapping("/{id}")
public R<MediaFilesVo> getInfo(@NotNull(message = "主键不能为空")
@PathVariable String id) {
return R.ok(mediaFilesService.queryById(id));
}
@Log(title = "视频分块文件上传")
@PostMapping(value = "/video/chunk")
public R<String> handleChunkUpload(
@RequestParam("file") MultipartFile file,
@RequestParam("md5") String md5,
@RequestParam("filename") String filename,
@RequestParam("chunkIndex") int chunkIndex,
@RequestParam("totalChunks") int totalChunks) {
if (ObjectUtil.isNull(file)) {
return R.fail("上传文件不能为空");
}
Boolean b = mediaFilesService.handleChunkUpload(file, md5);
if (b){
return R.ok();
}else {
return R.fail();
}
}
@Log(title = "分块文件是否已经存在")
@GetMapping(value = "/video/chunk")
public R<Boolean> isChunkExist(@RequestParam("md5") String md5) {
return R.ok(mediaFilesService.isChunkExist(md5));
}
@Log(title = "合并视频文件")
@PostMapping(value = "/video/chunk/merge")
public R<Boolean> mergeChunks(@RequestBody MediaVideoMergeBo bo) {
bo.setCompanyId(LoginHelper.getDeptId());
Boolean b = mediaFilesService.mergeChunks(bo);
if (b){
return R.ok();
}else {
return R.fail();
}
}
}
关于如何操作Minio等文件系统,不详细写明解释。只需要知道,给Minio提供文件本身、bucketName、path即可完成上传、下载、删除等操作。具体代码不同的包都不一样。
java
@Service
public class MediaFilesServiceImpl implements MediaFilesService {
@Autowired
private MediaFilesMapper mediaFilesMapper;
/**
* 分块文件上传
* <br/>
* 分块文件不存放mysql信息,同时文件名不含后缀,只有md5
* @param file 文件
* @param md5 md5
* @return {@link Boolean}
*/
@Override
public Boolean handleChunkUpload(MultipartFile file, String md5) {
//只上传至minio
OssClient storage = OssFactory.instance();
String path = getPathByMD5(md5, "");
try {
storage.upload(file.getInputStream(), path, file.getContentType(), minioProperties.getVideoBucket());
} catch (IOException e) {
throw new RuntimeException(e);
}
return true;
}
@Override
public Boolean isChunkExist(String md5) {
OssClient storage = OssFactory.instance();
String path = getPathByMD5(md5, "");
return storage.doesFileExist(minioProperties.getVideoBucket(), path);
}
@Override
public Boolean mergeChunks(MediaVideoMergeBo bo) {
OssClient storage = OssFactory.instance();
String originalfileName = bo.getVideoName();
String suffix = StringUtils.substring(originalfileName, originalfileName.lastIndexOf("."), originalfileName.length());
//创建临时文件,用来存放合并文件
String tmpDir = System.getProperty("java.io.tmpdir");
String tmpFileName = UUID.randomUUID().toString() + ".tmp";
File tmpFile = new File(tmpDir, tmpFileName);
try(
FileOutputStream fOut = new FileOutputStream(tmpFile);
) {
//将分块文件以流的形式copy到临时文件
List<String> chunksMd5 = bo.getChunksMd5();
chunksMd5.forEach(chunkMd5 -> {
String chunkPath = getPathByMD5(chunkMd5, "");
InputStream chunkIn = storage.getObjectContent(minioProperties.getVideoBucket(), chunkPath);
IoUtil.copy(chunkIn, fOut);
});
//合并文件上传到minio
String videoMd5 = bo.getVideoMd5();
String path = getPathByMD5(videoMd5, suffix);
storage.upload(tmpFile, path, minioProperties.getVideoBucket());
//删除分块文件
chunksMd5.forEach(chunkMd5->{
String chunkPath = getPathByMD5(chunkMd5, "");
storage.delete(chunkPath, minioProperties.getVideoBucket());
});
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
if (tmpFile.exists()){
tmpFile.delete();
}
}
//上传信息到mysql
MediaFiles mediaFiles = new MediaFiles();
mediaFiles.setId(bo.getVideoMd5());
mediaFiles.setCompanyId(bo.getCompanyId());
mediaFiles.setOriginalName(originalfileName);
mediaFiles.setFileSuffix(suffix);
mediaFiles.setSize(bo.getVideoSize());
mediaFiles.setPath(getPathByMD5(bo.getVideoMd5(), suffix));
mediaFiles.setRemark(bo.getRemark());
mediaFiles.setAuditStatus(MediaStatusEnum.UNREVIEWED.getValue());
return mediaFilesMapper.insert(mediaFiles) > 0;
}
/**
* 通过md5生成文件路径
* <br/>
* 比如
* md5 = 6c4acb01320a21ccdbec089f6a9b7ca3
* <br/>
* path = 6/c/md5 + suffix
* @param prefix 前缀
* @param suffix 后缀
* @return {@link String}
*/
public String getPathByMD5(String md5, String suffix) {
// 文件路径
String path = md5.charAt(0) + "/" + md5.charAt(1) + "/" + md5;
return path + suffix;
}
}