構建基於阿里雲OSS服務的web上傳SDK

概述

本文主要闡述瞭如何結合阿里雲 OSS 服務實現一個支持斷點續傳和多文件上傳的 web SDK。文章內容配合代碼食用風味更佳:https://github.com/polyv/vod-...javascript

其中,分片上傳和斷點續傳技術由阿里雲 OSS Browser.js SDK(下面簡稱 OSS SDK)提供,具體調用方法可參見阿里雲 OSS 的 相關文檔html

阿里雲 OSS 提供的分片上傳(Multipart Upload)和斷點續傳功能,能夠將要上傳的文件分紅多個數據塊(OSS 裏又稱之爲 Part)來分別上傳,上傳完成以後再調用 OSS 的接口將這些 Part 組合成一個 Object 來達到斷點續傳的效果。

web 上傳 SDK 所作的是結合業務邏輯將相關的上傳邏輯封裝起來,並提供相關調用方法,以便於其餘開發者快速集成。java

需求描述

先來了解一下咱們即將要作的是一個怎樣的產品 :webpack

  1. 提供支持斷點續傳的上傳 SDK,不提供界面,只提供調用方法,方便用戶集成。
  2. 提供管理上傳隊列的方法,包括控制整個文件隊列及操做單個文件的方法。
  3. 能夠設置上傳過程的回調函數,並經過回調返回上傳進度、文件信息等數據。
  4. 支持兩種 SDK 集成方式:git

    1. 經過 script 標籤引入在線資源 ( http://player.polyv.net/resp/... )
    2. npm 安裝:npm install @polyv/vod-upload-js-sdk

更詳細的使用文檔能夠查看這裏: @polyv/vod-upload-js-sdkgithub

src 目錄下的文件

正文開始前,先來看下 src 目錄下的全部文件。其中,UploadManagerPoolPlvVideoUpload 三個類會在後面着重分析。web

文件 說明
index.js SDK 入口文件,返回 PlvVideoUpload
pool.js 實現一個控制多個任務同時執行的任務池(Pool
queue.js 實現一個普通隊列類(Queue
upload.js 實現一個管理單個文件上傳的類(UploadManager
utils.js 工具函數

文件上傳流程

瞭解 OSS 直傳

Web 端常見的上傳方法是用戶在瀏覽器或 APP 端上傳文件到應用服務器,應用服務器再把文件上傳到 OSS。相對於這種上傳慢、擴展性差、費用高的方式,阿里雲官方更推薦將數據直傳到 OSS。ajax

阿里雲 OSS 直傳的三種方案:算法

  1. JavaScript 客戶端簽名後直傳。
  2. 服務端簽名後直傳。
  3. 服務端簽名後直傳並設置上傳回調。

這裏採用的是第三種直傳方案,具體流程以下:npm

OSS 直傳流程

能夠看到,Web 端須要作的只有兩步:

  1. 嚮應用服務器請求上傳 Policy 和回調。能夠在這一步作一些初始化上傳文件信息的操做,以及獲取業務邏輯須要使用的數據。
  2. 向 OSS 發送文件上傳請求,接收 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 截圖所示:

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 中使用的兩種配置方式,主要區別在於 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。而且但願可以在開發過程當中不管是修改了 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 入口點(polyfillmain),都會出如今生成的 HTML 文件中的 script 標籤中。

不要重複造輪子

開發過程當中還發現了一個頗有用的 javascript 基礎庫——jraiser,SDK 須要的事件驅動機制、ajax 請求接口、MD5 加密算法均可以在這個 npm 插件中找到相應的模塊來引入到項目中。更多的介紹能夠查看這篇 npm上的文檔 以及它的 API 文檔

參考資料

相關文章
相關標籤/搜索