vue3 + springboot3 大文件断点续传

序言:


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字节). 后端在每次上传完成后会返回是否是文件最后一个分片的标识

大文件断点续传流程图:

数据库设计:

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

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

SET FOREIGN_KEY_CHECKS = 1;

前端核心代码实现:

vue3上传文件组件:
<el-form
            :label-position="top"
            :inline=true
            :model="fileForm"
            label-width="200px"
        >
          <el-form-item label="文件信息">
            <el-upload
                class="upload-demo"
                :on-change="onChange"
                :on-remove="onRemove"
                :auto-upload="false"
            >
              <el-button size="small" type="primary">选择文件</el-button>
            </el-upload>
          </el-form-item>
          <el-form-item>
            <el-button type="danger" size="large" @click="onUpload" round>上传文件</el-button>
          </el-form-item>
</el-form>
上传文件方法:
    onUpload() {
      // 在上传文件的时候携带其他参数进行提交内容
      // 先请求 校验文件接口, 验证文件是否已上传,和上传到了哪个分片
      let realFile = this.fileForm.file.raw;

      let fileType = realFile.name.substring(realFile.name.lastIndexOf(".") + 1, realFile.name.length);
      let filePwd = realFile.name + realFile.size + fileType + realFile.lastModified;
      let filePwdMd5 = CryptoJS.MD5(filePwd).toString();

      let verifyData = new FormData();
      verifyData.append('file', realFile);
      verifyData.append('fileName', realFile.name);
      verifyData.append('fileSize', realFile.size);
      verifyData.append('lastModified', realFile.lastModified);
      verifyData.append('chunkMd5', filePwdMd5);
      // 业务相关字段
      verifyData.append('businessId', '1111111');
      verifyData.append('busMode', 'DEV_MODEL');

      // 校验文件, 并返回文件的分片数量,文件大小,文件ID,文件分片所需截取信息
      verifyFile(verifyData).then(res => {
        this.fileInfo = res.data.data;
        // 最后一个分片标识
        if (this.fileInfo.endFlag) {
          // 进度条
          this.Progress.progress = 100;
        } else {
          // 进度条计算
          this.Progress.progress = ((this.fileInfo.shardIndex / this.fileInfo.shardTotal) * 0.1) * 1000;
        }
        this.uploadShardFile(realFile);
      });
    },
断点续传方法:
    uploadShardFile(realFile) {
      // 对文件流按照校验接口的返回值进行截取,使用for循环 重复调用断点续传文件接口
      const asyncUpload = async () => {
        for (let i = 0; i < this.fileInfo.shardList.length; i++) {
          let item = this.fileInfo.shardList[i]
          let shardFile = realFile.slice(item.shardStart, item.shardEnd);
          let data = new FormData();
          data.append('fileId', this.fileInfo.fileId);
          data.append('file', shardFile);
          data.append('shardIndex', item.shardIndex);
          const res = await breakpointUpload(data)
          let uploadData = res.data.data;
          console.log(uploadData)
          // 最后一个分片标识
          if (uploadData.endFlag) {
            // 进度条计算
            this.Progress.progress = 100;
          } else {
            // 进度条计算
            this.Progress.progress = Math.round(((uploadData.shardIndex / uploadData.shardTotal) * 0.1) * 1000);
          }
          console.log(((uploadData.shardIndex / uploadData.shardTotal) * 0.1) * 1000)
          console.log(uploadData.shardIndex)
        }
      }
      asyncUpload()

      this.getTableDate();
    },

后端核心代码实现:

校验文件接口:
    public FileVerify verifyFile(FileVerify fileVerify) {
        // 校验文件时将保存文件信息到 数据库中.
        log.info("fileVerify-->{}", fileVerify);
        LambdaQueryWrapper<FileInfo> queryWrapper = Wrappers.lambdaQuery(new FileInfo());
        queryWrapper.eq(StrUtil.isNotBlank(fileVerify.getChunkMd5()), FileInfo::getChunkMd5, fileVerify.getChunkMd5());
        queryWrapper.eq(StrUtil.isNotBlank(fileVerify.getFileId()), FileInfo::getFileId, fileVerify.getFileId());

        FileInfo existFile = fileMapper.selectOne(queryWrapper);
        // 在编辑文件的时, 其 文件ID和文件MD5加密都在传参中,此时还是没有找到文件, 则抛出异常.
        Assert.isFalse(StrUtil.isNotBlank(fileVerify.getChunkMd5()) && StrUtil.isNotBlank(fileVerify.getFileId()) && Objects.isNull(existFile), "文件ID和文件MD5加密都在传参中,此时还是没有找到文件, 则抛出异常.");
        if (Objects.isNull(existFile)) {
            // 将文件信息保存到数据库中 并返回文件ID和MD5
            existFile = getFileInfoByVerifyFile(fileVerify);
            fileMapper.insert(existFile);
        }
        fileVerify.setFileId(existFile.getFileId());
        fileVerify.setChunkMd5(existFile.getChunkMd5());
        fileVerify.setChunkFlag(existFile.getChunkFlag());
        fileVerify.setShardTotal(existFile.getShardTotal());
        fileVerify.setShardSize(existFile.getShardSize());
        fileVerify.setShardIndex(existFile.getShardIndex());

        if (existFile.getShardIndex().equals(existFile.getShardTotal())) {
            fileVerify.setChunkFlag(Boolean.TRUE);
        }

        fileVerify.setFile(null);

        // 生成文件分片数值列表
        List<FileVerify.ChunkShard> shardList = getShardList(fileVerify);
        fileVerify.setShardList(shardList);
        fileVerify.setEndFlag(CollUtil.isEmpty(shardList));

        return fileVerify;
    }

    /**
     * 构建所需要对文件进行截取的分片开始值和结束值, 用于前端 for循环调用分片上传接口
     */
    private List<FileVerify.ChunkShard> getShardList(FileVerify fileVerify) {
        BigDecimal shardTotal = fileVerify.getShardTotal();
        List<FileVerify.ChunkShard> shardList = CollUtil.newArrayList();

        for (int i = fileVerify.getShardIndex().intValue(); i < shardTotal.intValue(); i++) {
            FileVerify.ChunkShard chunkShard = new FileVerify.ChunkShard();
            chunkShard.setShardTotal(fileVerify.getShardTotal());
            chunkShard.setShardIndex(BigDecimal.valueOf(i));
            chunkShard.setShardStart(BigDecimal.valueOf(i).multiply(fileVerify.getShardSize()));
            chunkShard.setShardEnd(BigDecimal.valueOf(i).multiply(fileVerify.getShardSize()).add(fileVerify.getShardSize()));
            if (i == shardTotal.intValue() - 1) {
                //如果是最后一个分片则对截取文件的大小
                chunkShard.setShardEnd(BigDecimal.valueOf(fileVerify.getFileSize()));
            }
            shardList.add(chunkShard);
        }

        return shardList;
    }

    // 每 20MB 作为一个分片
    private int shardSizeInt = 10 * 1024 * 1024;

    /**
     * 封装校验文件接口所需要的参数信息
     */
    private FileInfo getFileInfoByVerifyFile(FileVerify fileVerify) {
        FileInfo fileInfo = new FileInfo();
        fileInfo.setBusinessId(fileVerify.getBusinessId());
        fileInfo.setBusMode(fileVerify.getBusMode());
        fileInfo.setFileName(fileVerify.getFileName());
        fileInfo.setFileType(FileNameUtil.getSuffix(fileVerify.getFileName()));
        fileInfo.setFileSize(BigDecimal.valueOf(fileVerify.getFileSize()));

        fileInfo.setServerFileName(UUID.randomUUID().toString(true).toUpperCase() + "." + fileInfo.getFileType());

        fileInfo.setChunkMd5(fileVerify.getChunkMd5());
        fileInfo.setChunkFlag(Boolean.TRUE);
        fileInfo.setChunkFolder(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")) + "/" + RandomUtil.randomString(6).toUpperCase());

        fileInfo.setShardIndex(BigDecimal.ZERO);
        // 以 每 20MB进行上传文件
        // 字节数除以20MB, 即 除以 20*1024*1024
        BigDecimal shardSize = BigDecimal.valueOf(shardSizeInt);
        fileInfo.setShardSize(shardSize);
        BigDecimal shardTotal = fileInfo.getFileSize().divide(shardSize, 0, RoundingMode.HALF_UP);
        fileInfo.setShardTotal(shardTotal);

        return fileInfo;
    }
断点续传接口:
    public FileVerify breakpointUpload(FileVerify fileVerify) {
        Assert.notBlank(fileVerify.getFileId(), "文件ID不能为空.");
        FileInfo existFile = fileMapper.selectById(fileVerify.getFileId());
        Assert.notNull(existFile, "未找到此文件信息");
        fileVerify.setShardTotal(existFile.getShardTotal());

        // 上传文件
        MultipartFile file = fileVerify.getFile();
        // 自定义分片的名称以及上传路径
        String shardFileName = getShardFileName(existFile, fileVerify.getShardIndex());
        MinioUtils.me().upLoaderShardFile(file, shardFileName);
        log.info("上传成功的分片文件名是->{}", shardFileName);
        // 更新文件信息
        FileInfo fileInfo = new FileInfo();
        fileInfo.setFileId(fileVerify.getFileId());
        // 更新上传的文件分片下标
        fileInfo.setShardIndex(fileVerify.getShardIndex());
        fileMapper.updateById(fileInfo);
        log.error("更新文件分片信息到数据库-->{}", JSONUtil.toJsonStr(fileInfo));
        fileVerify.setFile(null);
        // 如果上传的分片下标正好等于 (总数的-1) 则认为是最后一个文件的分片已经上传成功了. 可以进行对所有的分片文件进行合并.
        if (fileVerify.getShardIndex().equals(existFile.getShardTotal().subtract(BigDecimal.ONE))) {
            fileVerify.setEndFlag(Boolean.TRUE);
            // 开始进行对分片文件进行合并
            log.error("此处所有分片文件都已上传, 进行合并文件...");
            mergeShardFile(existFile);
        }
        return fileVerify;
    }

    /**
     * 将多个分片文件进行合并.
     */
    private void mergeShardFile(FileInfo existFile) {
        // 获取所有的分片文件流,进行组装为一个流, 并写入到远程文件中.
        BigDecimal shardTotal = existFile.getShardTotal();
        byte[] allFileByte = new byte[existFile.getFileSize().intValue()];
        int startLength = 0;
        for (int i = 0; i < shardTotal.intValue(); i++) {
            existFile.setShardIndex(BigDecimal.valueOf(i));
            // 构建 文件服务器中的所有分片文件的名称
            // 通过文件名从文件服务器中获取文件流, 进行合并到一个文件流中.
            String fileName = getShardFileName(existFile, BigDecimal.valueOf(i));
            byte[] fileByte = MinioUtils.me().getFileStream(fileName);
            /**
             * System.arraycopy(src, srcPos, dest, destPos, length)
             * 参数解析:
             * src:byte源数组
             * srcPos:截取源byte数组起始位置(0位置有效)
             * dest,:byte目的数组(截取后存放的数组)
             * destPos:截取后存放的数组起始位置(0位置有效)
             * length:截取的数据长度
             */
            System.arraycopy(fileByte, 0, allFileByte, startLength, fileByte.length);
            startLength = startLength + fileByte.length;
        }
        InputStream inputStream = new ByteArrayInputStream(allFileByte);
        // 然后将文件流上传至minio服务器
        // 指定存储的文件名称
        String finalFileName = getFinalFileName(existFile);
        // 进行上传文件
        MinioUtils.me().upLoaderFileByByte(inputStream, finalFileName);
        log.info("上传成功的分片文件名是->{}", finalFileName);
        // 更新文件信息
        FileInfo fileInfo = new FileInfo();
        fileInfo.setFileId(existFile.getFileId());
        // 更新上传的文件分片下标
        fileInfo.setFileUrl(finalFileName);
        log.error("更新成功数据-->{}", JSONUtil.toJsonStr(fileInfo));
        fileMapper.updateById(fileInfo);
    }

    /**
     * 生成最终的需要的文件名+路径
     */
    private String getFinalFileName(FileInfo existFile) {
        return existFile.getChunkFolder() + "/" + existFile.getServerFileName();
    }

    /**
     * 生成文件分片名称+路径
     */
    private String getShardFileName(FileInfo fileInfo, BigDecimal shardIndex) {
        if (Objects.isNull(shardIndex)) {
            return fileInfo.getChunkFolder() + "/" + fileInfo.getServerFileName();
        }
        return fileInfo.getChunkFolder() + "/" + fileInfo.getServerFileName() + "." + shardIndex;
    }

过程中遇到的问题:

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

  • 1
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 6
    评论
vue3是一种流行的JavaScript框架,用于构建用户界面。它具有响应式数据绑定和组件化的特点,可以让开发者更轻松地构建交互性的Web应用程序。相比于旧版本,Vue3带来了许多改进和新特性,例如更快的渲染速度,更好的类型检查和更好的组件调试工具等。 SpringBoot是一个用于构建Java应用程序的开发框架。它简化了Java应用程序的开发流程,并提供了许多开箱即用的功能和库。使用SpringBoot,开发者可以更快地搭建和部署应用程序,并通过自动配置和减少样板代码来提高开发效率。 分片是指将一个大型任务或数据集分割成多个较小的部分进行处理或存储的过程。这种方法可以提高并行处理能力和系统的性能。在分片任务中,每个小任务可以独立地执行,从而更好地利用系统资源。 断点是一种调试技术,允许开发者在程序执行的特定位置暂停,并观察程序的状态和变量值。通过在代码中设置断点,开发者可以逐步跟踪代码执行的过程,并发现潜在的错误或问题。 多任务是指操作系统或应用程序同时执行多个任务的能力。在多任务环境中,操作系统可以通过时间片轮转或优先级调度等技术,使多个任务交替执行,并给用户带来更好的系统响应性和用户体验。 综上所述,Vue3和SpringBoot是用于构建不同类型应用程序的框架,分片是一种优化任务处理和存储的技术,断点是调试中的工具,而多任务是操作系统或应用程序的特性。
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值