当前位置:   article > 正文

vue3 + springboot3 大文件断点续传_vue3+springboot 断点续传

vue3+springboot 断点续传

序言:


1.  需求: 10MB以上的文件支持断点续传,需要有实时的进度条.

2. 目前网上有两种实现断点续传的方式, 主要区分于 切割文件的动作 在前端还是在后端. (本文章实现的是在前端实现对文件的切割, 也就是 前端文件流的slice函数, 会将文件转换为 bobl类型的数据传给后端).

3. 其主要通过 分片总数量和当前上传的分片下标 这两个值来通过数据库进行判断和执行. 

4.相关参考:

断点续传: https://www.bilibili.com/video/BV1sv411p7Ee/
断点续传的概念: https://blog.csdn.net/yjxkq99/article/details/128942133

5.分片文件上传时看需要进行建立一个单独的文件夹存储分片文件. 断点续传是 已经上传了一部分文件后中断上传, 再次 上传此文件时,会从之前已上传的文件开始继续上传剩余部分文件.

6.文章将介绍实现思路和流程图, 以及实现效果和开发过程中遇到的问题.

思路:

1. 第一步校验文件接口:

获取前端对文件信息的MD5加密, 和文件流. 对文件的大小, 类型, 文件名, 文件大小, 分片大小, 分片总数, 对文件进行计算所需截取的每个分片大小(存储在 分片数据集合 ), 是否是最后一个分片,文件ID 等主要参数, 保存到数据库中此文件的信息, 并会在此接口中返回, 交给前端判断.

2. 第二步断点续传接口:

将校验文件接口中的 分片数据集合 进行遍历, 调用 分片上传接口, 上传每次 slice函数截取的文件流, 文件ID. 其中截取文件大小的计算逻辑由后端完成(精确到byte字节). 后端在每次上传完成后会返回是否是文件最后一个分片的标识

大文件断点续传流程图:

数据库设计:

  1. SET NAMES utf8mb4;
  2. SET FOREIGN_KEY_CHECKS = 0;
  3. -- ----------------------------
  4. -- Table structure for sys_file
  5. -- ----------------------------
  6. DROP TABLE IF EXISTS `sys_file`;
  7. CREATE TABLE `sys_file` (
  8. `file_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '主键ID',
  9. `business_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '业务主键ID',
  10. `bus_mode` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT 'SYSTEM' COMMENT '业务模块类型',
  11. `server_file_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '对象存储服务器中的文件名',
  12. `file_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '文件名称',
  13. `file_type` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '文件类型',
  14. `file_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '文件路径',
  15. `file_size` decimal(30, 0) NULL DEFAULT NULL COMMENT '文件大小(byte字节数)',
  16. `source_type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT 'MANUAL' COMMENT '来源类型',
  17. `chunk_flag` tinyint NULL DEFAULT 0 COMMENT '分片标记: 分片-1;不分片-0;',
  18. `chunk_folder` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '分片文件夹',
  19. `chunk_md5` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '分片的MD5加密',
  20. `shard_total` decimal(10, 0) NULL DEFAULT NULL COMMENT '分片总数量',
  21. `shard_index` decimal(10, 0) NULL DEFAULT 0 COMMENT '当前分片编号',
  22. `shard_size` decimal(30, 0) NULL DEFAULT NULL COMMENT '每个分片大小',
  23. PRIMARY KEY (`file_id`) USING BTREE
  24. ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin COMMENT = '附件表' ROW_FORMAT = Dynamic;
  25. SET FOREIGN_KEY_CHECKS = 1;

前端核心代码实现:

vue3上传文件组件:
  1. <el-form
  2. :label-position="top"
  3. :inline=true
  4. :model="fileForm"
  5. label-width="200px"
  6. >
  7. <el-form-item label="文件信息">
  8. <el-upload
  9. class="upload-demo"
  10. :on-change="onChange"
  11. :on-remove="onRemove"
  12. :auto-upload="false"
  13. >
  14. <el-button size="small" type="primary">选择文件</el-button>
  15. </el-upload>
  16. </el-form-item>
  17. <el-form-item>
  18. <el-button type="danger" size="large" @click="onUpload" round>上传文件</el-button>
  19. </el-form-item>
  20. </el-form>
上传文件方法:
  1. onUpload() {
  2. // 在上传文件的时候携带其他参数进行提交内容
  3. // 先请求 校验文件接口, 验证文件是否已上传,和上传到了哪个分片
  4. let realFile = this.fileForm.file.raw;
  5. let fileType = realFile.name.substring(realFile.name.lastIndexOf(".") + 1, realFile.name.length);
  6. let filePwd = realFile.name + realFile.size + fileType + realFile.lastModified;
  7. let filePwdMd5 = CryptoJS.MD5(filePwd).toString();
  8. let verifyData = new FormData();
  9. verifyData.append('file', realFile);
  10. verifyData.append('fileName', realFile.name);
  11. verifyData.append('fileSize', realFile.size);
  12. verifyData.append('lastModified', realFile.lastModified);
  13. verifyData.append('chunkMd5', filePwdMd5);
  14. // 业务相关字段
  15. verifyData.append('businessId', '1111111');
  16. verifyData.append('busMode', 'DEV_MODEL');
  17. // 校验文件, 并返回文件的分片数量,文件大小,文件ID,文件分片所需截取信息
  18. verifyFile(verifyData).then(res => {
  19. this.fileInfo = res.data.data;
  20. // 最后一个分片标识
  21. if (this.fileInfo.endFlag) {
  22. // 进度条
  23. this.Progress.progress = 100;
  24. } else {
  25. // 进度条计算
  26. this.Progress.progress = ((this.fileInfo.shardIndex / this.fileInfo.shardTotal) * 0.1) * 1000;
  27. }
  28. this.uploadShardFile(realFile);
  29. });
  30. },
断点续传方法:
  1. uploadShardFile(realFile) {
  2. // 对文件流按照校验接口的返回值进行截取,使用for循环 重复调用断点续传文件接口
  3. const asyncUpload = async () => {
  4. for (let i = 0; i < this.fileInfo.shardList.length; i++) {
  5. let item = this.fileInfo.shardList[i]
  6. let shardFile = realFile.slice(item.shardStart, item.shardEnd);
  7. let data = new FormData();
  8. data.append('fileId', this.fileInfo.fileId);
  9. data.append('file', shardFile);
  10. data.append('shardIndex', item.shardIndex);
  11. const res = await breakpointUpload(data)
  12. let uploadData = res.data.data;
  13. console.log(uploadData)
  14. // 最后一个分片标识
  15. if (uploadData.endFlag) {
  16. // 进度条计算
  17. this.Progress.progress = 100;
  18. } else {
  19. // 进度条计算
  20. this.Progress.progress = Math.round(((uploadData.shardIndex / uploadData.shardTotal) * 0.1) * 1000);
  21. }
  22. console.log(((uploadData.shardIndex / uploadData.shardTotal) * 0.1) * 1000)
  23. console.log(uploadData.shardIndex)
  24. }
  25. }
  26. asyncUpload()
  27. this.getTableDate();
  28. },

后端核心代码实现:

校验文件接口:
  1. public FileVerify verifyFile(FileVerify fileVerify) {
  2. // 校验文件时将保存文件信息到 数据库中.
  3. log.info("fileVerify-->{}", fileVerify);
  4. LambdaQueryWrapper<FileInfo> queryWrapper = Wrappers.lambdaQuery(new FileInfo());
  5. queryWrapper.eq(StrUtil.isNotBlank(fileVerify.getChunkMd5()), FileInfo::getChunkMd5, fileVerify.getChunkMd5());
  6. queryWrapper.eq(StrUtil.isNotBlank(fileVerify.getFileId()), FileInfo::getFileId, fileVerify.getFileId());
  7. FileInfo existFile = fileMapper.selectOne(queryWrapper);
  8. // 在编辑文件的时, 其 文件ID和文件MD5加密都在传参中,此时还是没有找到文件, 则抛出异常.
  9. Assert.isFalse(StrUtil.isNotBlank(fileVerify.getChunkMd5()) && StrUtil.isNotBlank(fileVerify.getFileId()) && Objects.isNull(existFile), "文件ID和文件MD5加密都在传参中,此时还是没有找到文件, 则抛出异常.");
  10. if (Objects.isNull(existFile)) {
  11. // 将文件信息保存到数据库中 并返回文件ID和MD5
  12. existFile = getFileInfoByVerifyFile(fileVerify);
  13. fileMapper.insert(existFile);
  14. }
  15. fileVerify.setFileId(existFile.getFileId());
  16. fileVerify.setChunkMd5(existFile.getChunkMd5());
  17. fileVerify.setChunkFlag(existFile.getChunkFlag());
  18. fileVerify.setShardTotal(existFile.getShardTotal());
  19. fileVerify.setShardSize(existFile.getShardSize());
  20. fileVerify.setShardIndex(existFile.getShardIndex());
  21. if (existFile.getShardIndex().equals(existFile.getShardTotal())) {
  22. fileVerify.setChunkFlag(Boolean.TRUE);
  23. }
  24. fileVerify.setFile(null);
  25. // 生成文件分片数值列表
  26. List<FileVerify.ChunkShard> shardList = getShardList(fileVerify);
  27. fileVerify.setShardList(shardList);
  28. fileVerify.setEndFlag(CollUtil.isEmpty(shardList));
  29. return fileVerify;
  30. }
  31. /**
  32. * 构建所需要对文件进行截取的分片开始值和结束值, 用于前端 for循环调用分片上传接口
  33. */
  34. private List<FileVerify.ChunkShard> getShardList(FileVerify fileVerify) {
  35. BigDecimal shardTotal = fileVerify.getShardTotal();
  36. List<FileVerify.ChunkShard> shardList = CollUtil.newArrayList();
  37. for (int i = fileVerify.getShardIndex().intValue(); i < shardTotal.intValue(); i++) {
  38. FileVerify.ChunkShard chunkShard = new FileVerify.ChunkShard();
  39. chunkShard.setShardTotal(fileVerify.getShardTotal());
  40. chunkShard.setShardIndex(BigDecimal.valueOf(i));
  41. chunkShard.setShardStart(BigDecimal.valueOf(i).multiply(fileVerify.getShardSize()));
  42. chunkShard.setShardEnd(BigDecimal.valueOf(i).multiply(fileVerify.getShardSize()).add(fileVerify.getShardSize()));
  43. if (i == shardTotal.intValue() - 1) {
  44. //如果是最后一个分片则对截取文件的大小
  45. chunkShard.setShardEnd(BigDecimal.valueOf(fileVerify.getFileSize()));
  46. }
  47. shardList.add(chunkShard);
  48. }
  49. return shardList;
  50. }
  51. // 每 20MB 作为一个分片
  52. private int shardSizeInt = 10 * 1024 * 1024;
  53. /**
  54. * 封装校验文件接口所需要的参数信息
  55. */
  56. private FileInfo getFileInfoByVerifyFile(FileVerify fileVerify) {
  57. FileInfo fileInfo = new FileInfo();
  58. fileInfo.setBusinessId(fileVerify.getBusinessId());
  59. fileInfo.setBusMode(fileVerify.getBusMode());
  60. fileInfo.setFileName(fileVerify.getFileName());
  61. fileInfo.setFileType(FileNameUtil.getSuffix(fileVerify.getFileName()));
  62. fileInfo.setFileSize(BigDecimal.valueOf(fileVerify.getFileSize()));
  63. fileInfo.setServerFileName(UUID.randomUUID().toString(true).toUpperCase() + "." + fileInfo.getFileType());
  64. fileInfo.setChunkMd5(fileVerify.getChunkMd5());
  65. fileInfo.setChunkFlag(Boolean.TRUE);
  66. fileInfo.setChunkFolder(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")) + "/" + RandomUtil.randomString(6).toUpperCase());
  67. fileInfo.setShardIndex(BigDecimal.ZERO);
  68. // 以 每 20MB进行上传文件
  69. // 字节数除以20MB, 即 除以 20*1024*1024
  70. BigDecimal shardSize = BigDecimal.valueOf(shardSizeInt);
  71. fileInfo.setShardSize(shardSize);
  72. BigDecimal shardTotal = fileInfo.getFileSize().divide(shardSize, 0, RoundingMode.HALF_UP);
  73. fileInfo.setShardTotal(shardTotal);
  74. return fileInfo;
  75. }
断点续传接口:
  1. public FileVerify breakpointUpload(FileVerify fileVerify) {
  2. Assert.notBlank(fileVerify.getFileId(), "文件ID不能为空.");
  3. FileInfo existFile = fileMapper.selectById(fileVerify.getFileId());
  4. Assert.notNull(existFile, "未找到此文件信息");
  5. fileVerify.setShardTotal(existFile.getShardTotal());
  6. // 上传文件
  7. MultipartFile file = fileVerify.getFile();
  8. // 自定义分片的名称以及上传路径
  9. String shardFileName = getShardFileName(existFile, fileVerify.getShardIndex());
  10. MinioUtils.me().upLoaderShardFile(file, shardFileName);
  11. log.info("上传成功的分片文件名是->{}", shardFileName);
  12. // 更新文件信息
  13. FileInfo fileInfo = new FileInfo();
  14. fileInfo.setFileId(fileVerify.getFileId());
  15. // 更新上传的文件分片下标
  16. fileInfo.setShardIndex(fileVerify.getShardIndex());
  17. fileMapper.updateById(fileInfo);
  18. log.error("更新文件分片信息到数据库-->{}", JSONUtil.toJsonStr(fileInfo));
  19. fileVerify.setFile(null);
  20. // 如果上传的分片下标正好等于 (总数的-1) 则认为是最后一个文件的分片已经上传成功了. 可以进行对所有的分片文件进行合并.
  21. if (fileVerify.getShardIndex().equals(existFile.getShardTotal().subtract(BigDecimal.ONE))) {
  22. fileVerify.setEndFlag(Boolean.TRUE);
  23. // 开始进行对分片文件进行合并
  24. log.error("此处所有分片文件都已上传, 进行合并文件...");
  25. mergeShardFile(existFile);
  26. }
  27. return fileVerify;
  28. }
  29. /**
  30. * 将多个分片文件进行合并.
  31. */
  32. private void mergeShardFile(FileInfo existFile) {
  33. // 获取所有的分片文件流,进行组装为一个流, 并写入到远程文件中.
  34. BigDecimal shardTotal = existFile.getShardTotal();
  35. byte[] allFileByte = new byte[existFile.getFileSize().intValue()];
  36. int startLength = 0;
  37. for (int i = 0; i < shardTotal.intValue(); i++) {
  38. existFile.setShardIndex(BigDecimal.valueOf(i));
  39. // 构建 文件服务器中的所有分片文件的名称
  40. // 通过文件名从文件服务器中获取文件流, 进行合并到一个文件流中.
  41. String fileName = getShardFileName(existFile, BigDecimal.valueOf(i));
  42. byte[] fileByte = MinioUtils.me().getFileStream(fileName);
  43. /**
  44. * System.arraycopy(src, srcPos, dest, destPos, length)
  45. * 参数解析:
  46. * src:byte源数组
  47. * srcPos:截取源byte数组起始位置(0位置有效)
  48. * dest,:byte目的数组(截取后存放的数组)
  49. * destPos:截取后存放的数组起始位置(0位置有效)
  50. * length:截取的数据长度
  51. */
  52. System.arraycopy(fileByte, 0, allFileByte, startLength, fileByte.length);
  53. startLength = startLength + fileByte.length;
  54. }
  55. InputStream inputStream = new ByteArrayInputStream(allFileByte);
  56. // 然后将文件流上传至minio服务器
  57. // 指定存储的文件名称
  58. String finalFileName = getFinalFileName(existFile);
  59. // 进行上传文件
  60. MinioUtils.me().upLoaderFileByByte(inputStream, finalFileName);
  61. log.info("上传成功的分片文件名是->{}", finalFileName);
  62. // 更新文件信息
  63. FileInfo fileInfo = new FileInfo();
  64. fileInfo.setFileId(existFile.getFileId());
  65. // 更新上传的文件分片下标
  66. fileInfo.setFileUrl(finalFileName);
  67. log.error("更新成功数据-->{}", JSONUtil.toJsonStr(fileInfo));
  68. fileMapper.updateById(fileInfo);
  69. }
  70. /**
  71. * 生成最终的需要的文件名+路径
  72. */
  73. private String getFinalFileName(FileInfo existFile) {
  74. return existFile.getChunkFolder() + "/" + existFile.getServerFileName();
  75. }
  76. /**
  77. * 生成文件分片名称+路径
  78. */
  79. private String getShardFileName(FileInfo fileInfo, BigDecimal shardIndex) {
  80. if (Objects.isNull(shardIndex)) {
  81. return fileInfo.getChunkFolder() + "/" + fileInfo.getServerFileName();
  82. }
  83. return fileInfo.getChunkFolder() + "/" + fileInfo.getServerFileName() + "." + shardIndex;
  84. }

过程中遇到的问题:

1. 对于文件加密的过程在前端还是在后端? 需要在前端进行计算.

2. 对于文件的分片大小限定多少? 网上找的一些示例: 5MB-10MB, 此处定为 10MB.

3. 在前端循环分片数据集合调用接口时, 会出现接口调用时长不一致, 先上传了其中某一个分片, 在更新数据库时更新数据错误? 前端使用  async + await 进行异步阻塞式的获取接口返回参数, 保证是按照分片数据集合的顺序 调用分片上传接口的.

4. 对于文件分片的计算放在前端?还是后端? 放在后端使用 bigdecmail 类型, 计算时向上取整不保留小数位.

最终实现的效果:

项目源码地址:

前端:  hulunbuir-front/src/views/pages/FilePageView.vue - Gitee.com

后端:   

hulunbuir-study/src/main/java/com/hulunbuir/study/infra/service/FileServiceImpl.java · hulun-buir - Gitee.com

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/羊村懒王/article/detail/528131
推荐阅读
相关标签
  

闽ICP备14008679号