什么是分片上传
分片上传就是把一个大文件切割成若干块,一小块一小块的传输,分片上传的好处在于避免文件的重新上传。 试想一下,如果我们上传的文件是一个很大的文件,上传时间就会比较久,再加上网络不稳定或者误操作等不稳定因素,很容易导致传输中断,用户出了重新上传没有其他别的什么办法。 而分片上传就可以解决上述问题,当传输中断之后,我们可以只从中断的地方重新上传剩余片段,而不需要重新上传整个文件,大大减少了重传的开销。
分片上传的原理
- 将需要上传的文件按照一定的规则,分割成大小相同的数据块
- 初始化一个分片上传任务,并返回本次上传的唯一标识
- 按照一定的策略(串行或者并行)发送各个数据块
- 发送完成之后,服务端判断数据上传是否完整,如果完整,则将数据块进行合成得到原始文件
分片上传流程
使用分片上传需要注意的点是:
- 上传之前需要授权,避免第三方未经授权将数据往Bucket里面传
- 如果上传的文件小于100kb,默认使用直接上传
- 除了最后一个数据块,其他数据块的大小不能小于100KB,否则会导致合成失败
- 切割的数据块不是越大越好,也不是越小越好,要根据实际的网络情况做对应的处理,核心就是网络好的情况下可以切割的大一点,反之就切割的小一点
文件分块(divideParts)
首先需要定义好分片的大小partSize
,然后根据文件的大小fileSize
除以partSize
取余就可以计算出文件一共被分割成了多少个数据块
const divideParts = (fileSize, partSize) => {
const numParts = Math.ceil(fileSize / pasrtSize);
const partOffs = [];
for(let i = 0; i < numParts; i++) {
const start = partSize * i;
cosnt end = Math.min(start + partSize, fileSize);
partOffs.push({
start,
end
})
}
return partOffs;
}
初始化分片任务
在这个过程中,主要是生成一个唯一的UploadId,用于标识本次Multiple Upload事件,之后的中止上传以及断点续传都是通过这个UploadId来进行操作的;其中MD5算法可以用来生成唯一标识
const request = async () => {
// 开始请求UploadId等相关数据
return {};
}
const initMultipartUpload = async (name, options) => {
// 获取UploadId的请求参数
const params = {
}
const result = await request(params);
return {
res: result.res,
bucket: result.data.Bucket,
name: result.data.Key,
uploadId: result.data.UploadId
};
}
分片任务初始化完成,拿到UploadId之后,就可以进行真正的文件传输了,不过在此之前,仍需要做的一步就是记录上传之前的文件状态,这一步的目的就是在传输中断之后,再次上传的时候知道是从哪个地方继续传输,该状态可以用一个对象来记录
cosnt checkPoint = {
file, // 上传的文件
name, // 上传文件的名称
fileSize, // 上传文件的大小
partSize, // 每次需要上传的文件大小
uploadId, // 上传的唯一标识,
doneParts: [], // 上传完成的数据块集合
}
开始分片上传
初始化上传任务之后,就可以开始真正的文件上传了,这个过程会给阿里云服务器发送一个请求,请求Url为http://xmdev-resource-pub.oss-cn-hangzhou.aliyuncs.com/objectName
,请求方法为PUT
,请求参数为partNumber
和uploadId
;即根据指定的UploadId以及传输的数据块上传数据;
每一个上传的Part都有一个标识它的号码,即partNumber
,uploadId
用于唯一标识上传的Part属于哪个Object。
如果使用了同一个partNumber
上传了相同的数据,那么OSS上已有的partNumber对应的Part数据将被覆盖。
在上传的过程中,数据块每次上传完成之后,都会计算当前的上传进度以及数据块,并将进度以及当前数据块返回给客户端,客户端业务代码做相应的断点记录,为之后的断点续传做铺垫
// 上传完成之后
checkpont.doneParts.push({
number: partNumber,
etag: 每次上传成功之后服务器返回的ETag值
});
const currentProgress = checkpont.doneParts.length // 所有part的数量
// 调用客户端的progress函数
progress(currentProgress, checkpoint);
合成文件
最后在将所有数据Part都上传完成后,便会执行文件的合成工作,即将所有的part合成一个完整的文件; 在执行该操作时,必须提供有效的Part列表(包括Part号码和ETag),OSS收到用户提交的Part列表后,会逐一验证每个数据Part的有效性。当所有的数据Part验证通过后,OSS将把这些数据part组合成一个完整的Object。
到这里基本上算是完成了文件的上传,不过还有最后一步,就是我们再使用这个上传的文件的时候,需要知道这个文件的OSS地址,那么这个OSS地址其实是由自己的服务器返回来的,那么我们自己的服务器又是怎么拿到这个资源的呢?
了解OSS直传
Web 端常见的上传方法是用户在浏览器或 APP 端上传文件到应用服务器,应用服务器再把文件上传到 OSS。相对于这种上传慢、扩展性差、费用高的方式,阿里云官方更推荐将数据直传到 OSS。
阿里云 OSS 直传的三种方案:
- JavaScript 客户端签名后直传。
- 服务端签名后直传。
- 服务端签名后直传并设置上传回调。
我们的系统采用的是第三种上传方式
可以看到,Web 端需要做的只有两步:
向应用服务器请求上传 授权和回调。可以在这一步做一些初始化上传文件信息的操作,以及获取业务逻辑需要使用的数据。 向 OSS 发送文件上传请求,接收 OSS 服务器的响应。
什么是断点续传
所谓断点续传,也就是要从文件上传中断的地方开始继续上传,在HTTP1.1之前是不支持断点续传的,HTTP1.1之后,HTTP协议默认支持断点续传,主要是在header中增加了两个字段Range和contentRange。
Reange:用于请求头中,指定第一个字节的位置和最后一个字节的位置,比如
// 请求5001-10000字节
Range:bytes=5001-10000
// 请求5001字节之后全部的
Range:bytes=50001-
针对范围请求,通过HTTP响应返回状态码为206 Partial Content 的响应报文
Content-Range:用于响应头,指定整个实体中的一部分的插入位置,他也指示了整个实体的长度。在服务器向客户返回一个部分响应,它必须描述响应覆盖的范围和整个实体长度。
浏览器端使用
请求上传授权
客户端想OSS服务器上传文件之前需要获取上传授权,包括accessKeyId,accessKeySecret,region,bucket,stsToken,这些都是可以调应用服务器的接口拿到的,获取到授权之后,就可以创建一个OSS实例,用来执行分片上传和断点续传
Service.post('mfs/anon/mfs/multuPartUpload')
.then((res) => {
const { result = {} } = res;
const {
bucket,
callBack, // 回调函数
resourceId, // 资源占位符
accessKeyId,
accessKeySecret,
callbackBody, // 回调函数body
ossUri, // 后端给的上传地址
securityToken,
} = result;
const ossClient = new OSS({
bucket,
accessKeyId,
accessKeySecret,
region: 'oss-cn-hangzhou',
stsToken: securityToken
});
})
分片上传
ossClient.multipartUpload(ossUri, file, {
callback: {
url: callBack,
body: callbackBody,
contentType: 'application/josn'
},
parallel: 1024*1024, // 分片大小
partSize: 5, // 同时上传的分片数量
progress: onMultipartUploadProgress, // 上传进度
}).then(() => {
// 成功之后的处理
}).catch(() => {
// 失败之后的处理
});
const onMultipartUploadProgress = (progess, checkpoint) => {
// 记录文件的进度以及断点处理
const checkpoints = {};
checkpoints[checkpoint.uploadId] = checkpoint;
}
断点续传
Object.keys(checkpoints).forEach(checkpoint => {
ossClient.multipartUpload(ossUri, file => {
checkpoint, // 增加断点
callback: {
url: callBack,
body: callbackBody,
contentType: 'application/josn'
},
parallel: 1024*1024, // 分片大小
partSize: 5, // 同时上传的分片数量
progress: onMultipartUploadProgress, // 上传进度
})
})
刚开始看文档的时候还觉得挺简单的,以为只要使用一个API就可以完成功能,但过程中其实遇到了比较多的问题,让我印象深刻的是下面两个问题
1. 上传没有超过100KB的文件无法记录断点
一开始以为是自己使用API的姿势不对,一直对着文档找不同的地方,最后还是不明白问题出现在哪,被这个问题阻塞了一天之后,只能去看ali-oss这个npm里面的具体实现,找到multipartUpload这个函数,看到有这么一个逻辑
proto.multipartUpload = async function multipartUpload(name, file, options) {
const minPartSize = 100 * 1024;
const fileSize = await this._getFileSize(file);
// 如果文件大小小于100kb,那么就走普通上传
if (fileSize < minPartSize) {
if (options && options.progress) {
await options.progress(1);
}
const result = await this.putStream(name, stream, options);
const ret = {
res: result.res,
bucket: this.options.bucket,
name,
etag: result.res.headers.etag
};
return ret;
}
const checkpoint = {
file,
name,
fileSize,
partSize,
uploadId,
doneParts: []
};
if (options && options.progress) {
await options.progress(0, checkpoint, initResult.res);
}
return await this._resumeMultipart(checkpoint, options);
};
multipartUpload函数里面有一个判断就是当需要被上传的文件大小小于100kb的时候,直接用普通的文件上传,所有执行客户端的progress回调函数的时候,只传了一个参数,且值为1,所以当我们在progress回调函数里面进行一些有关于断点checkpoint的操作的时候,就会报错
2. 文件上传完成之后调用后端给的回调函数失败
文件上传完成之后,需要再调用应用服务器的回调函数,这样应用服务器才知道文件已经上传成功了,可以根据resourceId从对应的bucket里面获取资源并返回给客户端了,这个问题主要是后端那边去处理的,前端的问题在于续传的时候又重新请求授权了,,这样会使得一个文件有两个resourceId,结果就会导致文件找不到了。