Vue + el-upload + File.slice()满足服务端分片上传逻辑

685 阅读5分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

1. 为什么对象存储需要支持断点续传

在开发环境中往往是是理想的网络,永远通畅,对象存储系统并不需要断点续传这个功能,而现实世界里,对象存储服务在数据中心运行,而客户端在本地运行,通过互联网连接,互联网的连接速度不稳定,有可能由于网络故障导致断开连接,例如在客户端上传或者下载一个大对象时,由于网络断开导致上传或下载失败的概览就不容忽视,为了解决这个问题,对象存储服务必须提供断点续传功能,允许客户端从某个检查点而不是从头开始上传或者下载对象,本文只介绍客户端(用Vue实现)的断点上传。

2. Go后端接口服务关于分片上传提供的三个Api

代码就不展示了,关联代码太多

2.1 获取分片上传的token

Api

  • /objects/file

Method

  • POST

Request Header

  • Digest: SHA-256=对象文件的SHA-256散列值的base64编码
  • Size: 对象文件的大小

Response Header

  • Location: 带token的分片请求URL(格式为/temp/token)

3.2 获取当前token上传的字节数

Api

  • /temp/token

Method

  • HEAD

Response Header

  • Content-Length: token 当前的上传宇节数

3.3 分片上传请求

Api

  • /temp/token

Method

  • PUT

Request Header

  • Range: bytes=<first>-<last>

注:如果是最后一次上传,直接写Range: bytes=<first>-

Request Body

  • 对象的内容,字节范围为 first ~ last

3. 分片大致流程

3.1 三个api的流程

post请求获取分片上传的token,从Request Header获取对象的hash值和大小,如果该hash对应的对象存在,则返回http代码200,代表上传成功,否则生成一个token放入Location相应头部,返回http代码201。

put请求分片上传,从url中获取token,检查token大小判断token是否存在,不存在则返回403,从请求头中获得分片起点,如果起点和token当前大小不一致,返回416 Range Not Satisfiable,如果大小一致,以5MB长度读取请求体。

head请求根据token获取到当前上传的大小并放在Content-Length的头部返回

3.2 为什么需要head请求?

服务端的规则是:除非最后一批分片数据,否则只接受5MB长度的分片数据,这是服务端的行为逻辑,不能要求客户端知道接口服务背后的逻辑,所以接口服务必须提供token的head操作,让客户端知道服务端该token目前的写入进度,而去选择合适的分片起点。

4. Vue实现过程

只展示相关代码

template中,el-upload组件为element ui的上传组件,采用http-request自定义上传的方式,紧随其下的div为为进度条显示,只有当progressShow为true时才会显示,也就是上传时才会显示

<template>
    ...
    <!--上传组件-->
    <el-upload
               weight="100%"
               ref="upload"
               action=""
               class="upload-demo"
               drag
               :http-request="uploadRequest"
               multiple>
        <i class="el-icon-upload"></i>
        <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
    </el-upload>

    <div style="display: flex;align-items: center;justify-content: space-evenly;margin-top: 10px">
        <span v-if="progressShow" :style="{'width': (uploadSliceFlag? '20%':'15%')}"><span v-if="uploadSliceFlag">分片</span>上传进度:</span>
        <el-progress v-if="progressShow" :style="{'width': (uploadSliceFlag? '80%':'85%')}" :text-inside="true" :stroke-width="15" :percentage="progressPercent"></el-progress>
    </div>
    ...
</template>

script中data一些数据

data() {
  return {
       ...
    progressPercent: 0, // 进度条默认为0
    progressShow: false, // 进度条默认不显示
    uploadSliceFlag:false,// 分片 默认不显示
    bytesPerPiece: 5242880 // 约定每个切片的长度为5MB
    ...
  }
},

封装的axios请求

// 普通上传api
export const uploadObj = (params, data, hash, onUploadProgress) => {
    return service
        .request({
            url: '/objects/' + params,
            method: 'PUT',
            data: data,
            headers: {
                'Digest': 'SHA-256=' + hash
            },
            onUploadProgress
        })
        .then(res => res)
}

// 分片请求 获取token api 
export const getSliceUploadToken = (params, hash, size) => {
    return service
        .request({
            url: '/objects/' + params,
            method: 'POST',
            headers: {
                'Digest': 'SHA-256=' + hash,
                'Size': size
            }
        })
        .then(res => res)
}
// 获取上传进度api
export const headUploadSliceProgress = (params) => {
    return service
        .head(params)
        .then(res => res)
}

// 分片上传对象api
export const uploadSlice = (params, data, range,onUploadProgress) => {
    return service
        .request({
            url: params,
            method: 'put',
            data: data,
            headers: {
                'range': range
            },
            onUploadProgress
        })
        .then(res => res)
}

methods中的一些方法

uploadRequest为el-upload绑定的自定义上传函数,就从这里看起,先判断文件大小,小于50MB使用普通上传,大于50MB使用分片上传。使用分片上传前判断sessionStorage中有没有hash对应的token,这里是因为后续的分片上传方法中如果某个分片上传失败,则将token保存,下次使用之前的进度继续上传,如果该对象没有在sessionStorage中保存token,就取获取token,如果状态码201,则token创建成功,从响应头中Location获取分片上传的URL,再去执行分片上传的方法;如果状态码为200说明存在该对象,不再上传对象,直接提示上传成功(这是服务端数据去重的功能),具体代码如下

async uploadRequest(param) {
  this.progressPercent = 0// 上传新文件时,将进度条值置为零
  // 获取对象hash值的base64编码值
  let hash = "";
  hash = await this.getFileHash(param);

  // axios显示进度条
  const uploadProgressEvent = progressEvent => {
    this.progressPercent = Math.floor((progressEvent.loaded * 100) / progressEvent.total)
  }

  if (param.file.size <= 52428800) { // 50mb 以下 普通上传;以上 分片上传
    this.uploadSliceFlag = false // 不显示分片二字
    this.progressShow = true // 显示进度条
    this.uploadObj(param, hash, uploadProgressEvent)// 普通上传
  } else {
    // 判断sessionStorage中有没有hash对应的token
    var sessionToken = sessionStorage.getItem(hash);
    if (sessionToken === null) { // 如果sessionStorage中没有token
      // 发送分片上传请求
      this.$request.getSliceUploadToken(param.file.name, hash, param.file.size).then(val => {
        if (val.status === 201) {  // 201表示token创建成功
          var token = val.headers.location
          this.$message.info("文件大小超过50MB,将分片上传")
          // 文件切片上传
          this.uploadSliceFlag = true // 显示分片二字
          this.progressShow = true // 显示进度条
          this.uploadSlice(param, token, hash)
        } else if (val.status === 200) { // 200表示hash已存在,直接显示已上传,新增版本
          this.uploadSliceFlag = true // 显示分片二字
          this.progressShow = true // 显示进度条
          this.progressPercent = 100 // 进度条拉到100

          setTimeout(() => {
            this.progressPercent = 0 // 进度条归0
            this.progressShow = false // 关闭进度条
            this.$message.success("上传成功")
            this.getObjList(this.content, 1)
          }, 2000);
        } else {
          this.$message.error("获取token失败")
        }
      })
    } else { // 如果有,使用上传失败的token继续上传
      await this.uploadSlice(param, sessionToken, hash)
    }
  }
},

getFileHash是获得文件的hash值,并base64编码

getFileHash(param) {
  return new Promise(function (resolve, reject) {
    let reader = new FileReader()
    reader.readAsArrayBuffer(param.file)
    reader.onload = () => {
      var wordArray = CryptoJS.lib.WordArray.create(reader.result);
      var hash = CryptoJS.SHA256(wordArray).toString()
      var hashValue = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(hash));
      resolve(hashValue);
    }
  })
},

uploadObj为文件普通上传方法,参数为el-upload的读取的内容、对象文件hash值、进度条

uploadObj(param, hash, uploadProgressEvent) {
  this.$request.uploadObj(param.file.name, param.file, hash, uploadProgressEvent).then(val => {
    if (val.status === 200) {
      this.$message.success("上传成功")
    } else {
      this.$message.error("上传失败")
    }
  }).finally(() => {
    // 延时2秒刷新对象列表,并将对象进度条清0
    setTimeout(() => {
      this.progressPercent = 0 // 进度条归0
      this.progressShow = false // 关闭进度条
      this.getObjList(this.content, 1) // 这个是显示列表对象的方法,与本文无关
    }, 2000);
  })
},

headUploadSliceProgress方法是检查分片上传进度,这里因axios返回值是异步操作,获取返回值时,请求操作还未完成,就已经执行了赋值,防止结果为undefined,使用async声明方法为异步方法,await等待异步操作执行完毕,再返回

// 检查上传进度
async headUploadSliceProgress(token) {
  var start = 0
  await this.$request.headUploadSliceProgress(token).then(val => {
    if (val.status === 200) {
      start = val.headers['content-length']// 长度
    } else {
      this.$message.error("检查进度失败")
      start = -1
    }
  })
  return start
},

最关键的分片上传方法uploadSlice,是一个递归调用的过程。首先上个方法如果检查进度失败返回-1,分片上传也没有意义继续下去,直接return。否则,检查进度的结果就是分片的起点,终点就是起点+5MB。再下来就是停止递归的条件:当上传完最后一个分片就不再递归,因此需要记录最后一个分片的标记,也就是所需要上传的分片大小<=5MB即为最后一个分片。通过兼容方式获取File.slice()方法对文件切片,拼接请求头中的range,和分片数据共同调用分片上传的接口,如果返回状态码200,需要判断是否是最后一次,如果不是最后一次分片,则递归的调用uploadSlice()方法,否则如果是最后一次分片就不再递归;如果返回状态码不为200,说明某次分片上传失败,sessionStorage记录文件hash和token,为了下次上传续传。

// 分片上传
async uploadSlice(param, token, hash) {
  var start = parseInt(await this.headUploadSliceProgress(token)) // 分片起点
  if (start === -1) { // -1代表查看分片起点的请求失败
    return
  }
  var lastSlice = (param.file.size - start) <= this.bytesPerPiece // 最后一个分片标记

  var end = start + this.bytesPerPiece// 分片终点
  // 请求头部range
  var range = 'bytes=' + start + '-' + end
  if (lastSlice) { // 如果是最后一个分片
    range = 'bytes=' + start + '-'
  }
  // 文件分片
  var blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice; // 兼容方式获取slice方法
  var chunk_file = blobSlice.call(param.file, start, end);

  // 发送上传请求
  this.$request.uploadSlice(token, chunk_file, range).then(val => {
    if (val.status === 200) {
      this.progressPercent = Math.floor((100 / param.file.size) * start)
      if (lastSlice) { // 最后一个分片上传成功
        // 循环结束提示上传成功 进度条=100
        this.progressPercent = 100
        this.$message.success("上传成功")
        // 最后进度条清零,重新获取对象列表
        setTimeout(() => {
          if (sessionStorage.getItem(hash) !== null) {
            sessionStorage.removeItem(hash)
          }
          this.progressPercent = 0 // 进度条归0
          this.progressShow = false // 关闭进度条
          this.getObjList(this.content, 1)// 这个是显示列表对象的方法,与本文无关
        }, 2000);
      } else { // 不是最后一个分片,递归调用uploadSlice方法
        this.uploadSlice(param, token)
      }
    } else {
      // 上传过程中出现错误 给出错误提示,并且保存token到sessionStorage中
      this.$message.error("上传错误,请重新上传")
      sessionStorage.setItem(hash, token)
    }
  })
},

5. 运行效果

小于50MB的对象文件普通上传

\