java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > SpringBoot Vue分片上传系统

基于SpringBoot和Vue实现分片上传系统

作者:懂咖啡的Java实习生

最近想做一个关于文件上传的个人小网盘,一开始尝试使用了OSS的方案,但是该方案对于大文件来说并不友好,所以开始尝试分片上传方案的探索,接下来小编给大家详细的介绍一下如何基于SpringBoot和Vue实现分片上传系统,需要的朋友可以参考下

最近想做一个关于文件上传的个人小网盘,一开始尝试使用了OSS的方案,但是该方案对于大文件来说并不友好,一个是OSS云服务厂商费用高昂的问题,另外一个是大文件速度较慢。于是看了网络上的帖子以及工作室小伙伴的推荐,开始尝试分片上传方案的探索,目前整个项目已经完成,本人认为使用的技术都是最简单且高效的方法,主要采用自己编写的方案,在应用层比较少使用到第三方的技术,主要用到的技术有Vue+SpringBoot+MySQL+Redis。此次展示分享上传部分,感兴趣的小伙伴们可以点赞评论,我会在后面及时更新!

首先第一步是整个上传过程中最重要的一环,对文件内容而并非标题进行一个md5编码,基于每一个文件一个唯一的字符串,后续所有文件相关的处理都需要使用到该字符串。这里的计算过程中,采取了黑马在知乎上一篇文章的建议,对文件第一个分片和最后一个分片进行全部计算,其他地方采用前中后两个字节进行计算,这样子可以减少计算量,加快我们的编码速度,此步骤据说也有开源的框架可以代替,这样子可靠性也更高,有兴趣的小伙伴可以自己了解,下面附上自己实现的。

    async calculateHash(fileChunks) {
      return new Promise(resolve => {
        const spark = new sparkMD5.ArrayBuffer()
        const chunks = []
        const CHUNK_SIZE = this.CHUNK_SIZE
        fileChunks.forEach((chunk, index) => {
          if (index === 0 || index === fileChunks.length - 1) {
            // 1. 第一个和最后一个切片的内容全部参与计算
            chunks.push(chunk.file)
          } else {
            // 2. 中间剩余的切片我们分别在前面、后面和中间取2个字节参与计算
            // 前面的2字节
            chunks.push(chunk.file.slice(0, 2))
            // 中间的2字节
            chunks.push(chunk.file.slice(CHUNK_SIZE / 2, CHUNK_SIZE / 2 + 2))
            // 后面的2字节
            chunks.push(chunk.file.slice(CHUNK_SIZE - 2, CHUNK_SIZE))
          }
        })
        const reader = new FileReader()
        reader.readAsArrayBuffer(new Blob(chunks))
        reader.onload = (e) => {
          spark.append(e.target.result)
          resolve(spark.end())
        }
      })
    }

在计算完毕之后,我们可以在上传之前可以先做一次检查,返回我们需要得到的信息,包括但不限于文件是否存在于系统中,文件没有上传的话,那么是否有已经上传了的分片,可以返回一个索引数组。如果已经有该文件存在的话则直接进行保存文件信息就好了,后者有利于实现我们的断点续传工作,第二次上传只要上传还没有上传的部分即可,主要是后端实现为主。

    async uploadCheck() {
      let r;
      await axios.get('/api/file//uploadCheck?fileMd5=' + this.key).then(res => {
        r = res.data.flag;
        this.existCheck=[];
        //存在部分分片则返回存在的文件信息
        if (r == false){
          this.existCheck=res.data.data;
        }
      })
      return r;
    }

后端代码分为接口和服务层代码,分别给出:

    /**
     * 文件整体的查重校验
     * @param fileMd5
     * @return
     */
    @GetMapping("/uploadCheck")
    public Result uploadCheck(String fileMd5,HttpServletRequest httpServletRequest){
        String user = JwtUtil.getId(httpServletRequest.getHeader("token"));
        if (fileService.uploadCheck(fileMd5,user)){
            return new Result(true,true);
        }else {
            //查找文件是否有分片上传过到系统中
            Integer arr[] = fileService.existCheck(fileMd5);
            return new Result(false,arr);
        }
    }

上传时候如果在数据库中发现,已经有用户或者本用户在系统中已经成功上传过该文件的话,那么我们可以直接插入数据返回保存完毕即可了,无需真正意义上的上传。不存在则在redis中看一下那些索引已经上传过了,将索引数组返回,前端后续上传跳过即可。

    @Override
    public Boolean uploadCheck(String fileMd5, String userId) {
        //判断文件是否存在
        MyFile myFile = fileMapper.getFileByMd5(fileMd5);
        //如果文件存在直接给用户插入数据记录即可
        if (myFile != null) {
            MyFile newMyFile = new MyFile();
            newMyFile.setId(userId + DateTimeUtil.getTimeStamp());
            newMyFile.setFileName(myFile.getFileName());
            newMyFile.setUser(userId);
            newMyFile.setFileMd5(fileMd5);
            newMyFile.setFileSize(myFile.getFileSize());
            newMyFile.setTime(LocalDateTime.parse(DateTimeUtil.getDateTime(), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
            Integer num = fileMapper.insert(newMyFile);
            if (num == 1) {
                return true;
            }
        }
        return false;
    }

检查完毕之后我们可以开始上传文件啦!上传过程中我认为依然是前端占了打头的,后端只要接收文件不断磁盘写入就好了,虽然说不建议那么多的io次数,但是实际上测试下来还可以,4M带宽的学生服务器都可以做到20秒左右上传100M,本地的话更加是快的不得了,反而前端如果分片分的太小的话,触发的网络请求数量过多,这时候速度上才容易出事,前端分片不要设置太小的话,io次数的话也可以小一点。

      const formDatas = data.map(({chunk, fileHash, index, filename, chunkSize}) => {
        const formData = new FormData()
        // 切片文件
        formData.append('file', chunk)
        // 大文件hash
        formData.append('fileMd5', fileHash)
        //切片的索引
        formData.append('currentIndex', index)
        // 大文件的文件名
        formData.append('fileName', filename)
        // 分片大小
        formData.append('chunkCount', chunkSize)
        return formData
      })
      let index = 0;
      const max = 6; // 并发请求数量
      const taskPool = []// 请求队列
      let t = this.existCheck.length;
      while (index < formDatas.length) {
        //出现重复的切片,跳过
        if (this.existCheck.includes(index)){
          index++;
          continue;
        }
        const task = axios.post('/api/file/uploadBySlice', formDatas[index])
        //splice方法会删除数组中第一个匹配的元素,参数搭配使用findIndex可以找到第一个匹配的元素的索引
        task.then(() => {
          taskPool.splice(taskPool.findIndex((item) => item === task))
          t=t+1;
          this.percentage = Math.floor((t / formDatas.length * 100) * (1.0))-1
        })
        taskPool.push(task);
        if (taskPool.length === max) {
          // 当请求队列中的请求数达到最大并行请求数的时候,得等之前的请求完成再循环下一个
          await Promise.race(taskPool)
        }
        index++
      }
      await Promise.all(taskPool)
    }

后端代码实现,接口层简单,只展示服务层即可。后端主要负责的工作,包括分片写入磁盘,并且在redis中保存已经上传好的文件索引号。

    /**
     * 上传分片、文件
     *
     * @param file
     * @param fileMd5
     * @param currentIndex
     * @return
     */
    @Override
    public Integer uploadFile(MultipartFile file, String fileMd5, Integer currentIndex) {
        //在redis中查询该分片是否已经存在
        if (redisTemplate.opsForSet().isMember(fileMd5, currentIndex)) {
            return currentIndex;
        }
        // 生成分片的临时路径
        String filePath = tempPath + fileMd5 + "_" + currentIndex + ".tmp";
        //保存文件分片到本地的目标路径
        File targetFile = new File(filePath);
        try {
            RandomAccessFile raf = new RandomAccessFile(targetFile, "rw");
            byte[] data = file.getBytes();
            raf.write(data);
            raf.close();
            //在redis中保存该分片的索引
            redisTemplate.opsForSet().add(fileMd5, currentIndex);
            return currentIndex;
        } catch (IOException e) {
            // 处理异常
            throw new ServiceException("文件上传失败");
        }
    }

那么,如果我们我们上传一次中间不小心刷新或者网络中断后,我们应该如何处理呢?其实在前面的时候我们已经解决了,因为我们上传检查的时候,已经返回了已经上传过的索引号,所以这一次上传的时候自动跳过即可了,在上面前端的上传区域可以看见有跳过的代码设置!

最后文件分片都上传完毕了,我们就是最后一步了,等待前端所有的上传任务执行完毕,我们执行一次发送合并指令即可了,当然在后端其实也可以做,前端就不需要发送合并指令了。

    //发起文件合并请求
    merge() {
      axios.get('/api/file/merge' + '?fileMd5=' + this.key + '&fileName=' + this.filename + '&chunkCount=' + this.fileChunks.length).then((resp) => {
        if (resp.data.flag == true) {
          this.$message({
            message: '文件上传成功',
            type: 'success'
          });
          this.percentage = 100;
          this.query();
          this.uploadRefresh=false;
        }
      })
    }

后端此处代码比较长,但是实际上也比较简单的,主要是做了合并故障的处理,和刚才前端上传故障处理的思路类似,如果出现故障,下一次合并从断点继续就好了,这一次的逻辑从前端搬到了后端来做,也是通过redis来记录。

    /**
     * 合并分片文件
     *
     * @param fileName
     * @param chunkCount
     * @return
     */
    @Override
    public String mergeTmpFiles(String fileMd5, String fileName, Integer chunkCount, String userId) throws IOException {
        //记录本次合并的字节数
        long count = 0;
        //获取分片索引号的起始地址
        int start = 0 ;
        String countKey = fileMd5+"-count";
        if (!redisTemplate.hasKey(countKey)) {
            redisTemplate.opsForHash().put(countKey, "count", "0");
        }else {
            start = Integer.parseInt(redisTemplate.opsForHash().get(countKey, "count").toString());
            start++;
        }
        //记录分片文件的总大小
        for (int i = start; i < chunkCount; i++) {
            //读取分片文件
            String filePath = tempPath + fileMd5 + "_" + i + ".tmp";
            File file = new File(filePath);
            if (!file.exists()) {
                //需要排除redis造成的异常情况
                redisTemplate.opsForSet().remove(fileMd5, i);
                log.info("缺失索引编号", i);
                throw new ServiceException("文件分片缺失");
            } else {
                count += file.length();
            }
            //使用缓冲流读取到内存中
            byte[] data = new byte[(int) file.length()];
            FileInputStream inputStream = new FileInputStream(file);
            inputStream.read(data);
            inputStream.close();
            //保存文件到文件夹中
            file = new File(endPath + fileMd5 + "." + getFileExtension(fileName));
            FileOutputStream outputStream = new FileOutputStream(file, true);
            outputStream.write(data);
            outputStream.close();
            //删除碎片文件
            File temp = new File(filePath);
            temp.delete();
            //记录合并进度
            redisTemplate.opsForHash().put(countKey, "count", i);
        }
        //记录文件保存数据
        MyFile myFile = new MyFile();
        myFile.setId(userId + DateTimeUtil.getTimeStamp());
        myFile.setFileName(fileName);
        myFile.setUser(userId);
        myFile.setFileMd5(fileMd5);
        File file = new File(endPath + fileMd5 + "." + getFileExtension(fileName));
        myFile.setFileSize(file.length());
        myFile.setTime(LocalDateTime.parse(DateTimeUtil.getDateTime(), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
        fileMapper.insert(myFile);
        //删除各类缓存数据
        redisTemplate.delete(countKey);
        redisTemplate.delete(fileMd5);
        //返回处理结果
        return fileName + "  此次合并:" + count + "字节";
    }

好啦!分片上传,断点上传,秒传等功能已经全部实现啦!合并文件或者故障处理等都已经自己在后端打断点测试过,可靠性较高。在本地跑用的是8核+24G配置,没有出过什么故障,但是上传到本人2核+2G的机器上,超过100M的文件,偶尔合并会出现故障,但是通过合并的故障处理,我们可以让前端如果合并失败的话,再次发起合并请求即可,目前还没有出现过连续合并请求两次都不成功的,而且第二次合并请求也是在断点的基础上进行的,没有白费消耗。

以上就是基于SpringBoot和Vue实现的分片上传系统的详细内容,更多关于SpringBoot Vue分片上传系统的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文