本文主要阐述了如何结合阿里云 OSS 服务实现一个支持断点续传和多文件上传的 web SDK。文章内容配合代码食用风味更佳:github.com/polyv/vod-u… 。javascript
其中,分片上传和断点续传技术由阿里云 OSS Browser.js SDK(下面简称 OSS SDK)提供,具体调用方法可参见阿里云 OSS 的 相关文档。html
阿里云 OSS 提供的分片上传(Multipart Upload)和断点续传功能,能够将要上传的文件分红多个数据块(OSS 里又称之为 Part)来分别上传,上传完成以后再调用 OSS 的接口将这些 Part 组合成一个 Object 来达到断点续传的效果。java
web 上传 SDK 所作的是结合业务逻辑将相关的上传逻辑封装起来,并提供相关调用方法,以便于其余开发者快速集成。webpack
先来了解一下咱们即将要作的是一个怎样的产品 :git
script
标签引入在线资源 ( player.polyv.net/resp/vod-up… )npm install @polyv/vod-upload-js-sdk
更详细的使用文档能够查看这里: @polyv/vod-upload-js-sdkgithub
正文开始前,先来看下 src 目录下的全部文件。其中,UploadManager
、 Pool
和 PlvVideoUpload
三个类会在后面着重分析。web
文件 | 说明 |
---|---|
index.js | SDK 入口文件,返回 PlvVideoUpload 类 |
pool.js | 实现一个控制多个任务同时执行的任务池(Pool ) |
queue.js | 实现一个普通队列类(Queue ) |
upload.js | 实现一个管理单个文件上传的类(UploadManager ) |
utils.js | 工具函数 |
Web 端常见的上传方法是用户在浏览器或 APP 端上传文件到应用服务器,应用服务器再把文件上传到 OSS。相对于这种上传慢、扩展性差、费用高的方式,阿里云官方更推荐将数据直传到 OSS。ajax
阿里云 OSS 直传的三种方案:算法
- JavaScript 客户端签名后直传。
- 服务端签名后直传。
- 服务端签名后直传并设置上传回调。
这里采用的是第三种直传方案,具体流程以下:npm
能够看到,Web 端须要作的只有两步:
接下来,咱们来了解一下更具体的上传流程。
因为上传过程当中使用了由 OSS SDK 提供的分片上传和断点续传技术,咱们须要先安装该 SDK,具体的安装方式点击 这里 查看。该 SDK 经过提供 OSS 对象及相关方法来支持上传。
上传的主要流程包括:
这里的上传流程包括了从未开始和暂停两种状态开始的上传。若是是从暂停状态开始上传,则能够跳过向应用服务器请求 Policy 和回调、上传以前的业务逻辑处理等步骤。具体流程参考下图:
为了方便调用,图中的流程封装成一个 UploadManager
类,提供开始上传、暂停上传等方法。
下面来详细讲一下如何实现图中提到的断点续传、记录断点信息,以及为何要更新临时访问凭证。
正如前面提到的,咱们须要先初始化 OSS 实例,而后调用 multipartUpload()
方法开始上传,经过参数能够设置分片大小、上传进度回调、callback等。
相关代码以下:
// upload.js
import OSS from 'ali-oss/dist/aliyun-oss-sdk.min';
import PubSub from 'jraiser/pubsub/1.2/pubsub';
class UploadManager extends PubSub {
// ...
// 分片上传
_multipartUpload() {
// 初始化 OSS 实例
this.ossClient = new OSS(this.ossConfig);
// 从浏览器本地存储获取checkpoint
const checkpoint = getLocalFileInfo(this.fileData.id);
if (checkpoint) {
checkpoint.file = this.fileData.file;
}
// 断点续传
return new Promise((resolve, reject) => {
this.ossClient.multipartUpload(
this.filenameOss, // Object名称
this.fileData.file, // File 对象
{ // 额外参数
parallel: this.parallel,
partSize: this.partSize || getPartSize(this.fileData.file.size), // 分片大小
progress: this._updateProgress.bind(this), // 上传进度回调函数
checkpoint, // 断点记录点
callback: this.callbackBody // callback回调设置
}
).then(() => {
// 完成上传
// ...
}).catch((err) => {
// 异常处理
// ...
});
});
}
// ...
}
export default UploadManager;
复制代码
对于上传同一份文件,咱们但愿即使是关闭页面再从新打开,也能从断点处续传,所以须要将断点信息记录在 localstorage
中。因为每一个文件对应的 localstorage
的键名应该高度惟一,咱们最好能根据用户信息、文件名、文件大小、文件类型等综合角度去作惟一性标识。
考虑到要将这么多信息拼成一个字符串后长度可能会很长,而且可能会包含了一些特殊字符,因此选择了用 md5
将这个拼接获得的字符串进行加密,加密后的字符串就做为文件 id 使用。
相关代码以下:
// 根据文件信息及用户信息对每一个不一样的文件生成具备必定长度的惟一标识
function _generateFingerprint(fileData, userData) {
const { cataid, file } = fileData;
return md5(`polyv-${userData.userid}-${cataid}-${file.name}-${file.type}-${file.size}`);
}
复制代码
上图中提到的临时访问凭证是由于咱们使用了阿里云STS进行临时受权。
OSS能够经过阿里云 STS(Security Token Service)进行临时受权访问。阿里云STS是为云计算用户提供临时访问令牌的Web服务。经过 STS,能够为第三方应用或子用户(即用户身份由您本身管理的用户)颁发一个自定义时效和权限的访问凭证。
临时访问凭证有必定的有效期,过时以后上传过程会 catch 到错误并中止上传,这时须要更新凭证才能继续上传文件。
本 SDK 还须要实现多个文件同时上传,并限制同时处于上传状态的文件不能超过 5 个,以下面的 demo 截图所示:
图中是点击了"所有开始"的效果截图,虽然一共添加了 9 个文件,但只有前面 5 个文件真正处于上传状态。这里我将这些已经处于开始状态(包括未真正开始上传)的文件都添加到一个特殊的上传任务队列中,经过一个控制多个任务同时执行的任务池(Pool
类)来实现对上传队列的管理。
那么该如何限制多个文件同时上传呢?
按照上述的两种状态,咱们将任务池中的文件分为两类,分别对应:正在执行任务的列表(执行列表)和等待执行任务的列表(等待列表)。
要注意的是,虽然 Pool
类内部须要管理两个列表,可是对外表现为一个列表,因此一些队列方法都是对总体进行操做的。
Pool
类的关键方法Pool
类是用于管理上传队列,因此须要具备队列的管理方法,如入队、出队、查找、移除。此外,pool
还须要具备控制任务的执行,所以须要 _check()
方法检查执行列表是否已经"满员"、是否存在下一个等待执行的任务。
如下为 Pool
类的一些关键方法及说明:
enqueue()
:将元素添加到等待列表的尾部,并检查是否能够当即执行该任务。dequeue()
:从队列中删除第一个元素,并返回该元素的值。remove(id)
:移除队列中的指定元素,并返回该元素的值或 null
。_check()
:检查是否还有下一个任务能够执行。_run(item)
:执行指定的任务,并在执行完成后,调用 _check()
检查是否存在下一个等待执行的任务。UploadManager
类和 Pool
类都不会直接提供给外部调用,而是经过一个 PlvVideoUpload
类来整合文件列表的上传逻辑,以及对外提供接口。这里咱们来介绍一下这个 PlvVideoUpload
类的部分功能和实现。
上面提到的上传队列只是用于管理能够开始上传的文件,可是若是这时暂停了某个文件的上传,这个文件就应该从上传队列中出队。对于这个暂停状态的文件,咱们还须要一个队列来管理相似的状况。
在文件队列中,除了用于管理开始上传或准备上传的文件的上传队列 uploadPool
( Pool
类的实例),还应该有一个等待队列( waitQueue
)用于管理已经添加到文件队列但未容许开始上传的文件。
有两种状况须要将文件添加到等待队列中进行管理:
waitQueue
;不然添加到 uploadPool
。waitQueue
;继续上传时能够从 waitQueue
中根据 id
找到对应的上传管理器(UploadManager
类的实例)。因为 SDK 既支持全部文件的统一操做(开始、暂停),也支持对单个文件的操做,因此还要考虑操做单个文件对整个上传流程的影响。
比较复杂的状况是,总体的文件队列在上传时,操做单个文件须要对上传队列和等待队列从新进行调整,如今总结了几种操做对应的处理以下:
除了上述的状况,队列处于暂停状态时操做单个文件也须要对等待队列作相应调整,比较简单,这里就不赘述了 。
文件队列处于上传状态时可操做单个文件上传状态带来的另外一个问题是,该如何判断全部文件都已经结束上传呢?
将文件信息添加到上传队列以后,会返回一个 Promise
实例。可使用 Promise.all()
方法将多个 Promise
实例,包装成一个新的 Promise
实例。当全部文件都上传成功后,会触发这个新 Promise
实例。
从而有了如下的思路:
// upload.js
/** * 开始上传全部文件 */
startAll() {
const uploadPromiseList = [];
while (this.waitQueue.size > 0) {
const uploader = this.waitQueue.dequeue();
uploadPromiseList.push(this.uploadPool.enqueue(uploader));
}
// 判断全部文件上传是否结束
Promise.all(uploadPromiseList)
.then(() => {
// TODO: 触发 UploadComplete 事件
});
}
复制代码
可是,这样作的问题显而易见。若是文件队列处于上传状态,对某个文件前后执行暂停和继续操做后,就没法监控到这个文件上传结束的事件;若是文件队列上传结束,这时添加一个文件并单独对该文件执行上传操做,文件上传结束后,也不会触发 UploadComplete
事件。
咱们来从新整理一下思路:
uploader
。uploader
入队到上传队列时会返回一个 Promise 实例。uploader
入队到上传队列有如下几种状况:
promise
都集中起来处理。咱们这里定义一个数组 newUploadPromiseList
来存放这些上传 promise
,以及一个 _onPromiseEnd()
方法来监听 newUploadPromiseList
中全部 promise
的结束。_onPromiseEnd
方法的时机:
第 4 点中提到的 _onPromiseEnd()
代码以下:
// index.js
class PlvVideoUpload extends PubSub {
// ...
_onPromiseEnd() {
const uploadPromiseList = [...this.newUploadPromiseList];
this.newUploadPromiseList = [];
// 判断全部文件上传是否结束
Promise.all(uploadPromiseList)
.then(() => {
if (this.newUploadPromiseList.length > 0) { // 还有未监听到的 promise
this._onPromiseEnd();
} else if (this.uploadPool.size === 0) { // 上传队列长度为0
this.status = STATUS.NOT_STARTED; // 文件队列的上传状态改成暂停
if (this.waitQueue.size === 0 && this.fileQueue.size !== 0) { // 等待队列长度为0,但文件队列长度不为0
// TODO: 上传结束,触发 UploadComplete 事件
}
}
});
// 处理文件上传状态发生改变或上传报错的状况
for (let i = 0; i < uploadPromiseList.length; i++) {
uploadPromiseList[i]
.then(res => {
if (!res || !res.code) return;
this._handleUploadStatusChange(res);
})
.catch(err => {
// TODO: 上传报错,触发 Error 事件
});
}
}
}
复制代码
以上代码还包括了上传状态发生改变或上传报错时的处理。其中 _handleUploadStatusChange(res)
用于处理文件上传状态发生改变的状况。res.code
是由 uploadManager
实例(uploader
)返回的错误代码,能够用于区分各类状况致使的上传中断,以便于对不一样状况的中断作后续处理。
最后,咱们使用 webpack 进行打包。
经过 output.libraryTarget
,咱们能够决定如何暴露 SDK 。
output
选项主要用于配置文件输出规则,而 output.library
选项能够用于输出时将文件暴露为一个变量,能够说是为了打包 SDK 文件而生的一个配置项。另外一个选项 output.libraryTarget
则能够配置如何输出变量,默认值是 var
。
output.libraryTarget
的部分可选值:
libraryTarget: 'umd'
- This exposes your library under all the module definitions, allowing it to work with CommonJS, AMD and as global variable.
libraryTarget: 'commonjs2'
- The return value of your entry point will be assigned to themodule.exports
. As the name implies, this is used in CommonJS environments.更多可选值能够参考 webpack 模块定义系统的文档。
下面是 SDK 中使用的两种配置方式,主要区别在于 output
选项的配置:
// webpack.prod.config.js
const merge = require('webpack-merge');
const config = require('./webpack.config.js');
module.exports = merge(config, {
mode: 'none',
entry: './src/index.js',
output: {
filename: 'main.js',
libraryTarget: 'commonjs2'
}
});
复制代码
// webpack.umd.config.js
const merge = require('webpack-merge');
const prodConfig = require('./webpack.prod.config.js');
module.exports = merge(prodConfig, {
mode: 'production',
devtool: false,
entry: ['./src/index.js'],
output: {
filename: 'vod-upload-js-sdk.min.js',
library: 'PlvVideoUpload',
libraryTarget: 'umd',
libraryExport: 'default'
}
});
复制代码
因为 SDK 自己不包含界面,为了方便开发过程当中进行调试,加入了一个简单的 demo 来调用 SDK。而且但愿可以在开发过程当中不管是修改了 demo 仍是 SDK 中的代码,均可以实时从新加载。为此,咱们引入了 HtmlWebpackPlugin
插件来建立一个 HTML 文件:
// webpack.dev.config.js
const merge = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const config = require('./webpack.config.js');
module.exports = merge(config, {
devtool: 'inline-source-map',
mode: 'development',
entry: {
polyfill: 'babel-polyfill',
main: './demo/dev.js'
},
plugins: [
new HtmlWebpackPlugin({
template: './demo/dev.html',
inject: true
})
],
devServer: {
host: '0.0.0.0',
port: 14002,
compress: true,
overlay: true,
proxy: {
}
}
});
复制代码
上面的两个 webpack 入口点(polyfill
和 main
),都会出如今生成的 HTML 文件中的 script
标签中。
开发过程当中还发现了一个颇有用的 javascript 基础库——jraiser,SDK 须要的事件驱动机制、ajax 请求接口、MD5 加密算法均可以在这个 npm 插件中找到相应的模块来引入到项目中。更多的介绍能够查看这篇 npm上的文档 以及它的 API 文档。