乐趣区

构建基于阿里云OSS服务的web上传SDK

概述

本文主要阐述了如何结合阿里云 OSS 服务实现一个支持断点续传和多文件上传的 web SDK。文章内容配合代码食用风味更佳:https://github.com/polyv/vod-…。

其中,分片上传和断点续传技术由阿里云 OSS Browser.js SDK(下面简称 OSS SDK)提供,具体调用方法可参见阿里云 OSS 的 相关文档。

阿里云 OSS 提供的分片上传(Multipart Upload)和断点续传功能,可以将要上传的文件分成多个数据块(OSS 里又称之为 Part)来分别上传,上传完成之后再调用 OSS 的接口将这些 Part 组合成一个 Object 来达到断点续传的效果。

web 上传 SDK 所做的是结合业务逻辑将相关的上传逻辑封装起来,并提供相关调用方法,以便于其他开发者快速集成。

需求描述

再来更深入地了解一下我们即将要做的是一个怎样的产品:

  1. 提供支持断点续传的上传 SDK,不提供界面,只提供调用方法,方便用户集成。
  2. 提供管理上传队列的方法,包括控制整个文件队列及操作单个文件的方法。
  3. 可以设置上传过程的回调函数,并通过回调返回上传进度、文件信息等数据。
  4. 支持两种 SDK 集成方式:

    1. 通过 script 标签引入在线资源 (http://player.polyv.net/resp/…)
    2. npm 安装:npm install @polyv/vod-upload-js-sdk

更详细的使用文档可以查看这里:@polyv/vod-upload-js-sdk

src 目录下的文件

正文开始前,先来看下 src 目录下的所有文件。其中,UploadManagerPoolPlvVideoUpload 三个类会在后面着重分析。

文件 说明
index.js SDK 入口文件,返回 PlvVideoUpload
pool.js 实现一个控制多个任务同时执行的任务池(Pool
queue.js 实现一个普通队列类(Queue
upload.js 实现一个管理单个文件上传的类(UploadManager
utils.js 工具函数

文件上传流程

了解 OSS 直传

Web 端常见的上传方法是用户在浏览器或 APP 端上传文件到应用服务器,应用服务器再把文件上传到 OSS。相对于这种上传慢、扩展性差、费用高的方式,阿里云官方更推荐将数据直传到 OSS。

阿里云 OSS 直传的三种方案:

  1. JavaScript 客户端签名后直传。
  2. 服务端签名后直传。
  3. 服务端签名后直传并设置上传回调。

这里采用的是第三种直传方案,具体流程如下:

可以看到,Web 端需要做的只有两步:

  1. 向应用服务器请求上传 Policy 和回调。可以在这一步做一些初始化上传文件信息的操作,以及获取业务逻辑需要使用的数据。
  2. 向 OSS 发送文件上传请求,接收上传回调。

上传流程

接下来,我们来了解一下更具体的上传流程。

由于上传过程中使用了由 OSS SDK 提供的分片上传和断点续传技术,我们需要先安装该 SDK,具体的安装方式点击 这里 查看。该 SDK 通过提供 OSS 对象及相关方法来支持上传。

上传的主要流程包括:

  1. 向应用服务器请求上传 Policy 和回调,并获取数据。
  2. 上传之前的业务逻辑处理,包括:判断是否有足够的剩余空间来存储即将上传的文件;以及将发送上传请求需要使用的相关信息保存起来,方便下次从暂停状态开始上传。
  3. 从浏览器本地存储中获取文件的断点信息,初始化 OSS 对象(由 OSS SDK 提供),调用 OSS SDK 方法从断点开始上传文件。

这里的上传流程包括了从未开始和暂停两种状态开始的上传。如果是从暂停状态开始上传,则可以跳过向应用服务器请求 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 进行临时授权

上图中提到的临时访问凭证是因为我们使用了阿里云 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 类的部分功能和实现。

不在上传队列中的文件

上面提到的上传队列只是用于管理可以开始上传的文件,但是如果这时暂停了某个文件的上传,这个文件就应该从上传队列中出队。对于这个暂停状态的文件,我们还需要一个队列来管理类似的情况。

在文件队列中,除了用于管理开始上传或准备上传的文件的 上传队列 uploadPoolPool 类的实例),还应该有一个 等待队列waitQueue)用于管理已经添加到文件队列但未允许开始上传的文件。

有两种情况需要将文件添加到等待队列中进行管理:

  1. 添加文件的时候,如果文件队列处于暂停状态,应该将控制该文件上传的管理器添加到 waitQueue;否则添加到 uploadPool
  2. 指定的某个文件暂停上传后应该将其上传管理器添加到 waitQueue;继续上传时可以从 waitQueue 中根据 id 找到对应的上传管理器(UploadManager 类的实例)。

操作单个文件对整个上传流程的影响

由于 SDK 既支持所有文件的统一操作(开始、暂停),也支持对单个文件的操作,所以还要考虑操作单个文件对整个上传流程的影响。

比较复杂的情况是,整体的文件队列在上传时,操作单个文件需要对上传队列和等待队列重新进行调整,现在总结了几种操作对应的处理如下:

  1. 添加文件:添加到上传队列。
  2. 删除文件:从上传队列中找出该文件并移除。如果正在上传,则需要暂停上传。
  3. 暂停上传文件:从上传队列中找出该文件并移除。如果正在上传,则先暂停上传,然后将该文件添加到等待队列。
  4. 继续 / 开始上传文件:从等待队列中移除该文件,并将该文件添加到上传队列。

除了上述的情况,队列处于暂停状态时操作单个文件也需要对等待队列做相应调整,比较简单,这里就不赘述了。

判断文件队列中的所有文件是否已全部结束上传

文件队列处于上传状态时可操作单个文件上传状态带来的另一个问题是,该如何判断所有文件都已经结束上传呢?

将文件信息添加到上传队列之后,会返回一个 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 事件。

我们来重新整理一下思路:

  1. 每个文件都有其对应的上传管理器 uploader
  2. uploader 入队到上传队列时会返回一个 Promise 实例。
  3. uploader 入队到上传队列有以下几种情况:

    1. 开始上传所有文件。
    2. 开始上传指定文件。
    3. 在文件队列处于上传状态时添加新的文件到文件队列。
    4. 文件上传出错之后在有限次数内重新尝试上传。
  4. 需要将这些情况返回的上传 promise 都集中起来处理。我们这里定义一个数组 newUploadPromiseList 来存放这些上传 promise,以及一个 _onPromiseEnd() 方法来监听 newUploadPromiseList 中所有 promise 的结束。
  5. 执行 _onPromiseEnd 方法的时机:

    1. 在文件队列处于暂停状态时上传指定文件。
    2. 开始上传所有文件。
    3. 在文件队列处于暂停状态时重新尝试上传出错的文件。

第 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 进行打包。

构建 SDK 的配置

通过 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 the module.exports. As the name implies, this is used in CommonJS environments.

更多可选值可以参考 webpack 模块定义系统的文档。

构建 SDK demo 的配置

由于 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-regenerator-runtime',
    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 入口点(polyfillmain),都会出现在生成的 HTML 文件中的 script 标签中。

不要重复造轮子

开发过程中还发现了一个很有用的 javascript 基础库——jraiser,SDK 需要的事件驱动机制、ajax 请求接口、MD5 加密算法都可以在这个 npm 插件中找到相应的模块来引入到项目中。更多的介绍可以查看这篇 npm 上的文档 以及它的 API 文档。

参考资料

  • 阿里云官方文档:Web 端上传介绍
  • 阿里云官方文档:分片上传和断点续传
  • webpack 文档:创建 library
退出移动版