基於s3對象存儲多文件分片上傳的Javascript實現

基於s3對象存儲多文件分片上傳的Javascript實現

Contents

  1. 概述

  2. 瀏覽器文件操作限制

  3. 前端多文件分片上傳的原理和實現

預覽

概述

Amazon S3 提供了一個簡單 Web 服務接口,可用於隨時在 Web 上的任何位置存儲和檢索任何數量的數據。此服務讓所有開發人員都能訪問同一個具備高擴展性、可靠性、安全性和快速價廉的數據存儲基礎設施, Amazon 用它來運行其全球的網站網絡。此服務旨在爲開發人員帶來最大化的規模效益。
本文主要針對兼容aws-s3接口的第三方存儲服務,在不使用官方sdk的情況下直接使用Restful接口進行存儲桶多文件分片上傳,主要包含瀏覽器端的多文件分片上傳邏輯的Javascript代碼實現。

瀏覽器文件操作限制

  • HTML5新特性input[type=file]支持調用瀏覽器文件訪問窗口來獲取文件數據,實際上JS代碼使用此特性訪問本地文件系統後拿到的是一個指向文件的引用地址,且如果頁面刷新了那麼這個地址不可複用,JS代碼並沒有實際操作文件本身。前端上傳數據時根據這個指向文件的地址把文件的一小塊分片數據載入到內存並通過Ajax請求發送到中間件進行處理。
  • 瀏覽器JS代碼沒有文件系統操作權限,不能任意存儲和讀取文件,因此不支持刷新瀏覽器後上傳進度斷點恢復,刷新之後斷點恢復的前提是能拿到文件數據,但是JS代碼沒權限訪問之前拿到的文件引用地址,並且存儲之前上傳過的文件分片數據這一做法也不合理。
  • 相對於文件上傳,文件下載則完全不可控,由於文件操作權限,所以整個下載文件操作都是由瀏覽器自帶的的下載任務管理器控制的,沒有瀏覽器接口能拿到這些下載任務進度,所以下載任務進度也是不能獲取的。

前端多文件分片上傳的原理和實現

完整Github源碼

使用了React16/Webpack4/Mobx狀態管理庫

  • 支持批量文件分割並行上傳
  • 多文件操作:暫停/恢復/終止/續傳/重傳
  • 自定義上傳任務數目、單個分片大小

運行流程圖

主要流程

  1. cacheFile
    前端通過input組件拿到所有文件地址並緩存起來。
 /**
   * [cacheFile 緩存即將註冊的文件]
   */
  @action
  cacheFile = (files, bucket) => {
    const symbolArr = this.filesCache.map(file => this.getSymbol(file));
    const filtedFiles = [];
    let uploadingFileFound = false;
    files.forEach((file) => {
      if (!symbolArr.includes(this.getSymbol(file))) {
        if (this.findIsUploading(this.getSymbol(file), bucket)) {
          uploadingFileFound = true;
          filtedFiles.push(file.name);
        } else {
          this.filesCache.push(file);
          symbolArr.push(this.getSymbol(file));
        }
      }
    });
    if (!files.length) openNotification('warning', null, this.lang.lang.noFilCanBeUploaded);
    if (uploadingFileFound) openNotification('warning', null, this.lang.lang.uploadingFileReuploadTips + filtedFiles.join(', '));
  }
  1. registry
    根據上一步拿到的文件地址數組創建多個Mobx observable對象跟蹤每個上傳對象的基本識別信息,包括文件名、文件大小、類型、分片信息(分片大小和總分片數)、上傳狀態信息:uninitial(未初始化)/pending(準備)/uploading(上傳中)/pause(暫停)/error(錯誤)/break(上傳完成)、上傳開始時間、上傳完成時間,爲了便於訪問這些Mobx observable對象,建立一個weakMap存儲file對象和observable對象的弱映射關係。
/**
   * [registry 註冊上傳文件信息]
   * @param {[Object]} file [文件對象]
   * @param {[String]} uploadId [文件上傳進程id]
   * @param {[Object]} state [文件初始化狀態]
   */
  @action registry = (files, region, prefix) => {
    let fileObj = null;
    this.loading = true;
    files.forEach((file) => {
      if (this.files.includes(file)) {
        return;
      }
      this.files.push(file);
      fileObj = {
        name: file.webkitRelativePath || file.name,
        prefix: prefix || '',
        size: file.size,
        type: file.type || mapMimeType((file.webkitRelativePath || file.name).split('.').pop()).type,
        state: 'uninitial',
        creationTime: '',
        completionTime: '',
        index: 0,
        file,
        initialized: false,
        partEtags: [],
        region,
        blockSize: this.blockSize,
        total: Math.ceil(file.size / this.blockSize),
        activePoint: new Date(),
        speed: '0 MB/S',
        id: encodeURIComponent(new Date() + file.name + file.type + file.size),
      };
      this.taskType.uninitial.push(file);
      this.taskType.series.push(file);
      const obj = observable(fileObj);
      if (!this.fileStorage.get(region)) {
        this.fileStorage.set(region, [obj]);
      } else {
        this.fileStorage.get(region).push(obj);
      }
      this.fileStorageMap.set(file, obj);
    });
    this.loading = false;
  }
  1. startTasks
    獲取文件隊列中可用於上傳的文件對象,根據文件狀態對其做初始化或切割文件上傳的操作,同時實時修改對應的Mobx observable上傳對象的元數據標識,包括當前上傳文件的分片索引(單個文件上傳進度=分片索引/總分片數目)、已上傳完成的分片etag信息(由服務器返回,可用於完成分片上傳時校驗已上傳的所有分片數據是否匹配)、當前上傳對象4的上傳狀態(uninitial/pending/uploading/pause/eror/break)、當前上傳對象的上傳速度(速度=單個分片大小/單個分片上傳所用時間)。
/**
   * [startTasks 開啓上傳任務隊列]
   * @param  {[String]} region [桶名]
   */
  startTasks = (region) => {
    // 根據空閒任務類型和空閒任務併發限制開啓空閒任務
    this.refreshTasks(region);
    if (this.isUploadListEmpty(region)) return;

    const maxLength = this.multiTaskCount - this.taskType.uploading.length;
    const taskSeries = [];
    for (let i = 0; i < (maxLength) && this.taskType.series[i]; i += 1) {
      // const file = this.taskType.series.shift();
      const file = this.taskType.series[i];
      const storageObject = this.fileStorageMap.get(file);
      if (storageObject.state === 'uploading') continue; // 上傳中
      if (storageObject.state === 'pause') continue;
      taskSeries.push(storageObject);
    }

    let index;
    taskSeries.forEach((storageObject) => {
      index = this.taskType.series.indexOf(storageObject.file);
      index !== -1 && this.taskType.series.splice(index, 1);
      if (this.taskType.uninitial.includes(storageObject.file)) {
        this.initRequest(
          storageObject.file,
          {
            bucket: region,
            object: storageObject.name,
            prefix: storageObject.prefix,
          }
        ).then(({ err, init }) => {
          if (!err && init) {
            this.upload(storageObject.file, {
              bucket: region,
              object: storageObject.name,
              prefix: storageObject.prefix,
              uploadId: storageObject.uploadId,
            });
          }
        });
      } else {
        this.upload(storageObject.file, {
          bucket: region,
          object: storageObject.name,
          prefix: storageObject.prefix,
          uploadId: storageObject.uploadId,
        });
      }
    });
  }
  1. refreshTasks
    根據當前設置的並行上傳任務數目和正在上傳的任務數目及時從文件預備上傳隊列提取文件放入上傳可調用文件隊列。
/* 刷線任務列表 */
  @action
  refreshTasks = (region) => {
    // 統計空閒任務
    const storageObject = this.fileStorage.get(region);
    if (!storageObject) return;
    for (let i = 0; i < storageObject.length; i += 1) {
      if (
        storageObject[i].index !== storageObject[i].total
        && (storageObject[i].state === 'pending'
        || storageObject[i].state === 'uninitial')
      ) {
        const { file } = storageObject[i];
        if (!this.taskType.series.includes(file)) {
          this.taskType.series.push(file);
        }
      }
    }
  }
  1. upload & update
    根據當前文件對象的上傳分片索引對文件進行切割並更新索引,然後把切割下來的數據通過Ajax請求發送給中間件處理,中間件發送到後臺後返回得到的當前分片的etag信息,前端拿到etag信息並存儲到當前上傳對象分片etag信息數組裏面。
/**
   * [upload 分割文件發起上傳請求]
   * @param  {[Object]} file    [description]
   * @param  {[Object]} _params [...]
   * @param  {[String]}   _params.bucket [bucket name]
   * @param  {[String]}   _params.object [object name]
   * @param  {[String]}   _params.uploadId [upload id]
   */
  @action
  upload = (file, _params) => {
    const storageObject = this.fileStorageMap.get(file);
    let single = false; // 不分片
    /* 異常狀態退出 */
    if (!this.isValidUploadingTask(storageObject)) return;

    if (storageObject.state === 'pending') {
      this.taskType.pending.splice(this.taskType.pending.indexOf(file), 1);
      this.taskType.uploading.push(file);
      storageObject.state = 'uploading';
    }


    const num = storageObject.index;

    if (num === 0 && file.size <= storageObject.blockSize) {
      // 不用分片的情況
      single = true;
    } else if (num === storageObject.total) {
      // 所有分片都已經發出
      return;
    }
    const nextSize = Math.min((num + 1) * storageObject.blockSize, file.size);
    const fileData = file.slice(num * storageObject.blockSize, nextSize);
    const params = Object.assign(_params, {
      partNumber: num + 1,
    });
    storageObject.activePoint = new Date();
    this.uploadRequest({ params, data: fileData, single }).then((rsp) => {
      if (rsp.code !== 200) {
        openNotification('error', null, (rsp.result.data ? rsp.result.data.Code : this.lang.lang.uploadError));
        this.markError(file);
        this.startTasks(params.bucket);
        return;
      }
      const { completed, etags } = this.update({
        region: params.bucket,
        etag: rsp.result.etag,
        size: fileData.size,
        id: storageObject.id,
        index: params.partNumber,
      });
      if (completed) {
        (single ?
          () => {
            this.complete(file, params.bucket);
          } :
          (partEtags) => {
            this.completeRequest({
              bucket: params.bucket,
              uploadId: params.uploadId,
              object: params.object,
              prefix: params.prefix,
              partEtags,
            }, file);
          })(etags);
      } else {
        this.upload(file, {
          bucket: params.bucket,
          object: params.object,
          uploadId: params.uploadId,
          partNumber: params.partNumber,
          prefix: params.prefix,
        });
      }
    }).catch((error) => {
      this.markError(file);
      this.startTasks(params.bucket);
      console.log(`${params.bucket}_${params.object} upload error: ${error}`);
    });
    storageObject.index += 1;
  }

  1. complete
    當最後一個分片上傳請求完成返回後,我們就拿到了服務端返回的這個文件的所有分片etag信息,前端需要校驗當前上傳對象etag數組的長度是否匹配,數組內每個etag元素的索引和etag值是否匹配,校驗完成後發送最後一個請求到後端進行校驗和組裝分片,最終完成一個文件的分片上傳過程。
/**
   * [completeRequest 完成所有分片數據上傳]
   * @param  {[Object]} _params [...]
   * @param  {[String]}   _params.bucket [bucket name]
   * @param  {[String]}   _params.object [object name]
   * @param  {[String]}   _params.uploadId [upload id]
   * @param  {[String]}   _params.partEtags [upload id]
   * @param  {[Object]} file [文件對象]
   */
  @action completeRequest = (params, file) => {
    postDataPro(
      {
        ...{
          ...params,
          ...{
            object: params.prefix + params.object,
          },
        },
        partEtags: {
          CompleteMultipartUpload: {
            Part: params.partEtags.map(info => ({
              PartNumber: info.number,
              ETag: info.etag,
            })),
          },
        },
      },
      objectResourceApi.object.completeFragmentUpload
    ).then((data) => {
      this.complete(file, params.bucket);
    }).catch((error) => {
      this.startTasks(params.bucket);
      this.markError(file);
    });
  }

  /**
   * [complete 完成上傳]
   * @param {[Object]} file [文件對象]
   * @param {[String]} bucket [桶名]
   */
  @action
  complete = (file, bucket) => {
    const index = this.taskType.uploading.indexOf(file);
    this.taskType.uploading.splice(index, 1);
    this.taskType.break.push(file);
    const storageObject = this.fileStorageMap.get(file);
    storageObject.completionTime = (new Date().toTimeString()).split(' ')[0];
    storageObject.state = 'break';
    storageObject.index = storageObject.total;

    this.startTasks(bucket);
  };

其它操作

  1. 暫停文件上傳
    將上傳對象的狀態從uploading置爲pause,然後把該對象對應的文件從可調用上傳文件隊列移除。

  2. 開始暫停的上傳任務
    將上傳對象的狀態從pause置爲pending,然後把該對象對應的文件放入可調用上傳文件隊列,等待下一次刷新文件上傳任務隊列。

  3. 續傳上傳錯誤的任務
    將上傳對象的狀態從error置爲pending,然後把該對象對應的文件放入可調用上傳文件隊列,保持文件的已上傳分片索引記錄,等待下一次刷新文件上傳任務隊列,直接調用上傳函數進行切割並上傳。

  4. 重傳上傳錯誤的任務
    將上傳對象的狀態從error置爲pending,然後把該對象對應的文件放入可調用上傳文件隊列,並將文件已上傳分片索引記錄置爲初始狀態,等待下一次刷新文件上傳任務隊列,從文件初始位置重新開始切割文件並上傳。

一些關鍵代碼

  1. 一個分片上傳完成後將後臺返回的etag信息更新到本地的上傳對象屬性,並判斷此文件是否上傳完成。
/**
   * [update 更新本地上傳記錄]
   * @param {[String]} region [桶名]
   * @param {[String]} etag [分片標誌]
   */
  @action
  update = ({
    region, etag, size, id, index,
  }) => {
    const target = this.fileStorage.get(region);
    for (let i = 0; i < target.length; i += 1) {
      if (target[i].id === id) {
        target[i].speed = `${(size / 1024 / 1024 / (((new Date() - target[i].activePoint) / 1000))).toFixed(2)} MB/S`;
        if (target[i].speed === '0.00 MB/S') {
          target[i].speed = `${formatSizeStr(size)}/S`;
        }
        target[i].partEtags = target[i].partEtags.filter(etagItem => etagItem.number !== index);
        target[i].partEtags.push({
          number: index,
          etag,
        });
        // 最後一個分片恰好又暫停的情況
        if (index === target[i].total) {
          if (target[i].state === 'pause') {
            index -= 1;
          }
        }
        // 判斷上傳是否完成
        if (target[i].total === 0 || target[i].partEtags.toJS().length === target[i].total) {
          return {
            completed: true,
            etags: target[i].partEtags,
          };
        }
        return {
          completed: false,
        };
      }
    }
  }
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章