Java笔记 ·

大文件分片上传Java版简单实现

本文用于整理记录大文件分片上传、断点续传、极速秒传的Java版简单实现。

关于上传的文章

FTP文件上传下载

1. 分片上传

分片上传的核心思路:

  • 1.将文件按一定的分割规则(静态或动态设定,如手动设置20M为一个分片),用slice分割成多个数据块。
  • 2.为每个文件生成一个唯一标识Key,用于多数据块上传时区分所属文件。
  • 3.所有分片上传完成,服务端校验合并标识为Key的所有分片为一个最终文件。

分片上传到意义:

  • 将文件分片上传,在网络环境不佳时,可以对文件上传失败的部分重新上传,避免了每次上传都需要从文件起始位置上传到问题。
  • 分片的附带好处还能很方便的实现进度条。

    1.2 实例

    本代码基于Vue + SpringBoot 简单演示,篇幅有限仅放出关键代码,完整代码可在文章最后获取。

该实例是一个串行上传分片数据的实例,一个文件仅在数据库中保存了一条记录,每次上传一个分片时更新一次该记录,直到该文件到所有分片上传完成。

1.2.3 Vue

1.2.3.1 template

模板部分包含一个“上传”Button ,和一个隐藏的 <input type="file">

点击 Button 触发 input从而选择文件并上传。

<template>
    <div>
      <button type="button" v-on:click="selectFile()" class="btn btn-white btn-default btn-round">
          <i class="ace-icon fa fa-upload"></i>{{text}}
      </button>
      <input class="hidden" type="file" ref="file" v-on:change="uploadFile()" v-bind:id="inputId+'-input'">

    </div>
</template>

1.2.3.2 selectFile

点击 Button 【上传】,触发 隐藏 input 的点击事件,选择文件。

    /**
     * 点击【上传】
     */
    selectFile () {
        let _this = this;
        $("#" + _this.inputId + "-input").trigger("click");
    },

1.2.3.3 uploadFile

检测到选择好文件,input 执行该方法,完成文件上传。

     /**
       * 上传文件
       */
      uploadFile() {
        let _this = this; 
        // 1. 获取 input 中被选中的文件
        let file = _this.$refs.file.files[0];


        // 2. 生成文件标识,标识多次上传的是不是同一个文件
         let key = hex_md5(file.name + file.size + file.type);
        let key10 = parseInt(key, 16);
        let key62 = Tool._10to62(key10);

        // 判断文件格式 (非必选,根据实际情况选择是否需要限制文件上传类型)
        let suffixs = _this.suffixs;
        let fileName = file.name;
        let suffix = fileName.substring(fileName.lastIndexOf(".")+1, fileName.length).toLowerCase();
        if(!(!suffixs || JSON.stringify(suffixs) === "{}" || suffixs.length === 0)) {
            let validateSuffix = false;
            for(let s of suffixs) {
                if(s.toLocaleLowerCase() === suffix) {
                    validateSuffix = true;
                    break;
                }
            }
            if(!validateSuffix) {
                Toast.warning("文件格式不正确!只支持上传:" + suffixs.join(","));
                $("#" + _this0.inputId + "-input").val("");
                return;
            }
        }

        // 3. 文件分片开始
            // 3.1 设置与计算分片必选参数
        let shardSize = 20 * 1024 *1024; // 20M为一个分片
        let shardIndex = 1;   // 分片索引,1表示第1个分片
        let size = file.size; // 文件的总大小
        let shardTotal = Math.ceil(size / shardSize); // 总分片数

            //  3.2 拼接将要传递到参数, use 非必选,这里用来标识文件用途。
        let param = {
          'shardIndex': shardIndex,
          'shardSize': shardSize,
          'shardTotal': shardTotal,
          'use': _this.use,
          'name': file.name,
          'suffix': suffix,
          'size': file.size,
          'key': key62
        };

        //  3.3  传递分片参数,通过递归完成分片上传。
        _this.upload(param);

      },

1.2.3.4 upload

递归上传分片的过程

      /**
       * 递归上传分片
       */
      upload(param) {
        let _this = this;
        let shardIndex = param.shardIndex;
        let shardTotal = param.shardTotal;
        let shardSize = param.shardSize;
        // 3.3.1 根据参数,获取文件分片
        let fileShard = _this.getFileShard(shardIndex,shardSize);


        // 3.3.2 将文件分片转为base64进行传输
        let fileReader = new FileReader();
       // 读取并转化 fileShard 为 base64
        fileReader.readAsDataURL(fileShard);
        //  readAsDataURL 读取后的回调,
            // 将 经过  base64 编码的  分片 整合到 param ,发送给后端,从而上传分片。
        fileReader.onload = function (e) {
          let base64 = e.target.result;
          param.shard = base64;
          Loading.show();
          _this.$ajax.post(process.env.VUE_APP_SERVER + "/file/admin/big-upload", param).then((res)=> {
              Loading.hide();
              let resp = res.data; 
              // 上传结果
                // 当前分片索引小于 分片总数,继续执行分派,反之 则表示全部上传成功。
              if(shardIndex < shardTotal) {
                // 上传下一个分片
                param.shardIndex = param.shardIndex + 1;
                _this.upload(param);
              } else {
                  // 文件上传成功后的回调
                 _this.afterUpload(resp);
              }
              $("#" + _this.inputId + "-input").val("");
          });
        };
      },

1.2.3.5 getFileShard

1.2.3.4 upload 中根据传参,使用slice进行文件分片的函数。

       /**
        * 文件分片函数
        */
      getFileShard(shardIndex, shardSize) {
        let _this = this;
        let file = _this.$refs.file.files[0];
        let start = (shardIndex - 1) * shardSize; // 当前分片起始位置
        let end = Math.min(file.size, start + shardSize); // 当前分片结束位置
        let fileShard = file.slice(start, end); // 从文件中截取当前的分片数据
        return fileShard;
      },

1.2.4 Java

该部分由上传api函数和合并函数组成。

1.2.4.1 uploadOfMerge

文件上传的api函数,前端将分片数据通过该api上传并保存到对应目录下,当全部分片上传成功,将所有分片合并成文件,同时将相关信息保存到数据库。

合并部分可以考虑通过定时任务、MQ等方式优化。

    @PostMapping("/big-upload")
    public ResponseDto uploadOfMerge(@RequestBody FileDto fileDto) throws IOException {
        log.info("上传文件开始");


        String use = fileDto.getUse();
        String key = fileDto.getKey();
        String suffix = fileDto.getSuffix();
        String shardBase64 = fileDto.getShard();
        // 1. 将分片转为 MultipartFile
        MultipartFile shard = Base64ToMultipartFile.base64ToMultipart(fileDto.getShard());
        //  获取分片要保存到的路径
        //  根据use字段获取文件用途,从而上传到不同文件夹下(非必选)
        FileUseEnum useEnum = FileUseEnum.getByCode(use);
            // 若文件夹不存在则创建
        String dir = useEnum.name().toLowerCase();
        File fullDir = new File(FILE_PATH + dir);
        if (!fullDir.exists()) {
            fullDir.mkdir();
        }

        String path = new StringBuffer(dir)
                .append(File.separator)
                .append(key)
                .append(".")
                .append(suffix).toString(); // course\6sfSqfOwzmik4A4icMYuUe.mp4
        String localPath = new StringBuffer(path)
                .append(".")
                .append(fileDto.getShardIndex()).toString(); // course\6sfSqfOwzmik4A4icMYuUe.mp4.1
        String fullPath = FILE_PATH + localPath;
        // 2. 通过 transferTo 保存文件到服务器磁盘
        File dest = new File(fullPath);
        shard.transferTo(dest);
        log.info(dest.getAbsolutePath());
        // 3. 将文件分片信息保存/更新到数据库
        log.info("保存文件记录开始");
        fileDto.setPath(path);
        fileService.saveBigFile(fileDto);

        ResponseDto responseDto = new ResponseDto();
        responseDto.setContent(fileDto);

        // 4. 合并
            // 若分片均已上传,将所有分片合并成一个文件。
        if (fileDto.getShardIndex().equals(fileDto.getShardTotal())) {
            this.merge(fileDto);
        }
        // 5. 返回分片上传结果
        return responseDto;
    }

1.2.4.2 merge

文件所有分片上传完成后到合并操作,合并完成后删除文件的所有分片。

 private void merge(FileDto fileDto) {
        log.info("合并分片开始");
        String path = fileDto.getPath();
        Integer shardTotal = fileDto.getShardTotal();
        File newFile = new File(FILE_PATH + path);
        byte[] byt = new byte[10 * 1024 * 1024];
        FileInputStream inputStream = null;   // 分片文件
        int len;

        // 文件追加写入
        try (FileOutputStream outputStream = new FileOutputStream(newFile, true);
              ) {
            for (int i = 0; i < shardTotal; i++) {
                // 读取第一个分片
                inputStream = new FileInputStream(new File(FILE_PATH + path + "." + (i+1))); // course\6sfSqfOwzmik4A4icMYuUe.mp4.1
                while ((len = inputStream.read(byt))!=-1) {
                    outputStream.write(byt, 0, len);
                }
            }
        } catch (FileNotFoundException e) {
            log.info("文件寻找异常", e);
        } catch (IOException e) {
            log.info("分片合并异常", e);
        } finally {
            try {
                if(inputStream !=null) {
                    inputStream.close();
                }
                log.info("IO流关闭");
            } catch (IOException e) {
                log.error("IO流关闭", e);
            }

        }
        log.error("合并分片结束");

        System.gc();
        // 删除分片
        log.info("删除分片开始");
        for (int i = 0; i < shardTotal; i++) {
            String filePath = FILE_PATH + path + "." + (i + 1);
            File file = new File(filePath);
            boolean result = file.delete();
            log.info("删除{},{}", filePath, result ? "成功" : "失败");
        }
        log.info("删除分片结束");
    }

2. 断点续传/极速秒传

断点续传基于分片上传实现,使之前未上传完成到文件可以从上次上传完成的Part的位置继续上传。

断点续传实现了,也就间接实现了 极速秒传功能,通过 唯一key 检测文件上传进度,发现之前已经上传完成,便可返回给用户 “极速秒传” 成功的消息,而不需要将该文件再次上传一次。至于文件及其数据库信息是否需要内部拷贝,则看项目需求即可。

2.1 Vue

1.2.3.3 uploadFile 中的 _this.upload(param); 被 检测已上传分片的函数 _this.check(param); 取代。

upload 上传函数由 check 调用。

check(param) {
        let _this = this;
        _this.$ajax.get(process.env.VUE_APP_SERVER + "/file/admin/check/" + param.key).then((res)=> {
            let resp = res.data;
            if(resp.success) {
              let obj = resp.content;
              if(!obj) {
                param.shardIndex = 1;
                console.log("没有找到文件记录,从分片1开始上传");
                _this.upload(param);
              } else if (obj.shardIndex === obj.shardTotal) {
                // 已上传分片 = 分片总数,说明已全部上传完,不需要再上传
                Toast.success("文件极速秒传成功!");
                _this.afterUpload(resp);  
                $("#" + _this.inputId + "-input").val("");  
              }else {
                param.shardIndex = obj.shardIndex + 1;
                console.log("没有找到文件记录,从分片1开始上传");
                _this.upload(param);
              }
            } else {
              console.log("文件上传失败");
              $("#" + _this.inputId + "-input").val("");
            }
        });
      },

2.1 Java

Java 增加了一个检测文件分片上传情况到api。

    @GetMapping("/check/{key}")
    public ResponseDto check(@PathVariable String key) {
        log.info("检测上传分片开始:{}}", key);
        ResponseDto responseDto = new ResponseDto();
        FileDto fileDto = fileService.findByKey(key);
        responseDto.setContent(fileDto);
        return responseDto;
    }

3. 扩展

3.1 readAsDataURL

readAsDataURL 方法会读取指定的 Blob 或 File 对象。读取操作完成的时候,readyState 会变成已完成DONE,并触发 loadend 事件,同时 result 属性将包含一个data:URL格式的字符串(base64编码)以表示所读取文件的内容。

FileReader.readAsDataURL

3.2 小结

本文主要参考课程 《Spring Cloud + Vue 前后端分离 开发企业级在线视频课程系统》 中相关章节整理实现,示例本身挺基础,可供优化点很多,这里暂且不做扩展,原理了解之后,大家可自行扩展到并行上传分片、消息队列合并文件/删除分片等,应该不会太难,另外分片上传和分片下载比较类似,也可自行考虑实现。

相关下载

点击下载

参与评论