本文已参与「新人创作礼」活动,一起开启掘金创作之路。
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的对象文件普通上传
\