大文件分片上传断点续传解决方案

大文件分片上传断点续传解决方案

记录一次大文件上传,文件为巨量的图片压缩包,因为图片是需要预览的,所以每张都需要传到minio去,不能采用minio的分片上传方式,这个方式只能把压缩包上传到minio,无法实现预览。

后来逻辑为:先分片上传到服务器临时文件夹,然后后端解析,一张张传到minio,当然传到minio需要异步执行。

一、分片上传压缩包到服务器

需要前后端配合:前端获取文件的hash值,然后把文件按照一定大小分片,循环调用接口,上传到服务器,最后发送合并接口,合并的依据就是文件的hash值。

前端主要逻辑:

methods: {
    // 上传完文件触发
    async handleChange(file) {
      if (!file) return
      this.percent = 0
      this.percentCount = 0
      this.videoUrl = ''
      // 获取文件并转成 ArrayBuffer 对象
      const fileObj = file.raw
      this.file = fileObj.name
      let buffer
      try {
        buffer = await this.fileToBuffer(fileObj)
      } catch (e) {
        console.log(e)
      }

      // 将文件按固定大小(10M)进行切片,注意此处同时声明了多个常量
      const chunkSize = 10485760,
          chunkList = [], // 保存所有切片的数组
          chunkListLength = Math.ceil(fileObj.size / chunkSize), // 计算总共多个切片
          suffix = /\.([0-9A-z]+)$/.exec(fileObj.name)[1] // 文件后缀名

      // 根据文件内容生成 hash 值
      const spark = new SparkMD5.ArrayBuffer()
      spark.append(buffer)
      const hash = spark.end()

      // 生成切片,这里后端要求传递的参数为字节数据块(chunk)和每个数据块的文件名(fileName)
      let curChunk = 0 // 切片时的初始位置
      for (let i = 0; i < chunkListLength; i++) {
        const item = {
          chunk: fileObj.slice(curChunk, curChunk + chunkSize),
          fileName: `${hash}_${i}.${suffix}` // 文件名规则按照 hash_1.jpg 命名
        }
        curChunk += chunkSize
        chunkList.push(item)
      }
      this.chunkList = chunkList // sendRequest 要用到
      this.hash = hash // sendRequest 要用到
      this.sendRequest()
    },

    // 发送请求
    sendRequest() {
      const requestList = [] // 请求集合
      let that = this
      this.chunkList.forEach((item, index) => {
        const fn = () => {
          const formData = new FormData()
          formData.append('chunk', item.chunk)
          formData.append('filename', item.fileName)
          return api.upload.chunk(formData).then(res => {
            if (res.code === 200) { // 成功
              if (that.percentCount === 0) { // 避免上传成功后会删除切片改变 chunkList 的长度影响到 percentCount 的值
                that.percentCount = 100 / that.chunkList.length
              }
              if (that.percent >= 100) {
                that.percent = 100;
              }else {
                that.percent += that.percentCount // 改变进度
              }
              if (that.percent >= 100) {
                that.percent = 100;
              }
              that.chunkList.splice(index, 1) // 一旦上传成功就删除这一个 chunk,方便断点续传
            }
          })
        }
        requestList.push(fn)
      })

      let i = 0 // 记录发送的请求个数
      // 文件切片全部发送完毕后,需要请求 '/merge' 接口,把文件的 hash 传递给服务器
      const complete = () => {
        api.upload.merge(this.hash, this.file).then(res => {
          if (res.code === 200) { // 请求发送成功
            // this.videoUrl = res.data.path
            console.log(res)
          }
        })
      }
      const send = async () => {
        if (!this.upload) return
        if (i >= requestList.length) {
          // 发送完毕
          complete()
          return
        }
        await requestList[i]()
        i++
        send()
      }
      send() // 发送请求
    },

    // 按下暂停按钮
    handleClickBtn() {
      this.upload = !this.upload
      // 如果不暂停则继续上传
      if (this.upload) this.sendRequest()
    },

    // 将 File 对象转为 ArrayBuffer
    fileToBuffer(file) {
      return new Promise((resolve, reject) => {
        const fr = new FileReader()
        fr.readAsArrayBuffer(file)
        fr.onload = e => {
          resolve(e.target.result)
        }
        fr.onerror = () => {
          reject(new Error('转换文件格式发生错误'))
        }
      })
    }
}

后端主要逻辑

@PostMapping("chunk")
  @ApiOperation("压缩包分片上传")
  public Result<Object> chunkUpload(@RequestParam MultipartFile chunk,
                                    @RequestParam String filename) {
    File folder = new File(tmpPath);
    if (!(folder.exists() && folder.isDirectory())) {
      folder.mkdirs();
    }
    String filePath = tmpPath+File.separator+filename;
    try {
      File file = new File(filePath);
      FileOutputStream fileOutputStream = new FileOutputStream(file);
      fileOutputStream.write(chunk.getBytes());
      fileOutputStream.close();
      chunk.transferTo(file);
    }catch (Exception e) {
      e.printStackTrace();
    }
    return Result.OK("成功");
  }
// ------------------------------------------------------------------------------------
@GetMapping("merge")
  @ApiOperation("压缩包分片合并")
  public Result<String> merge(@RequestParam String hash,
                              @RequestParam String filename) {
    File chunkFileFolder = new File(tmpPath);
    File mergeFile = new File(tmpPath + File.separator + filename);
    File[] chunks = chunkFileFolder.listFiles();
    Assert.notNull(chunks, "未上传分片");
    File[] files = Arrays.stream(chunks)
            .filter(file -> file.getName().startsWith(hash))
            .sorted(Comparator.comparing(o -> Integer.valueOf(o.getName().split("\\.")[0].split("_")[1])))
            .toArray(File[]::new);

    try {
      RandomAccessFile randomAccessFileWriter = new RandomAccessFile(mergeFile, "rw");
      byte[] bytes = new byte[1024];
      for (File chunk : files) {
        RandomAccessFile randomAccessFileReader = new RandomAccessFile(chunk, "r");
        int len;
        while ((len = randomAccessFileReader.read(bytes)) != -1) {
          randomAccessFileWriter.write(bytes, 0 ,len);
        }
        randomAccessFileReader.close();
      }
      randomAccessFileWriter.close();

      // 删除分片
      Arrays.stream(files).forEach(File::delete);
    }catch (Exception e) {
      e.printStackTrace();
    }
    return Result.OK("成功");
  }

二、解析压缩包上传图片到minio

解析上传主要逻辑

先解压压缩包,然后验证文件夹层级的格式,然后递归遍历出所有的图片,然后循环上传至minio,用redis计数器记录上传图片的张数,最后把文件信息保存到数据库

/**
   * 异步方法
   * 有@Async注解的方法,默认就是异步执行的,会在默认的线程池中执行,但是此方法不能在本类调用;
   * 启动类需添加直接开启异步执行@EnableAsync。
   * @param filename zip
   */
  @Override
  @SneakyThrows
  @Async("normalThreadPool")
  public void parseAndUpload(String filename){
    // D:\tmp\罗柏线.zip
    String zipPath = path+File.separator+filename;
    // 解压成 -> D:\tmp\罗柏线
    String unzipPath = checkUnZip(zipPath);
    // 递归遍历拿到所有文件
    List<String> allPicAndFiles = new ArrayList<>();
    visitAllDirAndFiles(unzipPath, allPicAndFiles);
    // 检查格式
    checkFormat(allPicAndFiles);
    // 上传
    try {
      //查表 查fullPath判断是否上传过
      List<ThirdImageInfo> list = baseMapper.selectList(new QueryWrapper<>());
      if (!list.isEmpty()) {
        List<String> uploaded = list.stream().map(ThirdImageInfo::getLocatePath).collect(Collectors.toList());
        allPicAndFiles.removeIf(uploaded::contains);
      }

      redisUtil.set(ThirdConstant.REDIS_PIC_ALL_COUNT, allPicAndFiles.size());
      redisUtil.set(ThirdConstant.REDIS_PIC_CURRENT_COUNT, allPicAndFiles.size());

      List<ThirdImageInfo> infoList = new ArrayList<>();
      for (String fullPath : allPicAndFiles) {
        String objectName = fullPath.
                replace(path, ThirdConstant.IMAGE_OBJECT_NAME_ROOT).
                replace("\\", "/");
        @Cleanup
        FileInputStream stream = new FileInputStream(new File(fullPath));
        minioUtil.uploadFile(
                stream, defaultBucketName, objectName, FileUtil.getSuffix(fullPath)
        );
        redisUtil.decr(ThirdConstant.REDIS_PIC_CURRENT_COUNT, 1);
        // 上传成功 增加一条记录
        infoList.add(insertRecords(fullPath, objectName));
        System.out.println(fullPath +"  上传成功");
      }

      this.saveBatch(infoList);
      redisUtil.set(ThirdConstant.REDIS_PIC_ALL_COUNT, 0);
    }catch (Exception e) {
      e.printStackTrace();
    }

  }

完整代码上传至仓库,有时间再整理。

posted @   Wenenenenen  阅读(167)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
点击右上角即可分享
微信分享提示