自己實現一個大文件切片上傳 - 斷點續傳

PM:喂,那個切圖仔,我這裏有個 100G 的視頻要上傳,你幫我做一個上傳後臺,下班前給我哦,辛苦了。

我:。。。

相信每個切圖工程師,都接觸過文件上傳的需求,一般的小文件,我們直接使用 input file,然後構造一個 new FormData()對象,扔給後端就可以了。如果使用了 Ant design 或者 element ui 之類的 ui 庫,那更簡單,直接調用一下 api 即可。當然了,複雜一些的,市面上也有不少優秀的第三方插件,比如 WebUploader。但是作爲一個有追求的工程師,怎麼能僅僅滿足於使用插件呢,今天我們就來自己實現一個。

首先我們來分析一下需求

一個上傳組件,需要具備的功能:

  1. 需要校驗文件格式

  2. 可以上傳任何文件,包括超大的視頻文件(切片)

  3. 上傳期間斷網後,再次聯網可以繼續上傳(斷點續傳)

  4. 要有進度條提示

  5. 已經上傳過同一個文件後,直接上傳完成(秒傳)

前後端分工:

  1. 文件格式校驗

  2. 文件切片、md5 計算

  3. 發起檢查請求,把當前文件的 hash 發送給服務端,檢查是否有相同 hash 的文件

  4. 上傳進度計算

  5. 上傳完成後通知後端合併切片

  1. 檢查接收到的 hash 是否有相同的文件,並通知前端當前 hash 是否有未完成的上傳

  2. 接收切片

  3. 合併所有切片

架構圖如下

接下來開始具體實現

一、 格式校驗

對於上傳的文件,一般來說,我們要校驗其格式,僅需要獲取文件的後綴(擴展名),即可判斷其是否符合我們的上傳限制:

  //文件路徑
  var filePath = "file://upload/test.png";
  //獲取最後一個.的位置
  var index= filePath.lastIndexOf(".");
  //獲取後綴
  var ext = filePath.substr(index+1);
  //輸出結果
  console.log(ext);
  // 輸出:png

但是,這種方式有個弊端,那就是我們可以隨便篡改文件的後綴名,比如:test.mp4 ,我們可以通過修改其後綴名:test.mp4 -> test.png ,這樣即可繞過限制進行上傳。那有沒有更嚴格的限制方式呢?當然是有的。

那就是通過查看文件的二進制數據來識別其真實的文件類型,因爲計算機識別文件類型時,並不是真的通過文件的後綴名來識別的,而是通過 “魔數”(Magic Number)來區分,對於某一些類型的文件,起始的幾個字節內容都是固定的,根據這幾個字節的內容就可以判斷文件的類型。藉助十六進制編輯器,可以查看一下圖片的二進制數據,我們還是以test.png爲例:

由上圖可知,PNG 類型的圖片前 8 個字節是 0x89 50 4E 47 0D 0A 1A 0A。基於這個結果,我們可以據此來做文件的格式校驗,以 vue 項目爲例:

  <template>
  <div>
    <input
      type="file"
      id="inputFile"
      @change="handleChange"
    />
  </div>
</template>

<script>
export default {
  name: "HelloWorld",
  methods: {
    check(headers) {
      return (buffers, options = { offset: 0 }) =>

      headers.every(

      (header, index) =header === buffers[options.offset + index]

      );
    },
    async handleChange(event) {
      const file = event.target.files[0];

      // 以PNG爲例,只需要獲取前8個字節,即可識別其類型
      const buffers = await this.readBuffer(file, 0, 8);

      const uint8Array = new Uint8Array(buffers);

      const isPNG = this.check([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);

      // 上傳test.png後,打印結果爲true
      console.log(isPNG(uint8Array))

    },
    readBuffer(file, start = 0, end = 2) {
      // 獲取文件的二進制數據,因爲我們只需要校驗前幾個字節即可,所以並不需要獲取整個文件的數據
        return new Promise((resolve, reject) ={
          const reader = new FileReader();

          reader.onload = () ={
            resolve(reader.result);
          };

          reader.onerror = reject;

          reader.readAsArrayBuffer(file.slice(start, end));
        });
    }
  }
};
</script>

以上爲校驗文件類型的方法,對於其他類型的文件,比如 mp4,xsl 等,大家感興趣的話,也可以通過工具查看其二進制數據,以此來做格式校驗。

以下爲彙總的一些文件的二進制標識:

  1.JPEG/JPG - 文件頭標識 (2 bytes): ff, d8 文件結束標識 (2 bytes): ff, d9
  2.TGA - 未壓縮的前 5 字節 00 00 02 00 00 - RLE 壓縮的前 5 字節 00 00 10 00 00
  3.PNG - 文件頭標識 (8 bytes) 89 50 4E 47 0D 0A 1A 0A
  4.GIF - 文件頭標識 (6 bytes) 47 49 46 38 39(37) 61
  5.BMP - 文件頭標識 (2 bytes) 42 4D B M
  6.PCX - 文件頭標識 (1 bytes) 0A
  7.TIFF - 文件頭標識 (2 bytes) 4D 4D 或 49 49
  8.ICO - 文件頭標識 (8 bytes) 00 00 01 00 01 00 20 20
  9.CUR - 文件頭標識 (8 bytes) 00 00 02 00 01 00 20 20
  10.IFF - 文件頭標識 (4 bytes) 46 4F 52 4D
  11.ANI - 文件頭標識 (4 bytes) 52 49 46 46

二、 文件切片

假設我們要把一個 1G 的視頻,分割爲每塊 1MB 的切片,可定義 DefualtChunkSize = 1 * 1024 * 1024,通過 spark-md5來計算文件內容的 hash 值。那如何分割文件呢,使用文件對象 File 的方法File.prototype.slice即可。

需要注意的是,切割一個較大的文件,比如 10G,那分割爲 1Mb 大小的話,將會生成一萬個切片,衆所周知,js 是單線程模型,如果這個計算過程在主線程中的話,那我們的頁面必然會直接崩潰,這時,就該我們的 Web Worker 來上場了。

Web Worker 的作用,就是爲 JavaScript 創造多線程環境,允許主線程創建 Worker 線程,將一些任務分配給後者運行。在主線程運行的同時,Worker 線程在後臺運行,兩者互不干擾。具體的作用,不瞭解的同學可以自行去學些一下。這裏就不展開講了。

以下爲部分關鍵代碼:

  // upload.js

  // 創建一個worker對象
  const worker = new worker('worker.js')
  // 向子線程發送消息,並傳入文件對象和切片大小,開始計算分割切片
  worker.postMessage(file, DefualtChunkSize)

  // 子線程計算完成後,會將切片返回主線程
  worker.onmessage = (chunks) ={
    ...
  }

子線程代碼:

  // worker.js

  // 接收文件對象及切片大小
  onmessage (file, DefualtChunkSize) ={
    let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice,
      chunks = Math.ceil(file.size / DefualtChunkSize),
      currentChunk = 0,
      spark = new SparkMD5.ArrayBuffer(),
      fileReader = new FileReader();

    fileReader.onload = function (e) {
      console.log('read chunk nr', currentChunk + 1, 'of');

      const chunk = e.target.result;
      spark.append(chunk);
      currentChunk++;

      if (currentChunk < chunks) {
        loadNext();
      } else {
        let fileHash = spark.end();
        console.info('finished computed hash', fileHash);
        // 此處爲重點,計算完成後,仍然通過postMessage通知主線程
        postMessage({ fileHash, fileReader })
      }
    };

    fileReader.onerror = function () {
      console.warn('oops, something went wrong.');
    };

    function loadNext() {
      let start = currentChunk * DefualtChunkSize,
        end = ((start + DefualtChunkSize) >= file.size) ? file.size : start + DefualtChunkSize;
      let chunk = blobSlice.call(file, start, end);
      fileReader.readAsArrayBuffer(chunk);
    }

    loadNext();
  }

以上利用 worker 線程,我們即可得到計算後的切片,以及 md5 值。

三、 斷點續傳 + 秒傳 + 上傳進度計算

在拿到切片和 md5 後,我們首先去服務器查詢一下,是否已經存在當前文件。

  1. 如果已存在,並且已經是上傳成功的文件,則直接返回前端上傳成功,即可實現 "秒傳"。

  2. 如果已存在,並且有一部分切片上傳失敗,則返回給前端已經上傳成功的切片 name,前端拿到後,根據返回的切片,計算出未上傳成功的剩餘切片,然後把剩餘的切片繼續上傳,即可實現 "斷點續傳"。

  3. 如果不存在,則開始上傳,這裏需要注意的是,在併發上傳切片時,需要控制併發量,避免一次性上傳過多切片,導致崩潰。

// 檢查是否已存在相同文件
   async function checkAndUploadChunk(chunkList, fileMd5Value) {
    const requestList = []
    // 如果不存在,則上傳
    for (let i = 0; i < chunkList; i++) {
      requestList.push(upload({ chunkList[i], fileMd5Value, i }))
    }

    // 併發上傳
    if (requestList?.length) {
      await Promise.all(requestList)
    }
  }

 // 上傳chunk
  function upload({ chunkList, chunk, fileMd5Value, i }) {
    current = 0
    let form = new FormData()
    form.append("data", chunk) //切片流
    form.append("total", chunkList.length) //總片數
    form.append("index", i) //當前是第幾片     
    form.append("fileMd5Value", fileMd5Value)
    return axios({
      method: 'post',
      url: BaseUrl + "/upload",
      data: form
    }).then(({ data }) ={
      if (data.stat) {
        current = current + 1
        // 獲取到上傳的進度
        const uploadPercent = Math.ceil((current / chunkList.length) * 100)
      }
    })
  }

在以上代碼中,我們在上傳切片的同時,也會告訴後端當前上傳切片的 index,後端接收後,記錄該 index 以便在合併時知道切片的順序。

當所有切片上傳完成後,再向後端發送一個上傳完成的請求,即通知後端把所有切片進行合併,最終完成整個上傳流程。

大功告成!由於篇幅有限,本文主要講了前端的實現思路,最終落地成完整的項目,還是需要大家根據真實的項目需求來實現。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/SyP0zUsRHsTDyEQoab4fXg