> 文章列表 > 学成在线笔记+踩坑(5)——【媒资模块】上传视频,断点续传

学成在线笔记+踩坑(5)——【媒资模块】上传视频,断点续传

学成在线笔记+踩坑(5)——【媒资模块】上传视频,断点续传

目录

5 上传视频 

5.1 媒资管理页面上传视频流程预览

5.2 断点续传技术

5.2.1 什么是断点续传

5.2.2 测试分块与合并,RandomAccessFile随机流

5.2.3 视频上传流程

5.2.4 测试minio合并文件

5.3 接口定义,检查文件/分块、上传分块、合并分块

5.4 上传分块Service

5.4.1 检查文件和分块

5.4.2 上传分块

5.4.3 完善接口层

报错、Tomcat默认上传文件大小限制为1M,yml配置文件上传限制 

5.5 合并分块开发

5.5.1 service开发

5.5.2 接口层完善

5.5.2 合并分块测试


5 上传视频 

5.1 媒资管理页面上传视频流程预览

1、教学机构人员进入媒资管理列表查询自己上传的媒资文件。

点击“媒资管理”

进入媒资管理列表页面查询本机构上传的媒资文件。

2、教育机构用户在"媒资管理"页面中点击 "上传视频" 按钮。

点击“上传视频”打开上传页面

3、选择要上传的文件,自动执行文件上传。

4、视频上传成功会自动处理,处理完成可以预览视频。

5.2 断点续传技术

5.2.1 什么是断点续传

如果一个大文件快上传完了网断了没有上传完成,需要客户重新上传,用户体验非常差。

断点续传:

在下载或上传时,将下载或上传任务(一个文件或一个压缩包)人为的划分为几个部分每一个部分采用一个线程进行上传或下载,如果碰到网络故障,可以从已经上传或下载的部分开始继续上传下载未完成的部分,而没有必要从头开始上传下载,断点续传可以节省操作时间

流程如下:

1、前端上传前先把文件分成块

2、一块一块的上传,上传中断后重新上传,已上传的分块则不用再上传

3、各分块上传完成最后在服务端合并文件

5.2.2 测试分块与合并,RandomAccessFile随机流

文件分块的流程如下:

  • 1、获取源文件长度
  • 2、根据设定的分块文件的大小计算出块数
  • 3、从源文件读数据依次向每一个块文件写数据。

测试代码如下:

随机流RandomAccessFile:

是Java 输入/输出流体系中功能最丰富的文件内容访问类,它提供了众多的方法来访问文件内容,它既可以读取文件内容,也可以向文件输出数据。与普通的输入/输出流不同的是,RandomAccessFile支持"随机访问"的方式,程序可以直接跳转到文件的任意地方来读写数据。


package com.xuecheng.media;
/*** @description 大文件处理测试*/
public class BigFileTest {//分块测试,将视频按每块5m进行分块@Testpublic void testChunk() throws IOException {//源文件File sourceFile = new File("D:\\\\develop\\\\upload\\\\1.项目背景.mp4");//分块文件存储路径。这个路径得是真实存在的,否则会报错找不到路径String chunkFilePath = "D:\\\\develop\\\\upload\\\\chunk\\\\";//分块文件大小。这里设置成5Mint chunkSize = 1024 * 1024 * 5;//分块文件个数。Math.ceil是向上取整int chunkNum = (int) Math.ceil(sourceFile.length() * 1.0 / chunkSize);//使用随机流从源文件读数据,向分块文件中写数据RandomAccessFile raf_r = new RandomAccessFile(sourceFile, "r");//缓存区byte[] bytes = new byte[1024];//遍历所有块for (int i = 0; i < chunkNum; i++) {//“D:\\develop\\upload\\chunk\\1”、“D:\\develop\\upload\\chunk\\2”...File chunkFile = new File(chunkFilePath + i);//分块文件写入流RandomAccessFile raf_rw = new RandomAccessFile(chunkFile, "rw");int len = -1;//每次写满一个字节数组while ((len=raf_r.read(bytes))!=-1){raf_rw.write(bytes,0,len);//当分块大小超过5m时停止在这一块写数据。不加这句的话会出现第一块大小和源文件一样,其余块大小都为0if(chunkFile.length()>=chunkSize){break;}}raf_rw.close();}raf_r.close();}
}

运行测试: 

文件合并流程:

1、找到要合并的文件并按文件合并的先后进行排序。

2、创建合并文件

3、依次从合并的文件中读取数据向合并文件写入数

文件合并的测试代码 :

    //将分块进行合并@Testpublic void testMerge() throws IOException {//块文件目录File chunkFolder = new File("D:\\\\develop\\\\upload\\\\chunk");//源文件File sourceFile = new File("D:\\\\develop\\\\upload\\\\1.项目背景.mp4");//合并后的文件File mergeFile = new File("D:\\\\develop\\\\upload\\\\1.项目背景_2.mp4");//1.取出所有分块文件File[] files = chunkFolder.listFiles();//2.将数组转成list,以便于排序List<File> filesList = Arrays.asList(files);//3.对分块文件排序Collections.sort(filesList, new Comparator<File>() {@Overridepublic int compare(File o1, File o2) {return Integer.parseInt(o1.getName())-Integer.parseInt(o2.getName());}});//向合并文件写的流RandomAccessFile raf_rw = new RandomAccessFile(mergeFile, "rw");//缓存区byte[] bytes = new byte[1024];//4.遍历每个分块,向合并的目标文件写for (File file : filesList) {//读分块的流RandomAccessFile raf_r = new RandomAccessFile(file, "r");int len = -1;while ((len=raf_r.read(bytes))!=-1){raf_rw.write(bytes,0,len);}raf_r.close();}raf_rw.close();//合并文件完成后对合并的文件md5校验FileInputStream fileInputStream_merge = new FileInputStream(mergeFile);FileInputStream fileInputStream_source = new FileInputStream(sourceFile);String md5_merge = DigestUtils.md5Hex(fileInputStream_merge);String md5_source = DigestUtils.md5Hex(fileInputStream_source);if(md5_merge.equals(md5_source)){System.out.println("文件合并成功");}}

5.2.3 视频上传流程

下图是上传视频的整体流程:

1、前端对文件进行分块

2、前端上传分块文件前请求媒资服务检查原文件和分块文件是否存在,如果已经存在则不需要再上传。

检查文件存在依据:是媒资主键为文件的md5值,两个文件md5值相等,则是一个文件。

3、如果分块文件不存在则前端开始上传

4、前端请求媒资服务上传分块。

5、媒资服务将分块上传至MinIO

注意:minio文件和文件的分块存储路径都应该尽量避免存在根目录下,这里将文件名前两位设成路径。

6、前端将分块上传完毕请求媒资服务合并分块。

7、媒资服务判断分块上传完成则请求MinIO合并文件

8、合并完成校验合并后的文件是否完整,如果完整则上传完成并删除分块,否则删除文件。

5.2.4 测试minio合并文件

1、将分块文件上传至minio

//将分块文件上传至minio
@Test
public void uploadChunk(){String chunkFolderPath = "D:\\\\develop\\\\upload\\\\chunk\\\\";File chunkFolder = new File(chunkFolderPath);//获取所有分块文件。listFiles()方法返回该文件路径下所有文件数组File[] files = chunkFolder.listFiles();//将分块文件上传至miniofor (int i = 0; i < files.length; i++) {try {UploadObjectArgs uploadObjectArgs = UploadObjectArgs.builder().bucket("testbucket").object("chunk/" + i).filename(files[i].getAbsolutePath()).build();minioClient.uploadObject(uploadObjectArgs);System.out.println("上传分块成功"+i);} catch (Exception e) {e.printStackTrace();}}}

2、通过minio的合并文件

//合并文件,要求分块文件最小5M
@Test
public void test_merge() throws Exception {List<ComposeSource> sources = Stream.iterate(0, i -> ++i).limit(6).map(i -> ComposeSource.builder().bucket("testbucket").object("chunk/".concat(Integer.toString(i))).build()).collect(Collectors.toList());ComposeObjectArgs composeObjectArgs = ComposeObjectArgs.builder().bucket("testbucket").object("merge01.mp4").sources(sources).build();minioClient.composeObject(composeObjectArgs);}
//清除分块文件
@Test
public void test_removeObjects(){//合并分块完成将分块文件清除List<DeleteObject> deleteObjects = Stream.iterate(0, i -> ++i).limit(6).map(i -> new DeleteObject("chunk/".concat(Integer.toString(i)))).collect(Collectors.toList());RemoveObjectsArgs removeObjectsArgs = RemoveObjectsArgs.builder().bucket("testbucket").objects(deleteObjects).build();Iterable<Result<DeleteError>> results = minioClient.removeObjects(removeObjectsArgs);results.forEach(r->{DeleteError deleteError = null;try {deleteError = r.get();} catch (Exception e) {e.printStackTrace();}});
}

使用minio合并文件报错:java.lang.IllegalArgumentException: source testbucket/chunk/0: size 1048576 must be greater than 5242880

minio合并文件默认分块最小5M,我们将分块改为5M再次测试。

5.3 接口定义,检查文件/分块、上传分块、合并分块

与前端的约定是操作成功返回{code:0}否则返回{code:-1}

定义接口如下:

package com.xuecheng.media.api;
/*** @description 大文件上传接口*/
@Api(value = "大文件上传接口", tags = "大文件上传接口")
@RestController
public class BigFilesController {@ApiOperation(value = "文件上传前检查文件")@PostMapping("/upload/checkfile")public RestResponse<Boolean> checkfile(@RequestParam("fileMd5") String fileMd5) throws Exception {return null;}//chunk是分块序号@ApiOperation(value = "分块文件上传前的检测")@PostMapping("/upload/checkchunk")public RestResponse<Boolean> checkchunk(@RequestParam("fileMd5") String fileMd5,@RequestParam("chunk") int chunk) throws Exception {return null;}@ApiOperation(value = "上传分块文件")@PostMapping("/upload/uploadchunk")public RestResponse uploadchunk(@RequestParam("file") MultipartFile file,@RequestParam("fileMd5") String fileMd5,@RequestParam("chunk") int chunk) throws Exception {return null;}@ApiOperation(value = "合并文件")@PostMapping("/upload/mergechunks")public RestResponse mergechunks(@RequestParam("fileMd5") String fileMd5,@RequestParam("fileName") String fileName,@RequestParam("chunkTotal") int chunkTotal) throws Exception {return null;}}

5.4 上传分块Service

5.4.1 检查文件和分块

接口完成进行接口实现,首先实现检查文件方法和检查分块方法。

@Override
public RestResponse<Boolean> checkFile(String fileMd5) {//查询文件信息MediaFiles mediaFiles = mediaFilesMapper.selectById(fileMd5);if (mediaFiles != null) {//桶String bucket = mediaFiles.getBucket();//存储目录String filePath = mediaFiles.getFilePath();//文件流InputStream stream = null;try {stream = minioClient.getObject(GetObjectArgs.builder().bucket(bucket).object(filePath).build());if (stream != null) {//文件已存在return RestResponse.success(true);}} catch (Exception e) {}}//文件不存在return RestResponse.success(false);
}@Override
public RestResponse<Boolean> checkChunk(String fileMd5, int chunkIndex) {//得到分块文件目录String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);//得到分块文件的路径String chunkFilePath = chunkFileFolderPath + chunkIndex;//文件流InputStream fileInputStream = null;try {fileInputStream = minioClient.getObject(GetObjectArgs.builder().bucket(bucket_videoFiles).object(chunkFilePath).build());if (fileInputStream != null) {//分块已存在return RestResponse.success(true);}} catch (Exception e) {}//分块未存在return RestResponse.success(false);
}//得到分块文件的目录
private String getChunkFileFolderPath(String fileMd5) {return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/" + "chunk" + "/";
}

5.4.2 上传分块

@Override
public RestResponse uploadChunk(String fileMd5, int chunk, String localChunkFilePath) {//得到分块文件的目录路径。“abcde”->“a/b/abcde”String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);//得到分块文件的路径String chunkFilePath = chunkFileFolderPath + chunk;//获取文件类型mimeTypeString mimeType = getMimeType(null);//将文件存储至minIOboolean b = addMediaFilesToMinIO(localChunkFilePath, mimeType, bucket_videoFiles, chunkFilePath);if (!b) {log.debug("上传分块文件失败:{}", chunkFilePath);return RestResponse.validfail(false, "上传分块失败");}log.debug("上传分块文件成功:{}",chunkFilePath);return RestResponse.success(true);}
    //根据扩展名获取mimeTypeprivate String getMimeType(String extension) {if (extension == null) {extension = "";}//根据扩展名取出mimeTypeContentInfo extensionMatch = ContentInfoUtil.findExtensionMatch(extension);String mimeType = MediaType.APPLICATION_OCTET_STREAM_VALUE;//通用mimeType,字节流if (extensionMatch != null) {mimeType = extensionMatch.getMimeType();}return mimeType;}

5.4.3 完善接口层

@ApiOperation(value = "文件上传前检查文件")
@PostMapping("/upload/checkfile")
public RestResponse<Boolean> checkfile(@RequestParam("fileMd5") String fileMd5
) throws Exception {return mediaFileService.checkFile(fileMd5);
}@ApiOperation(value = "分块文件上传前的检测")
@PostMapping("/upload/checkchunk")
public RestResponse<Boolean> checkchunk(@RequestParam("fileMd5") String fileMd5,@RequestParam("chunk") int chunk) throws Exception {return mediaFileService.checkChunk(fileMd5,chunk);
}@ApiOperation(value = "上传分块文件")
@PostMapping("/upload/uploadchunk")
public RestResponse uploadchunk(@RequestParam("file") MultipartFile file,@RequestParam("fileMd5") String fileMd5,@RequestParam("chunk") int chunk) throws Exception {//创建临时文件File tempFile = File.createTempFile("minio", "temp");//上传的文件拷贝到临时文件file.transferTo(tempFile);//文件路径String absolutePath = tempFile.getAbsolutePath();return mediaFileService.uploadChunk(fileMd5,chunk,absolutePath);
}

启动前端工程,进入上传视频界面进行前后端联调测试。 

报错、Tomcat默认上传文件大小限制为1M,yml配置文件上传限制 

minio合并的分块小于5M时会报错:

解决:

前端对文件分块的大小为5MB,SpringBoot web默认上传文件的大小限制为1MB,这里需要在nacos里media-api工程yml配置如下:

spring:servlet:multipart:max-file-size: 50MBmax-request-size: 50MB

max-file-size:单个文件的大小限制

Max-request-size: 单次请求的大小限制

5.5 合并分块开发

5.5.1 service开发

业务流程 :

1.获取分块文件路径
2.合并
3.验证md5合并后的文件和源文件是否一致,从而判断是否上传成功
4.文件信息入数据库
5.清除分块文件

代码实现: 

@Override
public RestResponse mergechunks(Long companyId, String fileMd5, int chunkTotal, UploadFileParamsDto uploadFileParamsDto) {//=====1.获取分块文件路径=====String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);//组成将分块文件路径组成 List<ComposeSource>List<ComposeSource> sourceObjectList = Stream.iterate(0, i -> ++i).limit(chunkTotal).map(i -> ComposeSource.builder().bucket(bucket_videoFiles).object(chunkFileFolderPath.concat(Integer.toString(i))).build()).collect(Collectors.toList());//=====2.合并=====//文件名称String fileName = uploadFileParamsDto.getFilename();//文件扩展名String extName = fileName.substring(fileName.lastIndexOf("."));//合并文件路径String mergeFilePath = getFilePathByMd5(fileMd5, extName);try {//合并文件ObjectWriteResponse response = minioClient.composeObject(ComposeObjectArgs.builder().bucket(bucket_videoFiles).object(mergeFilePath).sources(sourceObjectList).build());log.debug("合并文件成功:{}",mergeFilePath);} catch (Exception e) {log.debug("合并文件失败,fileMd5:{},异常:{}",fileMd5,e.getMessage(),e);return RestResponse.validfail(false, "合并文件失败。");}// ====3.验证md5合并后的文件和源文件是否一致,从而判断是否上传成功====//下载合并后的文件File minioFile = downloadFileFromMinIO(bucket_videoFiles,mergeFilePath);if(minioFile == null){log.debug("下载合并后文件失败,mergeFilePath:{}",mergeFilePath);return RestResponse.validfail(false, "下载合并后文件失败。");}try (InputStream newFileInputStream = new FileInputStream(minioFile)) {//minio上文件的md5值String md5Hex = DigestUtils.md5Hex(newFileInputStream);//比较md5值,不一致则说明文件不完整if(!fileMd5.equals(md5Hex)){return RestResponse.validfail(false, "文件合并校验失败,最终上传失败。");}//文件大小uploadFileParamsDto.setFileSize(minioFile.length());}catch (Exception e){log.debug("校验文件失败,fileMd5:{},异常:{}",fileMd5,e.getMessage(),e);return RestResponse.validfail(false, "文件合并校验失败,最终上传失败。");}finally {if(minioFile!=null){minioFile.delete();}}//====4.文件信息入数据库。注入自己这个bean,加“currentProxy.”主要为了让组成事务。非事务方法调用事务方法必须用代理对象调用=====
//    @Autowired
//    MediaFileService currentProxy;
currentProxy.addMediaFilesToDb(companyId,fileMd5,uploadFileParamsDto,bucket_videoFiles,mergeFilePath);//=====5.清除分块文件=====clearChunkFiles(chunkFileFolderPath,chunkTotal);return RestResponse.success(true);
}/*** 从minio下载文件* @param bucket 桶* @param objectName 对象名称* @return 下载后的文件*/
public File downloadFileFromMinIO(String bucket,String objectName){//临时文件File minioFile = null;FileOutputStream outputStream = null;try{InputStream stream = minioClient.getObject(GetObjectArgs.builder().bucket(bucket).object(objectName).build());//创建临时文件minioFile=File.createTempFile("minio", ".merge");outputStream = new FileOutputStream(minioFile);IOUtils.copy(stream,outputStream);return minioFile;} catch (Exception e) {e.printStackTrace();}finally {if(outputStream!=null){try {outputStream.close();} catch (IOException e) {e.printStackTrace();}}}return null;
}
/*** 得到合并后的文件的地址* @param fileMd5 文件id即md5值* @param fileExt 文件扩展名* @return*/
private String getFilePathByMd5(String fileMd5,String fileExt){return   fileMd5.substring(0,1) + "/" + fileMd5.substring(1,2) + "/" + fileMd5 + "/" +fileMd5 +fileExt;
}/*** 清除分块文件* @param chunkFileFolderPath 分块文件路径* @param chunkTotal 分块文件总数*/
private void clearChunkFiles(String chunkFileFolderPath,int chunkTotal){try {List<DeleteObject> deleteObjects = Stream.iterate(0, i -> ++i).limit(chunkTotal).map(i -> new DeleteObject(chunkFileFolderPath.concat(Integer.toString(i)))).collect(Collectors.toList());RemoveObjectsArgs removeObjectsArgs = RemoveObjectsArgs.builder().bucket("video").objects(deleteObjects).build();Iterable<Result<DeleteError>> results = minioClient.removeObjects(removeObjectsArgs);results.forEach(r->{DeleteError deleteError = null;try {deleteError = r.get();} catch (Exception e) {e.printStackTrace();log.error("清楚分块文件失败,objectname:{}",deleteError.objectName(),e);}});} catch (Exception e) {e.printStackTrace();log.error("清楚分块文件失败,chunkFileFolderPath:{}",chunkFileFolderPath,e);}
}

注意:

非事务方法调用事务方法必须用代理对象调用。

所以文件信息入数据库时,要注入自己这个bean,加“currentProxy.”,而不能加“this.”,主要为了让组成事务。

5.5.2 接口层完善

@ApiOperation(value = "合并文件")
@PostMapping("/upload/mergechunks")
public RestResponse mergechunks(@RequestParam("fileMd5") String fileMd5,@RequestParam("fileName") String fileName,@RequestParam("chunkTotal") int chunkTotal) throws Exception {Long companyId = 1232141425L;UploadFileParamsDto uploadFileParamsDto = new UploadFileParamsDto();uploadFileParamsDto.setFileType("001002");uploadFileParamsDto.setTags("课程视频");uploadFileParamsDto.setRemark("");uploadFileParamsDto.setFilename(fileName);return mediaFileService.mergechunks(companyId,fileMd5,chunkTotal,uploadFileParamsDto);}

5.5.2 合并分块测试

下边进行前后端联调:

1、上传一个视频测试合并分块的执行逻辑

进入service方法逐行跟踪。

2、断点续传测试

上传一部分后,停止刷新浏览器再重新上传,通过浏览器日志发现已经上传过的分块不再重新上传