一、简化演示

分块上传、合并分块

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

断点续传

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

秒传

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

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

  • 视频文件和文件块都通过文件本身计算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没有后缀的文件块,判断其创建时间距离当前是否足够久,是则删除。

三、代码示例

前端代码

<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>
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
  });
};

后端代码

@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即可完成上传、下载、删除等操作。具体代码不同的包都不一样。

@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;
    }

}
Logo

前往低代码交流专区

更多推荐