Spring Boot+VUE分片上传大文件到OSS服务器解决方案

根据项目需要,在很多地方都需要将超大文件上传到服务器,特别是将视频文件上传到云平台的OSS服务器上,这种需求在项目中已经是十分普遍的需求了。在网上收集了很多资料,基本上都只有JAVA到OSS服务器,或都VUE前端服务端到Spring Boot后端服务器。当前前后端开发已经十分普及了,大文件一般都保存到OSS服务器,不会保存到自己的业务服务器,OSS大文件服务器+tomcat为业务服务器+VUE/Ract/小程序前端服务器的三层后台架构是当前最主流的架构设计,因此必须解决这个问题。
通过几天的反复研究和实践,结合网上找到的资料,需要将VUE服务端到Spring Boot服务器和JAVA到OSS服务器两部分结合起来,形成整体的解决方案。
在这个整体中有两上难点:
1、VUE前端服务器的JS程序是异步的,而大文件分片传输需要大量分次请求,请求执行完所顺序是不一致的;
2、由于是三层请求,涉及到多用户,Spring Boot服务器需要将分原来整体功能,撤分为三个,存在中间缓存信息持久化保存与多用户隔离问题

一、解决方案框架

由于这个功能还是非常复杂,为了解决这个问题,还是绘制了功能框架图,并且在具体编程时,对功能框架图进行了多次修正,最终框架如下图所示。
在这里插入图片描述
1、 框架整体上是前端判断是否大文件,超过10M,如果向Spring Boot服务器,请求大文件传输,Spring Boot向OSS服务器申请创新大文件传递的uploadID.
2、前端对文件进行分片,并产生分片MD5码,根据第1得到的id发起分片传输,Spring Boot收到分片后,上传分片
3、Spring boot在redis中缓存存文件ID,分片信息,以备文件合成用
4、前端检索返回MD5,如果有错,重新传递分片
5、前端检查分片是否全部正确传输完成,如果传递完成,发起文件合并请求
6、Spring boot将缓存信息组合,向OSS提出文件合并请求
7、OSS服务器收到相关信息,检验后合成文件并返回文件访问地址
8、Spring boot将访问地址及相关信息保存到服务器,并清除缓存
9、前端收到信息,显示进展,请除缓存存
10、根据地址使用已经上传的文件,如播放或预览。

二、Spring Boot后台程序

后台程序分为Controller和utils两部分,controller与前端交互,utils与OSS交互。

2.1、前端与用户交互的Controller

由于前后端分离,需要将大文件上传分为三个部分,即大文件上传初始化、上传大文件块、合并已上传文件,分别与VUE前端进行交互。

/**
 * <p>
 * 前端控制器
 * </p>
 *
 * @author wu jize
 * @since 2020-07-05
 */
@RestController
@RequestMapping("/map/scenic/file")
public class MapBigFileController extends BaseController {
    @Autowired
    private IMapScenicFileService iMapScenicFileService;

    @Autowired
    IMapScenicService iMapScenicService;

    @Autowired
    private TokenService tokenService;

    @Autowired
    private BigFileUploadUtils bigFileUploadUtils;

    /**
     * 大文件上传初始化
     * 返回文件uploadId
     */
    @ApiOperation("大文件上传初始化")
    @PreAuthorize("@ss.hasPermi('data:scenicfile:edit')")
    @Log(title = "大文件上传初始化", businessType = BusinessType.UPDATE)
    @PostMapping("/initUpload")
    public AjaxResult initBigFileUpload(@RequestParam(name = "fileName", required = true) String fileName,
                                        @RequestParam(name = "scenicId", required = false) Long scenicId) {
        //格式化文件路径,按县、景区ID、用户名组织文件
        String adcode = iMapScenicService.selectScenicById(scenicId).getAdcode();
        if (StringUtils.isEmpty(adcode)) {
            throw new CustomException("景区行政区域编码为空,不能添加景区多媒体文件!");
        }
        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
        String author = loginUser.getUsername();
        String objectName = "scenicfile" + "/" + adcode + "/" + scenicId.toString() + "/" + author + '/' + fileName;
        String uploadId = bigFileUploadUtils.initUpload(objectName);

        AjaxResult ajax = AjaxResult.success();
        ajax.put("uploadId", uploadId);
        return ajax;
    }

    /**
     * 上传大文件的Chunk
     * 返回chunk的MD5
     */
    @ApiOperation("上传大文件的Chunk")
    @PreAuthorize("@ss.hasPermi('data:scenicfile:edit')")
    @Log(title = "上传大文件的Chunk", businessType = BusinessType.UPDATE)
    @PostMapping("/uploadChunk")
    public AjaxResult uploadChunk(@RequestParam("chunkFile") MultipartFile chunkFile,
                                  @RequestParam(name = "uploadId", required = true) String uploadId,
                                  @RequestParam(name = "chunkId", required = true) Integer chunkId,
                                  @RequestParam(name = "total", required = true) Integer total) throws IOException {

        String md5Str = bigFileUploadUtils.uploadChunk(uploadId, chunkId, chunkFile);
        AjaxResult ajax = AjaxResult.success();

        ajax.put("md5Str", md5Str);
        return ajax;
    }

    /**
     * 大文件上传完成后合并
     * 返回文件访问的URL
     */
    @ApiOperation("大文件上传完成后合并")
    @PreAuthorize("@ss.hasPermi('data:scenicfile:edit')")
    @Log(title = "大文件上传完成后合并", businessType = BusinessType.UPDATE)
    @PostMapping("/mergeFile")
    public AjaxResult mergeFile(@RequestParam(name = "uploadId", required = true) String uploadId) {
        AjaxResult ajax = AjaxResult.success();
        String url = bigFileUploadUtils.completeFile(uploadId);
        ajax.put("url", url);
        return ajax;
    }
}

2.2、util包,负责与OSS交互。
在后端与OSS交互中,有一个难点是解决大文件前后端分离后,异步数据上传中该文件相关信息的保存。由于是异步请求,方法分为三段执行,不同请求对于Spring Boot来说就是不同的线程,试了以下几种方法,最后用redis缓存存:
1、用包类静态全局变量,由是不同请求会产生不同线程,此方法行不通
2、用线程变量,静态全局变量不行就考虑过用线程变量,功能实现后现还是不行,进一步研究,发现前端不同请求在后端仍然在不同线程,经如初始化大文件上传的uploadId和objectId,在后面请求访问不到。
3、用session变量,研究了一下,发现更加复杂,还不一定能解决得好并好用户,同一用户不同窗口上传问题
4、反馈给前端,uploadId和objectId还行,但是分片的partETag信息太多,处理起来会十分繁琐
5、用数据库临时表进行持久化,也挺复杂,并且还要引入JPA,如用mybatis工作也不少
6、经过多方比较,还是用redis,简单并且速度快,唯一缺点时redis服务器重启后,没有上传完的片断会变成数据垃圾,还没搞清楚阿里OSS怎么处理,不知道要不要占自己的存储容量。Redis根据uploadId保存信息,不同用户,即使同一用户同时上传不同文件其uploadId是唯一的,这样能做好多用户相互隔离问题。

/**
 * 用于超过10M的大文件分片上传
 *
 * @author Wu Jize
 * @version 1.0
 * @date 2020/7/5 18:28
 */
@Component

public class BigFileUploadUtils {
    protected static final Logger log = LoggerFactory.getLogger(OSSFileUtils.class);
	//将OSS访问参数保存到配置文件,并忽略上传GIT,避免有权限的数据泄漏
    @Value("${aliyun.oss.endpoint}")
    private String endpoint;
    @Value("${aliyun.oss.accessKeyId}")
    private String accessKeyId;
    @Value("${aliyun.oss.accessKeySecret}")
    private String accessKeySecret;
    @Value("${aliyun.oss.dir.prefix}")
    private String prefix;
    @Value("${aliyun.oss.bucketName}")
    private String bucketName;

    @Autowired
    private RedisCache redisCache;

    /**
     * 创建OSSClient全局变量。
     *
     */
    private static OSS ossClient = null;

    /**
     * 上初始化大文件上传环境,返回uploadId
     *
     * @param objectName 文件
     * @return 返回uploadId
     */
    public String initUpload(String objectName) {
        ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
        // 创建InitiateMultipartUploadRequest对象。
        InitiateMultipartUploadRequest request = new InitiateMultipartUploadRequest(bucketName, objectName);

        // 如果需要在初始化分片时设置文件存储类型,请参考以下示例代码。
        // ObjectMetadata metadata = new ObjectMetadata();
        // metadata.setHeader(OSSHeaders.OSS_STORAGE_CLASS, StorageClass.Standard.toString());
        // request.setObjectMetadata(metadata);

        // 初始化分片。
        InitiateMultipartUploadResult upresult = ossClient.initiateMultipartUpload(request);
        // 返回uploadId,它是分片上传事件的唯一标识,您可以根据这个uploadId发起相关的操作,如取消分片上传、查询分片上传等。
        String uploadId =  upresult.getUploadId();
		//将uploadId缓存到redis,2个小时有效
        redisCache.setCacheObject("uploadId:"+uploadId, objectName, 120*60*1000, TimeUnit.MINUTES);
        return uploadId;
    }

    /**
     * 上传指定的文件片断,返回uploadId
     *
     * @param uploadId 文件ID
     * @return chunk的MD5
     */
    public String uploadChunk(String uploadId,
                              Integer chunkId,
                              MultipartFile file) throws IOException {
        ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
        InputStream instream = file.getInputStream();
        long curPartSize = file.getSize();
        String objectName = redisCache.getCacheObject("uploadId:"+uploadId);

        UploadPartRequest uploadPartRequest = new UploadPartRequest();
        uploadPartRequest.setBucketName(bucketName);
        uploadPartRequest.setKey(objectName);
        uploadPartRequest.setUploadId(uploadId);
        uploadPartRequest.setInputStream(instream);
        // 设置分片大小。除了最后一个分片没有大小限制,其他的分片最小为100 KB。
        uploadPartRequest.setPartSize(curPartSize);
        // 设置分片号。每一个上传的分片都有一个分片号,取值范围是1~10000,如果超出这个范围,OSS将返回InvalidArgument的错误码。
        uploadPartRequest.setPartNumber(chunkId);
        System.out.println(uploadPartRequest);
        System.out.println(ossClient);
        // 每个分片不需要按顺序上传,甚至可以在不同客户端上传,OSS会按照分片号排序组成完整的文件。
        UploadPartResult uploadPartResult = ossClient.uploadPart(uploadPartRequest);
        // 每次上传分片之后,OSS的返回结果包含PartETag。PartETag将被保存在partETags中。
        PartETag partETag = uploadPartResult.getPartETag();
        //需要将PartETag缓存存起来,如果是第一个块,需要先将创建list,以便add
        List<PartETag> partETagList = redisCache.getCacheObject("partETags:"+uploadId);
        if (partETagList == null){
            partETagList = new ArrayList<PartETag>();
        }
        partETagList.add(partETag);
        redisCache.setCacheObject("partETags:"+uploadId, partETagList,120*60*1000, TimeUnit.MINUTES);
        System.out.println(partETag);
        String md5Str = partETag.getETag();
        return md5Str;
    }

    /**
     * 合并文件,返回文件URL
     *
     * @param uploadId 文件
     * @return 返回文件返回URL
     */
    public String completeFile(String uploadId) {
        ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
        //获取该uploadId缓存的信息和各块的信息,
        String objectName = redisCache.getCacheObject("uploadId:"+uploadId);
        List<PartETag> partETagList = redisCache.getCacheObject("partETags:"+uploadId);
        // 创建CompleteMultipartUploadRequest对象。
        // 在执行完成分片上传操作时,需要提供所有有效的partETags。OSS收到提交的partETags后,会逐一验证每个分片的有效性。当所有的数据分片验证通过后,OSS将把这些分片组合成一个完整的文件。
        CompleteMultipartUploadRequest completeMultipartUploadRequest =
                new CompleteMultipartUploadRequest(bucketName, objectName, uploadId, partETagList);

        // 如果需要在完成文件上传的同时设置文件访问权限,请参考以下示例代码。
        // completeMultipartUploadRequest.setObjectACL(CannedAccessControlList.PublicRead);

        // 完成上传。
        CompleteMultipartUploadResult completeMultipartUploadResult = ossClient.completeMultipartUpload(completeMultipartUploadRequest);

        System.out.println(completeMultipartUploadResult);
        // 关闭OSSClient,删除redis缓存的内容。
        ossClient.shutdown();
        redisCache.deleteObject("uploadId:"+uploadId);
        redisCache.deleteObject("partETags:"+uploadId);
        return completeMultipartUploadResult.getLocation();
    }

}

三、VUE前端方案

前端功能主要是组件化实现,文件选取,大文件分块,分块传输,进度条,上传文件验证等,效果图如下。

3.1、文件选取组件

这个组件与upload区别不大
在这里插入图片描述

3.2、文件进度及提示

在这里插入图片描述
添加了进度条,文件上传信息

3.3、前端代码

<template>
  <div class="fileuploadbase">
    <div class="fileuploadgroup">
      <input type="file" name="file" id="fileinput" @change="fileUploadChanged" :multiple='false'
             style="display: none;"/>
      <el-button v-loading="loading" @click="fileButtonClick()"
                 :disabled="!isUploadClick"
                 icon="el-icon-upload" size="mini" plain style='width"96px;height:38px;font-size: 14px;'>
        {{fileButtonText}}
      </el-button>
      <div class="currentfilename" v-if="!isAutoUpload">{{ waitUploadFile }}</div>
      <el-button @click="fileUploadStartup" v-show="isFileUploadStartupBottonShow" v-if="!isAutoUpload" size="mini"
                 plain>点击上传
      </el-button>
    </div>
    <div v-show="currentFileName.length > 0">
      <div class="fileuploadgroup" v-for="(item, index) in currentFileName" :key="index">
        <div class="currentfilename" @click="handleFileDownloadClick(item)">{{ item.name }}</div>
        <el-progress :text-inside="true" :stroke-width="18" v-if="percentage" :percentage="percentage"
                     class="elprogress"
                     v-show="isProgressVis && index==(currentFileName.length-1)"></el-progress>
        <i class="el-icon-delete" title="删除文件" @click="handleFileDelClick(item)"
           v-show="isDeleteIconShow && (!isProgressVis || index!=(currentFileName.length-1))"
           :style="{cursor: 'pointer'}"></i>
      </div>
    </div>
  </div>
</template>
<script>
  import { initUpload, uploadChunk, mergeFile } from '@/api/data/bigfileupload'

  export default {
    name: 'bigFileUpload',
    data() {
      return {
        percentage: 0,
        filesdata: [],
        // 遮罩层
        loading: false,
        isDeleteIconShow: false,
        isFileUploadStartupBottonShow: false,
        isProgressVis: false,
        currentFileName: [],
        waitUploadFile: '',
        isUploadClick: true,
        //大文件上传相关信息
        uploadId: '',
        md5Str: '',
        //上传成功后文件地址
        fileUrl: '',
        //上传文件块号
        chunkId: 0,
        md5Str: []
      }
    },
    props: {
      // 上传方式:true - 自动上传;false - 手动上传
      isAutoUpload: {
        type: Boolean,
        default: false
      },
      // 单/多文件:true - 多文件;false - 单文件
      isMultiple: {
        type: Boolean,
        default: false
      },
      // 景区代码
      scenicId: {
        type: Number,
        default: null
      }
    },
    computed: {
      fileButtonText() {
        if (this.currentFileName.length == 0) {
          return '上传文件'
        } else {
          return '继续添加'
        }
      }
    },
    created() {
      if (!this.histFileArr) {
        this.isDeleteIconShow = false
        this.currentFileName = []
        return
      }
      this.isDeleteIconShow = true
      this.currentFileName = this.histFileArr
    },
    methods: {
      fileUploadChanged() {
        this.filesdata = this.$el.getElementsByTagName('input')[0].files//this.$el.getElementsByTagName方法可返回带有指定标签名的对象的集合。
        if (this.filesdata.length == 0) return
        this.isDeleteIconShow = true
        this.isFileUploadStartupBottonShow = true
        this.isProgressVis = false
        this.percentage = 0
        this.waitUploadFile = this.filesdata[0].name
        if (this.isAutoUpload) {
          this.fileUploadStartup()
        }
      },
      fileUploadStartup() {
        this.loading = true
        this.isUploadClick = false
        this.isDeleteIconShow = false
        this.isFileUploadStartupBottonShow = false
        //初始化参数
        this._name = this.filesdata[0].name //文件名
        this._size = this.filesdata[0].size //总大小
        this._shardSize = 2 * 1024 * 1024 //以2MB为一个分片
        this._shardCount = Math.ceil(this._size / this._shardSize) //总片数
        this.uploaded = 0

        if (!this.isMultiple) {
          this.fileDelOper(this.currentFileName[0])// 进行删除操作
          this.currentFileName = []
        }
        this.isProgressVis = true
        this.currentFileName.push({ name: this.filesdata[0].name })
        //初始化大文件上传,传入景区代码文文件名,返回uploadId
        let self = this
        let formData = new FormData()
        formData.append('scenicId', this.scenicId)
        formData.append('fileName', this.filesdata[0].name)
        initUpload(formData).then(response => {
          console.log(response)
          if (response.code === 200) {
            self.msgSuccess('大文件上传初始化成功')
            //从第0块开始上传
            self.uploadId = response.uploadId
            self.uploadByChunk(self.chunkId)
          } else {
            self.msgError(response.msg)
          }
        })
      },
      //分片上传大文件
      uploadByChunk(index) {
        this._start = index * this._shardSize
        this._end = Math.min(this._size, this._start + this._shardSize)//结束时总大小,和 开始的大小+之前的大小比较
        let self = this
        let fileData = this.filesdata[0].slice(this._start, this._end)
        //获取文件块MD5
        let reader = new FileReader()
        reader.readAsBinaryString(fileData)
        // 读取成功后的回调
        reader.onloadend = function() {
          //用hex_md5生成md5值,与OSS的算法对应,并转换为大写,不是直接md5-js的算法
          self.md5Str[index] = self.hex_md5(this.result).toLocaleUpperCase()
          console.log(self.md5Str[index])
        }
        let form1 = new FormData()//new一个form的实例,可以进行键值对的添加,
        form1.append('chunkFile', fileData) //slice方法用于切出文件的一部分
        form1.append('uploadId', this.uploadId)
        form1.append('chunkId', (index + 1).toString())
        form1.append('total', this._shardCount.toString()) //是否最后一片
        uploadChunk(form1).then(response => {
          console.log(response.md5Str)
          if (response.code === 200) {
            //判断返回的MD5值是否一致,一致继续传下一块,否则重传本块
            if (self.md5Str[index] === response.md5Str) {
              this.msgSuccess('第' + (index + 1).toString() + '块文件上传成功')
              self.uploaded++
              let percent = Math.floor((self.uploaded / self._shardCount) * 100)
              self.percentageSend(percent)
              //如果没上传完成,继续上传一下块
              index++
              if (index < self._shardCount) {
                this.uploadByChunk(index)
              }
            } else {
              //不一致,重新传本块
              this.msgSuccess('第' + (index + 1).toString() + '块文件上传不成功,重新上传')
              this.uploadByChunk(index)
            }
          } else {
            //出错,跳出循环显示错误
            this.msgError(response.msg)
          }
        })
      },
      //接收上传的百分值回调
      percentageSend(perNum) {
        this.percentage = perNum
        //如果上传完成,合并文件
        if (perNum === 100) {
          let form2 = new FormData()//new一个form的实例,可以进行键值对的添加,
          form2.append('uploadId', this.uploadId)
          mergeFile(form2).then(response => {
            console.log(response)
            if (response.code === 200) {
              this.msgSuccess('大文件合并成功')
              this.fileUrl = response.url
              this.successUpload(true, this.filesdata[0], response.url)
            } else {
              this.msgError(response.msg)
            }
          })
          this.loading = false
        }
      },
      successUpload(success, field, value) {
        if (success) {
          if (this.percentage < 100) return
          this.isDeleteIconShow = true
          this.isFileUploadStartupBottonShow = false
          this.isProgressVis = false
          this.isUploadClick = true
          document.getElementById('fileinput').value = ''
          this.waitUploadFile = ''
          this.successvalue = value
          console.log(this.successvalue)
          this.$emit('successvalue', this.successvalue)
          this.$message({
            message: '文件上传成功',
            type: 'success'
          })
        }
      },
      // 点击文件后面的删除按钮
      handleFileDelClick(item) {
        this.filesdata = []
        document.getElementById('fileinput').value = ''
        for (let i = 0; i < this.currentFileName.length; i++) {
          if (this.currentFileName[i].name == item.name) {
            this.fileDelOper(item)// 进行删除操作
            this.currentFileName.splice(i, 1) //删除下标为i的元素
            return this.currentFileName
          }
        }
        this.isDeleteIconShow = false
        this.isFileUploadStartupBottonShow = false
        this.isProgressVis = false
      },
      // 点击选取文件
      fileButtonClick() {
        document.getElementById('fileinput').click()
      },
      // 删除操作
      fileDelOper(val) {
        this.$emit('fileDelOper', val)
      },
      // 点击文件下载
      handleFileDownloadClick(val) {
        this.$emit('fileDownload', val)
      }
    }

  }
</script>

<style scoped>
  a {
    color: #65b978;
  }
</style>

前端有几个关键点:
1、分块代码上传
最先是先计算好分块数量,用for循环逐块上传,发现有一个问题,js前端是异步的,for一下就循环结束了,一下全部交给后端,各块上传结束时间不一样,后面的块很快就会超时传输不成功。
先试了一下前端循环等待,CPU直接飙升,风扇加速,受不了。
试用使用sleep方案,这个timeout也是异就的,起不到等待作用,且前端会仍阻塞,CPU占用缓慢飙升。
后面想到了用request异步嵌套实现,即上传成功返回后,再调下一块上传请求。

//如果没上传完成,继续上传一下块
index++
if (index < self._shardCount) {
  this.uploadByChunk(index)
}

2、MD5比较
上传前先生成MD5,然后与阿里OSS返回的比较,注意阿里用的是hex_md5函数方法,开始下载了md5-js组件,用md5函数,生成的不一样,后从网上找了一个包含hex_md5函数的JS,自己将其加到utils然后在main.js中引入。
3、上传完成判断
刚开始是放在for循环中判断和Spring boot中同时判断,哪个发现了就先发走文件流关闭,发现有问题,系统是异步提效的,不论是Spring boot还是for循环的最后一个块都有可能不是最后完成的块,提前关闭会导致文件出错。
最后选择的方法是在前端块上传结束后,如果正确,并判断上成功上传块数量。即增加上传完成的块数this.uploaded变量,这个变量触发进度条更新,在进度条更新到100后,再触发文件合并关闭请求。同时,在Spring boot中不再判断。

四、小结

总的来说,VUE通过Spring boot上传超大文件到OSS过程还是比较复杂,花了不少时间,才完全将此问题解决。网上有几篇相关文章,虽然不全,但对学习起了很大作用并引用了部分代码,一并表示感谢。


版权声明:本文为wujize原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。