Java笔记··By/蜜汁炒酸奶

大文件分片上传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从而选择文件并上传。

&lt;template&gt;
    &lt;div&gt;
      &lt;button type=&quot;button&quot; v-on:click=&quot;selectFile()&quot; class=&quot;btn btn-white btn-default btn-round&quot;&gt;
          &lt;i class=&quot;ace-icon fa fa-upload&quot;&gt;&lt;/i&gt;{{text}}
      &lt;/button&gt;
      &lt;input class=&quot;hidden&quot; type=&quot;file&quot; ref=&quot;file&quot; v-on:change=&quot;uploadFile()&quot; v-bind:id=&quot;inputId+&#039;-input&#039;&quot;&gt;
     
    &lt;/div&gt;
&lt;/template&gt;
1
2
3
4
5
6
7
8
9

1.2.3.2 selectFile

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

    /**
     * 点击【上传】
     */
    selectFile () {
        let _this = this;
        $(&quot;#&quot; + _this.inputId + &quot;-input&quot;).trigger(&quot;click&quot;);
    },
1
2
3
4
5
6
7

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(&quot;.&quot;)+1, fileName.length).toLowerCase();
        if(!(!suffixs || JSON.stringify(suffixs) === &quot;{}&quot; || suffixs.length === 0)) {
            let validateSuffix = false;
            for(let s of suffixs) {
                if(s.toLocaleLowerCase() === suffix) {
                    validateSuffix = true;
                    break;
                }
            }
            if(!validateSuffix) {
                Toast.warning(&quot;文件格式不正确!只支持上传:&quot; + suffixs.join(&quot;,&quot;));
                $(&quot;#&quot; + _this0.inputId + &quot;-input&quot;).val(&quot;&quot;);
                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 = {
          &#039;shardIndex&#039;: shardIndex,
          &#039;shardSize&#039;: shardSize,
          &#039;shardTotal&#039;: shardTotal,
          &#039;use&#039;: _this.use,
          &#039;name&#039;: file.name,
          &#039;suffix&#039;: suffix,
          &#039;size&#039;: file.size,
          &#039;key&#039;: key62
        };

        //  3.3  传递分片参数,通过递归完成分片上传。
        _this.upload(param);
        
      },
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56

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 + &quot;/file/admin/big-upload&quot;, param).then((res)=&gt; {
              Loading.hide();
              let resp = res.data; 
              // 上传结果
                // 当前分片索引小于 分片总数,继续执行分派,反之 则表示全部上传成功。
              if(shardIndex &lt; shardTotal) {
                // 上传下一个分片
                param.shardIndex = param.shardIndex + 1;
                _this.upload(param);
              } else {
                  // 文件上传成功后的回调
                 _this.afterUpload(resp);
              }
              $(&quot;#&quot; + _this.inputId + &quot;-input&quot;).val(&quot;&quot;);
          });
        };
      },

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

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
3
4
5
6
7
8
9
10
11

1.2.4 Java

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

1.2.4.1 uploadOfMerge

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

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

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

 
        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(&quot;.&quot;)
                .append(suffix).toString(); // course\6sfSqfOwzmik4A4icMYuUe.mp4
        String localPath = new StringBuffer(path)
                .append(&quot;.&quot;)
                .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(&quot;保存文件记录开始&quot;);
        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
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50

1.2.4.2 merge

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

 private void merge(FileDto fileDto) {
        log.info(&quot;合并分片开始&quot;);
        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 &lt; shardTotal; i++) {
                // 读取第一个分片
                inputStream = new FileInputStream(new File(FILE_PATH + path + &quot;.&quot; + (i+1))); // course\6sfSqfOwzmik4A4icMYuUe.mp4.1
                while ((len = inputStream.read(byt))!=-1) {
                    outputStream.write(byt, 0, len);
                }
            }
        } catch (FileNotFoundException e) {
            log.info(&quot;文件寻找异常&quot;, e);
        } catch (IOException e) {
            log.info(&quot;分片合并异常&quot;, e);
        } finally {
            try {
                if(inputStream !=null) {
                    inputStream.close();
                }
                log.info(&quot;IO流关闭&quot;);
            } catch (IOException e) {
                log.error(&quot;IO流关闭&quot;, e);
            }

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

        System.gc();
        // 删除分片
        log.info(&quot;删除分片开始&quot;);
        for (int i = 0; i &lt; shardTotal; i++) {
            String filePath = FILE_PATH + path + &quot;.&quot; + (i + 1);
            File file = new File(filePath);
            boolean result = file.delete();
            log.info(&quot;删除{},{}&quot;, filePath, result ? &quot;成功&quot; : &quot;失败&quot;);
        }
        log.info(&quot;删除分片结束&quot;);
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47

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 + &quot;/file/admin/check/&quot; + param.key).then((res)=&gt; {
            let resp = res.data;
            if(resp.success) {
              let obj = resp.content;
              if(!obj) {
                param.shardIndex = 1;
                console.log(&quot;没有找到文件记录,从分片1开始上传&quot;);
                _this.upload(param);
              } else if (obj.shardIndex === obj.shardTotal) {
                // 已上传分片 = 分片总数,说明已全部上传完,不需要再上传
                Toast.success(&quot;文件极速秒传成功!&quot;);
                _this.afterUpload(resp);  
                $(&quot;#&quot; + _this.inputId + &quot;-input&quot;).val(&quot;&quot;);  
              }else {
                param.shardIndex = obj.shardIndex + 1;
                console.log(&quot;没有找到文件记录,从分片1开始上传&quot;);
                _this.upload(param);
              }
            } else {
              console.log(&quot;文件上传失败&quot;);
              $(&quot;#&quot; + _this.inputId + &quot;-input&quot;).val(&quot;&quot;);
            }
        });
      },
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

2.1 Java

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

    @GetMapping(&quot;/check/{key}&quot;)
    public ResponseDto check(@PathVariable String key) {
        log.info(&quot;检测上传分片开始:{}}&quot;, key);
        ResponseDto responseDto = new ResponseDto();
        FileDto fileDto = fileService.findByKey(key);
        responseDto.setContent(fileDto);
        return responseDto;
    }
1
2
3
4
5
6
7
8

3. 扩展

3.1 readAsDataURL

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

FileReader.readAsDataURL

3.2 小结

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

4 相关代码

时不时有朋友留言要源码,之前在评论区做了回复,但今天对相关git库做了归档整理,也涉及到该项目的,故评论区的链接已失效。这里重新指引一下。

# 后端起始Controller:
https://gitee.com/windcoder/qydemo-java/blob/master/qycms-2022/imoocDemo/cloudCourseDemo/file/src/main/java/com/windcoder/cloudCourseDemo/file/controller/admin/UploadController.java
	- 里面的 `@PostMapping("/big-upload")` 注解的即为大文件上传部分。 	
	- `merge` 方法为最终合并逻辑。
# 前端上传组件:
https://gitee.com/windcoder/qydemo-java/blob/master/qycms-2022/imoocDemo/cloudCourseDemo/admin/src/components/big-file.vue
1
2
3
4
5
6

本身cloudCourseDemo 为一个完整的前后端分离项目源码,其中:

  • admin :管理端web项目
  • web:浏览端web项目
  • 其余的为基于 eureka + gateway 的 springcloud 项目中的多个微服务项目。
预览

相关资源

文中提到的源代码等相关资源
Loading comments...
7 条评论
  • W

    请问,20M的文件片,java端是用什么方式接收这个参数的?

    • W

      回复 @yansy: 看当时的代码目前是通过base64传递的,您可以根据实际情况自己选择技术。 String shardBase64 = fileDto.getShard(); MultipartFile shard = Base64ToMultipartFile.base64ToMultipart(fileDto.getShard());

    • W

      回复 @yansy: java方法uploadOfMerge中通过参数 fileDto 接收前端参数,具体步骤: 1. 前端在uploadFile()的最后有创建所需传递的参数值, 2.之后通过 upload(param) 方法不断上传文件的分片信息到后端uploadOfMerge方法对应的api 3. java端通过uploadOfMerge不断获取最新数据并在最后合并所有分片

  • W

    请问博主可以把文件传给我吗?

    • W

      回复 @11: 您好,您说的这些都在那链接的git库中,这文章只是git库里面大文件上传功能的实现说明。

    • W

      回复 @蜜汁炒酸奶: 作者可以把一些像dto和service文件也上传下

    • W

      回复 @张: 不好意思啊,最近忙着改造,没来得及看,文件可见 https://gitee.com/windcoder/imoocDemo/blob/master/cloudCourseDemo/file/src/main/java/com/windcoder/cloudCourseDemo/file/controller/admin/UploadController.java

example
预览