Spring Boot 2.x(十六):玩转vue文件上传

为什么使用 Vue-Simple-Uploader

最近用到了 Vue + Spring Boot 来完成文件上传的操作,踩了一些坑,对比了一些 Vue 的组件,发现了一个很好用的组件——Vue-Simple-Uploader

再说说为什么选用这个组件,对比 vue-ant-design 和 element-ui 的上传组件,它能做到更多的事情,比如:

  • 可暂停、继续上传
  • 上传队列管理,支持最大并发上传
  • 分块上传
  • 支持进度、预估剩余时间、出错自动重试、重传等操作
  • 支持“快传”,通过文件判断服务端是否已存在从而实现“快传”

由于需求中需要用到断点续传,所以选用了这个组件,下面我会从最基础的上传开始说起:

单文件上传、多文件上传、文件夹上传

Vue 代码:

 <uploader
        :options="uploadOptions1"
        :autoStart="true"
        class="uploader-app"
      >
        <uploader-unsupport></uploader-unsupport>
        <uploader-drop>
          <uploader-btn style="margin-right:20px;" :attrs="attrs">选择文件</uploader-btn>
          <uploader-btn :attrs="attrs" directory>选择文件夹</uploader-btn>
        </uploader-drop>
        <uploader-list></uploader-list>
</uploader>

该组件默认支持多文件上传,这里我们从官方 demo 中粘贴过来这段代码,然后在uploadOption1中配置上传的路径即可,其中 uploader-btn 中设置 directory 属性即可选择文件夹进行上传。

uploadOption1:

 uploadOptions1: {
        target: "//localhost:18080/api/upload/single",// 上传的接口
        testChunks: false, // 是否开启服务器分片校验
        fileParameterName: "file",// 默认的文件参数名
        headers: {},
        query() {},
        categaryMap: { // 用于限制上传的类型
          image: ["gif", "jpg", "jpeg", "png", "bmp"]
        }
}

在后台的接口的编写,我们为了方便,定义了一个 chunk 类用于接收组件默认传输的一些后面方便分块断点续传的参数:

Chunk 类

@Data
public class Chunk implements Serializable {
<span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-type">long</span> <span class="hljs-variable">serialVersionUID</span> <span class="hljs-operator">=</span> <span class="hljs-number">7073871700302406420L</span>;

<span class="hljs-keyword">private</span> Long id;
<span class="hljs-comment">/**
 * 当前文件块,从1开始
 */</span>
<span class="hljs-keyword">private</span> Integer chunkNumber;
<span class="hljs-comment">/**
 * 分块大小
 */</span>
<span class="hljs-keyword">private</span> Long chunkSize;
<span class="hljs-comment">/**
 * 当前分块大小
 */</span>
<span class="hljs-keyword">private</span> Long currentChunkSize;
<span class="hljs-comment">/**
 * 总大小
 */</span>
<span class="hljs-keyword">private</span> Long totalSize;
<span class="hljs-comment">/**
 * 文件标识
 */</span>
<span class="hljs-keyword">private</span> String identifier;
<span class="hljs-comment">/**
 * 文件名
 */</span>
<span class="hljs-keyword">private</span> String filename;
<span class="hljs-comment">/**
 * 相对路径
 */</span>
<span class="hljs-keyword">private</span> String relativePath;
<span class="hljs-comment">/**
 * 总块数
 */</span>
<span class="hljs-keyword">private</span> Integer totalChunks;
<span class="hljs-comment">/**
 * 文件类型
 */</span>
<span class="hljs-keyword">private</span> String type;

<span class="hljs-comment">/**
 * 要上传的文件
 */</span>
<span class="hljs-keyword">private</span> MultipartFile file;

}

在编写接口的时候,我们直接使用这个类作为参数去接收 vue-simple-uploader 传来的参数即可,注意这里要使用 POST 来接收哟 ~

接口方法:

    @PostMapping("single")
    public void singleUpload(Chunk chunk) {
   		     	// 获取传来的文件
        MultipartFile file = chunk.getFile();
        // 获取文件名
        String filename = chunk.getFilename();
        try {
            // 获取文件的内容
            byte[] bytes = file.getBytes();
            // SINGLE_UPLOADER 是我定义的一个路径常量,这里的意思是,如果不存在该目录,则去创建
            if (!Files.isWritable(Paths.get(SINGLE_FOLDER))) {
                Files.createDirectories(Paths.get(SINGLE_FOLDER));
            }
            // 获取上传文件的路径
            Path path = Paths.get(SINGLE_FOLDER,filename);
            // 将字节写入该文件
            Files.write(path, bytes);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

这里需要注意一点,如果文件过大的话,Spring Boot 后台会报错

org.apache.tomcat.util.http.fileupload.FileUploadBase$FileSizeLimitExceededException: The field file exceeds its maximum permitted size of 1048576 bytes.

这时需要在application.yml中配置 servlet 的最大接收文件大小 (默认大小是 1MB 和 10MB)

spring:
  servlet:
    multipart:
      max-file-size: 10MB 
      max-request-size: 100MB

下面我们启动项目,选择需要上传的文件就可以看到效果了 ~ 是不是很方便 ~ 但是同样的事情其余的组件基本上也可以做到,之所以选择这个,更多的是因为它可以支持断点分块上传,实现上传过程中断网,再次联网的话可以从断点位置开始继续秒传 ~ 下面我们来看看断点续传是怎么玩的。

断点分块续传

先说一下分块断点续传的大概原理,我们在组件可以配置分块的大小,大于该值的文件会被分割成若干块儿去上传,同时将该分块的chunkNumber保存到数据库 (Mysql or Redis,这里我选择的是Redis

组件上传的时候会携带一个identifier的参数 (这里我采用的是默认的值,你也可以通过生成 md5 的方式来重新赋值参数),将identifier作为Redis的 key,设置 hashKey 为”chunkNumber“,value 是由每次上传的chunkNumber组成的一个Set集合。

在将uploadOption中的testChunk的值设置为true之后,该组件会先发一个get请求,获取到已经上传的 chunkNumber 集合,然后在checkChunkUploadedByResponse方法中判断是否存在该片段来进行跳过,发送post请求上传分块的文件。

每次上传片段的时候,service 层返回当前的集合大小,并与参数中的 totalChunks 进行对比,如果发现相等,就返回一个状态值,来控制前端发出merge请求,将刚刚上传的分块合为一个文件,至此文件的断点分块上传就完成了。

下面是对应的代码 ~

Vue 代码:

<uploader
        :options="uploadOptions2"
        :autoStart="true"
        :files="files"
        @file-added="onFileAdded2"
        @file-success="onFileSuccess2"
        @file-progress="onFileProgress2"
        @file-error="onFileError2"
      >
        <uploader-unsupport></uploader-unsupport>
        <uploader-drop>
          <uploader-btn :attrs="attrs">分块上传</uploader-btn>
        </uploader-drop>
        <uploader-list></uploader-list>
</uploader>

校验是否上传过的代码

 uploadOptions2: {
        target: "//localhost:18080/api/upload/chunk",
        chunkSize: 1 * 1024 * 1024,
        testChunks: true,
        checkChunkUploadedByResponse: function(chunk, message) {
          let objMessage = JSON.parse(message);
          	// 获取当前的上传块的集合
          let chunkNumbers = objMessage.chunkNumbers;
          // 判断当前的块是否被该集合包含,从而判定是否需要跳过
          return (chunkNumbers || []).indexOf(chunk.offset + 1) >= 0;
        },
        headers: {},
        query() {},
        categaryMap: {
          image: ["gif", "jpg", "jpeg", "png", "bmp"],
          zip: ["zip"],
          document: ["csv"]
        }
}

上传后成功的处理,判断状态来进行 merge 操作

onFileSuccess2(rootFile, file, response, chunk) {
      let res = JSON.parse(response);
	      // 后台报错
      if (res.code == 1) {
        return;
      }
      // 需要合并
      if (res.code == 205) {
        // 发送 merge 请求,参数为 identifier 和 filename,这个要注意需要和后台的 Chunk 类中的参数名对应,否则会接收不到 ~
        const formData = new FormData();
        formData.append("identifier", file.uniqueIdentifier);
        formData.append("filename", file.name);
        merge(formData).then(response => {});
      } 
    },

判定是否存在的代码,注意这里的是 GET 请求!!!

 				   @GetMapping("chunk")
    public Map<String, Object> checkChunks(Chunk chunk) {
        return uploadService.checkChunkExits(chunk);
    }
<span class="hljs-meta">@Override</span>
<span class="hljs-keyword">public</span> Map&lt;String, Object&gt; <span class="hljs-title function_">checkChunkExits</span><span class="hljs-params">(Chunk chunk)</span> {
    Map&lt;String, Object&gt; res = <span class="hljs-keyword">new</span> <span class="hljs-title class_">HashMap</span>&lt;&gt;();
    <span class="hljs-type">String</span> <span class="hljs-variable">identifier</span> <span class="hljs-operator">=</span> chunk.getIdentifier();
    <span class="hljs-keyword">if</span> (redisDao.existsKey(identifier)) {
        Set&lt;Integer&gt; chunkNumbers = (Set&lt;Integer&gt;) redisDao.hmGet(identifier, <span class="hljs-string">"chunkNumberList"</span>);
        res.put(<span class="hljs-string">"chunkNumbers"</span>,chunkNumbers);
    }
    <span class="hljs-keyword">return</span> res;
}

保存分块,并保存数据到 Redis 的代码。这里的是 POST 请求!!!

    
    @PostMapping("chunk")    
			    public Map<String, Object> saveChunk(Chunk chunk) {
        // 这里的操作和保存单段落的基本是一致的 ~
        MultipartFile file = chunk.getFile();
        Integer chunkNumber = chunk.getChunkNumber();
        String identifier = chunk.getIdentifier();
        byte[] bytes;
        try {
            bytes = file.getBytes();
            // 这里的不同之处在于这里进行了一个保存分块时将文件名的按照 -chunkNumber 的进行保存
            Path path = Paths.get(generatePath(CHUNK_FOLDER, chunk));
            Files.write(path, bytes);
        } catch (IOException e) {
            e.printStackTrace();
        }
   			     // 这里进行的是保存到 redis,并返回集合的大小的操作
        Integer chunks = uploadService.saveChunk(chunkNumber, identifier);
        Map<String, Object> result = new HashMap<>();
        // 如果集合的大小和 totalChunks 相等,判定分块已经上传完毕,进行 merge 操作
        if (chunks.equals(chunk.getTotalChunks())) {
            result.put("message","上传成功!");
            result.put("code", 205);
        }
        return result;
    }
    <span class="hljs-comment">/**
     * 生成分块的文件路径
     */</span>
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> String <span class="hljs-title function_">generatePath</span><span class="hljs-params">(String uploadFolder, Chunk chunk)</span> {
    <span class="hljs-type">StringBuilder</span> <span class="hljs-variable">sb</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">StringBuilder</span>();
    <span class="hljs-comment">// 拼接上传的路径</span>
    sb.append(uploadFolder).append(File.separator).append(chunk.getIdentifier());
    <span class="hljs-comment">//判断uploadFolder/identifier 路径是否存在,不存在则创建</span>
    <span class="hljs-keyword">if</span> (!Files.isWritable(Paths.get(sb.toString()))) {
        <span class="hljs-keyword">try</span> {
            Files.createDirectories(Paths.get(sb.toString()));
        } <span class="hljs-keyword">catch</span> (IOException e) {
            log.error(e.getMessage(), e);
        }
    }
    <span class="hljs-comment">// 返回以 - 隔离的分块文件,后面跟的chunkNumber方便后面进行排序进行merge</span>
    <span class="hljs-keyword">return</span> sb.append(File.separator)
            .append(chunk.getFilename())
            .append(<span class="hljs-string">"-"</span>)
            .append(chunk.getChunkNumber()).toString();

}

<span class="hljs-comment">/**
 * 保存信息到Redis
 */</span>
    <span class="hljs-keyword">public</span> Integer <span class="hljs-title function_">saveChunk</span><span class="hljs-params">(Integer chunkNumber, String identifier)</span> {
    <span class="hljs-comment">// 获取目前的chunkList</span>
    Set&lt;Integer&gt; oldChunkNumber = (Set&lt;Integer&gt;) redisDao.hmGet(identifier, <span class="hljs-string">"chunkNumberList"</span>);
    <span class="hljs-comment">// 如果获取为空,则新建Set集合,并将当前分块的chunkNumber加入后存到Redis</span>
    <span class="hljs-keyword">if</span> (Objects.isNull(oldChunkNumber)) {
        Set&lt;Integer&gt; newChunkNumber = <span class="hljs-keyword">new</span> <span class="hljs-title class_">HashSet</span>&lt;&gt;();
        newChunkNumber.add(chunkNumber);
        redisDao.hmSet(identifier, <span class="hljs-string">"chunkNumberList"</span>, newChunkNumber);
        <span class="hljs-comment">// 返回集合的大小</span>
        <span class="hljs-keyword">return</span> newChunkNumber.size();
    } <span class="hljs-keyword">else</span> {
   	     <span class="hljs-comment">// 如果不为空,将当前分块的chunkNumber加到当前的chunkList中,并存入Redis</span>
        oldChunkNumber.add(chunkNumber);
        redisDao.hmSet(identifier, <span class="hljs-string">"chunkNumberList"</span>, oldChunkNumber);
        <span class="hljs-comment">// 返回集合的大小</span>
        <span class="hljs-keyword">return</span> oldChunkNumber.size();
    }

}

合并的后台代码:

    @PostMapping("merge")
    public void mergeChunks(Chunk chunk) {
        String fileName = chunk.getFilename();
        uploadService.mergeFile(fileName,CHUNK_FOLDER + File.separator + chunk.getIdentifier());
    }
    <span class="hljs-meta">@Override</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">mergeFile</span><span class="hljs-params">(String fileName, String chunkFolder)</span> {
    <span class="hljs-keyword">try</span> {
        <span class="hljs-comment">// 如果合并后的路径不存在,则新建</span>
        <span class="hljs-keyword">if</span> (!Files.isWritable(Paths.get(mergeFolder))) {
            Files.createDirectories(Paths.get(mergeFolder));
        }
        <span class="hljs-comment">// 合并的文件名</span>
        <span class="hljs-type">String</span> <span class="hljs-variable">target</span> <span class="hljs-operator">=</span> mergeFolder + File.separator + fileName;
        <span class="hljs-comment">// 创建文件</span>
        Files.createFile(Paths.get(target));
        <span class="hljs-comment">// 遍历分块的文件夹,并进行过滤和排序后以追加的方式写入到合并后的文件</span>
        Files.list(Paths.get(chunkFolder))
                 <span class="hljs-comment">//过滤带有"-"的文件</span>
                .filter(path -&gt; path.getFileName().toString().contains(<span class="hljs-string">"-"</span>))
                 <span class="hljs-comment">//按照从小到大进行排序</span>
                .sorted((o1, o2) -&gt; {
                    <span class="hljs-type">String</span> <span class="hljs-variable">p1</span> <span class="hljs-operator">=</span> o1.getFileName().toString();
                    <span class="hljs-type">String</span> <span class="hljs-variable">p2</span> <span class="hljs-operator">=</span> o2.getFileName().toString();
                    <span class="hljs-type">int</span> <span class="hljs-variable">i1</span> <span class="hljs-operator">=</span> p1.lastIndexOf(<span class="hljs-string">"-"</span>);
                    <span class="hljs-type">int</span> <span class="hljs-variable">i2</span> <span class="hljs-operator">=</span> p2.lastIndexOf(<span class="hljs-string">"-"</span>);
                    <span class="hljs-keyword">return</span> Integer.valueOf(p2.substring(i2)).compareTo(Integer.valueOf(p1.substring(i1)));
                })
                .forEach(path -&gt; {
                    <span class="hljs-keyword">try</span> {
                        <span class="hljs-comment">//以追加的形式写入文件</span>
                        Files.write(Paths.get(target), Files.readAllBytes(path), StandardOpenOption.APPEND);
                        <span class="hljs-comment">//合并后删除该块</span>
                        Files.delete(path);
                    } <span class="hljs-keyword">catch</span> (IOException e) {
                        e.printStackTrace();
                    }
                });
    } <span class="hljs-keyword">catch</span> (IOException e) {
        e.printStackTrace();
    }
}

至此,我们的断点续传就完美结束了,完整的代码我已经上传到 gayhub~,欢迎 star fork pr( 后面还会把博文也上传到 gayhub 哟)

前端:https://github.com/viyog/viboot-front

后台:https://github.com/viyog/viboot

写在后面

最近由于家庭 + 工作忙昏了头,鸽了这么久很是抱歉,从这周开始恢复更新,同时本人在准备往大数据转型,后续会出一系列的 Java 转型大数据的学习笔记,包括 Java 基础系列的深入解读和重写,同时 Spring Boot 系列还会一直保持连载,不过可能不会每周都更,我会把目前使用 Spring Boot 中遇到的问题和坑写一写,谢谢一直支持我的粉丝们