视频分块上传Vue3+SpringBoot3+Minio

视频分块上传Vue3+SpringBoot3+Minio

一、简化演示

分块上传、合并分块

前端将完整的视频文件分割成多份文件块,依次上传到后端,后端将其保存到文件系统。前端将文件块上传完毕后,发送合并请求,后端拿取文件块,合并后重新上传到文件系统。

断点续传

前端遍历文件块,每次上传之前,先询问文件块是否存在,只有不存在的情况下,才会上传。

秒传

前端分割视频文件前,先询问此视频是否已经存在,存在则不再上传,后端之间返回视频信息。前端看起来就像是被秒传了。

二、更详细的逻辑和细节问题

  • 视频文件和文件块都通过文件本身计算MD5值作为唯一标志
  • 文件系统使用Minio,只要提供buckerNamepath就可以操作文件
  • 后端合并文件块成功后会删除文件块,并以MD5值为id存入数据库
  • Minio存储文件块时,依据其md5值计算path,比如取前两个字符构建二级文件夹,文件名为md5值,无后缀。所以只需要提供文件块的md5值就可以操作文件块。
  • Minio存储完整视频文件时,依据其md5值计算path,同上,文件名为md5值,携带.mp4等后缀,所以只需要提供视频文件的md5值就可以操作视频文件。
  1. 首先,前端计算视频文件的MD5值,记为fileMd5,传递MD5值来询问后端此视频文件是否存在,后端查询数据库返回结果,如果存在,则前端触发”秒传”。
  2. 如果不存在,则将视频文件分割成文件块,循环上传,每次循环,首先计算文件块的md5值,传递md5值询问后端此文件块是否存在,后端根据md5判断文件块是否存在,如果存在,前端跳过此文件块上传,直接标记为上传成功,如果不存在,则上传至后端,后端将其保存到minio。这其实就是”分块上传,断点续传”。
  3. 最后所有分块文件都上传成功,前端发起合并请求,传递视频文件的md5值和所有文件块的md5值到后端,后端进行文件块合并、文件块的删除、合并文件的上传,将信息存储在mysql数据库,将执行结果告知前端。这就是”合并分块”

可能存在的隐患

一个视频文件的文件块没有全部上传完成就终止,此时文件块将一直保存在minio中,如果之后此视频再也没有发起过上传请求,那么这些文件块都是是一种垃圾。

可以写一个定时任务,遍历Minio没有后缀的文件块,判断其创建时间距离当前是否足够久,是则删除。

三、代码示例

前端代码

language-html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
<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>
language-javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
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
});
};

后端代码

language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
@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即可完成上传、下载、删除等操作。具体代码不同的包都不一样。

language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
@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;
}

}