spring boot 、vue-simple-uploader 实现分片、断点上传

1,456 阅读3分钟

前言

​ 公司最新有个需求需要上传大文件,需要支持分片及断点上传,需要有上传、暂停、取消等功能,且支持关闭浏览器后下次打开继续上传。本片文章记录我使用vue-simple-uploader的过程。

环境

  • vue 2.6.10
  • vue-simple-upload 0.7.6
  • springboot 2.5.x
  • mysql5.x
  • mybatis plus 3.4.1

vue-simple-upload 是什么?

vue-simple-uploader是基于 simple-uploader.js 封装的vue上传插件。它的优点包括且不限于以下几种:

  • 支持文件、多文件、文件夹上传;支持拖拽文件、文件夹上传
  • 可暂停、继续上传
  • 错误处理
  • 支持“秒传”,通过文件判断服务端是否已存在从而实现“秒传”
  • 分块上传
  • 支持进度、预估剩余时间、出错自动重试、重传等操作

vue-simple-uploader文档

simple-uploader.js文档

安装

我开发使用的管理工具是yarn,npm同理。

yarn add vue-simple-uploader

使用

初始化
import Vue from 'vue'
import App from './App.vue'
import uploader from 'vue-simple-uploader'
Vue.use(uploader)

new Vue({
    render: h => h(App)
}).$mount('#app')
封装全局上传组件
<template>
  <div>
    <p style="font-size: 18px">上传</p>
    <uploader
      ref="uploader"
      :options="options"
      :autoStart=false
      :file-status-text="fileStatusText"
      @file-added="onFileAdded"
      @file-success="onFileSuccess"
      @file-progress="onFileProgress"
      @file-error="onFileError"
      class="uploader-ui">
      <uploader-unsupport></uploader-unsupport>
      <uploader-drop>
        <div>
          <uploader-btn id="global-uploader-btn" :attrs="attrs" ref="uploadBtn">选择文件<i
            class="el-icon-upload el-icon--right"></i></uploader-btn>
        </div>
      </uploader-drop>
      <uploader-list></uploader-list>
    </uploader>
  </div>
</template>

<script>
import SparkMD5 from 'spark-md5';
import {mergeFile} from '@/utils/upload/multipartUpload';

export default {
  name: 'MultipartUpload',
  props: {
    projectNo: {
      type: String,
      default: ''
    },
  },
  data() {
    return {
      options: {
        target: process.env.VUE_APP_API_BASE_URL + "/cms/splitupuload/chunk",//校验分片
        chunkSize: '2048000',
        fileParameterName: 'upfile',
        maxChunkRetries: 3,
        testChunks: true,
        checkChunkUploadedByResponse: function (chunk, response_msg) {
          let objMessage = JSON.parse(response_msg);
          if (objMessage.skipUpload) {
            return true;
          }
          return (objMessage.uploadedChunks || []).indexOf(chunk.offset + 1) >= 0;
        }
      },
      attrs: {
        accept: ['.mp4', '.rmvb', '.mkv', '.wmv', '.flv']
      },
      fileStatusText: {
        success: '上传成功',
        error: '上传失败',
        uploading: '上传中',
        paused: '暂停',
        waiting: '等待上传'
      },
    }
  },
  methods: {
    onFileAdded(file) {
      this.computeMD5(file);
    },
    onFileSuccess(rootFile, file, response, chunk) {
      file.refProjectId = this.projectNo;
      mergeFile(file).then(responseData => {
        console.log("文件上传", responseData)
      }).catch(function (error) {
        console.log("文件上传异常", error);
      });
    },
    onFileError(rootFile, file, response, chunk) {
      console.log('文件上传失败:' + response);
    },
    onFileProgress(rootFile, file, chunk) {
      //上传进度
    },
    computeMD5(file) {
      file.pause();
      let fileReader = new FileReader();
      let time = new Date().getTime();
      let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
      let currentChunk = 0;
      const chunkSize = 10 * 1024 * 1000;
      let chunks = Math.ceil(file.size / chunkSize);
      let spark = new SparkMD5.ArrayBuffer();
      let chunkNumberMD5 = 1;
      loadNext();
      fileReader.onload = (e => {
        spark.append(e.target.result);
        if (currentChunk < chunkNumberMD5) {
          loadNext();
        } else {
          let md5 = spark.end();
          file.uniqueIdentifier = md5;
          file.resume();
          console.log(`MD5计算完毕:${file.name} \nMD5:${md5} \n分片:${chunks} 大小:${file.size} 用时:${new Date().getTime() - time} ms`);
        }
      });

      fileReader.onerror = function () {
        this.error(`文件${file.name}读取出错,请检查该文件`)
        file.cancel();
      };

      function loadNext() {
        let start = currentChunk * chunkSize;
        let end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;

        fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end));
        currentChunk++;
      }
    },
    close() {
      this.uploader.cancel();
    },
    error(msg) {
      this.$message.error(msg, 2000)
    }
  }
}</script>

<style>
.uploader-ui {
  padding: 15px;
  font-size: 12px;
  font-family: Microsoft YaHei;
  box-shadow: 0 0 10px rgba(0, 0, 0, .4);
}

.uploader-ui .uploader-btn {
  margin-right: 4px;
  font-size: 12px;
  border-radius: 3px;
  color: #FFF;
  background-color: #409EFF;
  border-color: #409EFF;
  display: inline-block;
  line-height: 1;
  white-space: nowrap;
}

.uploader-ui .uploader-list {
  max-height: 440px;
  overflow: auto;
  overflow-x: hidden;
  overflow-y: auto;
}
</style>
上传组件使用
......
<MultipartUpload :projectNo="projectNo"></MultipartUpload>
.....
export default {
  name: 'UploadMovie',
  components: {MultipartUpload},
  data() {
    return {
		projectNo:'UP000000001'
	}
  }
}
..........

后端

mysql数据库脚本
CREATE TABLE `t_chunk_info` (
  `id` varchar(64) NOT NULL COMMENT '主键',
  `chunk_number` bigint(20) NOT NULL COMMENT '分片编号',
  `chunk_size` bigint(20) NOT NULL COMMENT '分片大小',
  `current_chunkSize` bigint(20) DEFAULT NULL COMMENT '校验大小',
  `identifier` varchar(64) NOT NULL COMMENT '标记',
  `filename` varchar(500) DEFAULT NULL COMMENT '文件名',
  `relative_path` varchar(500) NOT NULL COMMENT '文件路径',
  `total_chunks` bigint(20) NOT NULL COMMENT '校验分片数量',
  `type` bigint(20) DEFAULT NULL COMMENT '类型'
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT;

CREATE TABLE `t_file_info` (
  `id` varchar(64) NOT NULL COMMENT '主键',
  `filename` varchar(500) NOT NULL COMMENT '文件名称',
  `identifier` varchar(64) NOT NULL COMMENT '标识',
  `type` varchar(10) DEFAULT NULL COMMENT '类型',
  `total_size` bigint(20) NOT NULL COMMENT '文件大小',
  `location` varchar(200) NOT NULL COMMENT '路径',
  `del_flag` varchar(2) NOT NULL DEFAULT '0' COMMENT '删除标记',
  `ref_project_id` varchar(64) NOT NULL COMMENT '来源',
  `upload_by` varchar(64) DEFAULT NULL COMMENT '上传人',
  `upload_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
服务接口
//分片上传
@PostMapping("/chunk")
public String uploadChunk(SplitChunkInfoVO chunk) {
    HttpStatus httpStatus = HttpStatus.UNSUPPORTED_MEDIA_TYPE;
    MultipartFile file = chunk.getUpfile();
    log.info("file originName: {}, chunkNumber: {}", file.getOriginalFilename(), chunk.getChunkNumber());
    try {
        byte[] bytes = file.getBytes();
        Path path = Paths.get(FileInfoUtils.generatePath(uploadFolder, chunk));
        Files.write(path, bytes);
        SplitChunkInfo splitChunkInfo = BeanUtils.copyBeanNoException(chunk, SplitChunkInfo.class);
        splitChunkInfo.setId(IdUtil.fastSimpleUUID());
        if (this.splitChunkInfoService.save(splitChunkInfo)) {
            httpStatus = HttpStatus.OK;
        }
    } catch (IOException e) {
        log.error("上传分片信息失败");
    }
    return String.valueOf(httpStatus.value());
}
//分片校验
@GetMapping("/chunk")
public UploadResult checkChunk(SplitChunkInfoVO chunk, HttpServletResponse response) {
    UploadResult ur = new UploadResult();
    response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
    String file = uploadFolder + "/" + chunk.getIdentifier() + "/" + chunk.getFilename();
    //先判断整个文件是否已经上传过了,如果是,则告诉前端跳过上传,实现秒传
    if (FileInfoUtils.fileExists(file)) {
        ur.setSkipUpload(true);
        ur.setLocation(file);
        response.setStatus(HttpServletResponse.SC_OK);
        ur.setMessage("完整文件已存在,直接跳过上传,实现秒传");
        return ur;
    }
    List<Integer> list = this.splitChunkInfoService.list(new LambdaQueryWrapper<SplitChunkInfo>()
            .eq(SplitChunkInfo::getIdentifier, chunk.getIdentifier())
            .eq(SplitChunkInfo::getFilename, chunk.getFilename())).stream().map(x -> x.getChunkNumber().intValue()).collect(Collectors.toList());
    if (list.size() > 0) {
        ur.setSkipUpload(false);
        ur.setUploadedChunks(list);
        response.setStatus(HttpServletResponse.SC_OK);
        ur.setMessage("部分文件块已存在,继续上传剩余文件块,实现断点续传");
        return ur;
    }
    return ur;
}
//合并分片
@PostMapping("/mergeFile")
public String mergeFile(@RequestBody SplitFileInfoVO fileInfoVO) {
    SplitFileInfo fileInfo = new SplitFileInfo();
    fileInfo.setFilename(fileInfoVO.getName());
    fileInfo.setIdentifier(fileInfoVO.getUniqueIdentifier());
    fileInfo.setId(fileInfoVO.getId());
    fileInfo.setTotalSize(fileInfoVO.getSize());
    fileInfo.setRefProjectId(fileInfoVO.getRefProjectId());
    String filename = fileInfo.getFilename();
    String file = uploadFolder + "/" + fileInfo.getIdentifier() + "/" + filename;
    String folder = uploadFolder + "/" + fileInfo.getIdentifier();
    String fileSuccess = FileInfoUtils.merge(file, folder, filename);
    fileInfo.setLocation(file);
    //文件合并成功后,保存记录至数据库
    if (SplitUploadConstants.MERGE_FILE_OK.equals(fileSuccess)) {
        fileInfo.setId(IdUtil.fastSimpleUUID());
        if (this.splitFileInfoService.save(fileInfo)) {
            
            return SplitUploadConstants.MERGE_FILE_SUCCESS;
        }
    } else if (SplitUploadConstants.MERGE_FILE_REPEAT.equals(fileSuccess)) {
        String fileId = null;
        SplitFileInfo splitFileInfo = this.splitFileInfoService.getOne(new LambdaQueryWrapper<SplitFileInfo>()
                .eq(SplitFileInfo::getFilename, fileInfo.getFilename())
                .eq(SplitFileInfo::getIdentifier, fileInfo.getIdentifier())
                .last("limit 1")
        );
        if (Objects.isNull(splitFileInfo) ||
                (!fileInfo.getRefProjectId().equals(splitFileInfo.getRefProjectId()))) {
            fileInfo.setId(IdUtil.fastSimpleUUID());
            JcBootException.isTrue(this.splitFileInfoService.save(fileInfo), "文件上传失败!");
            fileId = fileInfo.getId();
        } else {
            fileId = splitFileInfo.getId();

        }
      
        return SplitUploadConstants.MERGE_FILE_SUCCESS;
    }
    return SplitUploadConstants.MERGE_FILE_FALURE;
}

总结

以上就是本次文件分片上传的全部内容了,只是简单记录了一下使用过程,细节没有赘述,大致的方式方法都一样,大家有什么更好的上传组件或者意见欢迎讨论。

参考

基于vue-simple-uploader封装文件分片上传、秒传及断点续传的全局上传插件