大檔案上傳實踐分享

京东云开发者發表於2024-03-27

一、方案背景:

在此前的專案中有個需求是使用者需要透過前端頁面上傳大約1.5G的壓縮包,儲存到OSS,後提供給其他使用者下載。於是我開始了大檔案上傳方案的探索。本文主要探究的是前端技術實現,後端給予相應的支援。

二、 原理探索之路

2.1大檔案上傳想要實現的目標

在此專案中,我想實現的目標是

  1. 能夠快速的將1.5G的檔案上傳到服務端, 由服務端進行儲存,之後提供給其他裝置下載。

  2. 能夠支援在網路條件不好時實現 斷點續傳

  3. 能夠在不同使用者上傳同一個檔案包時執行秒傳

2.2 實現思路

  1. spark-md5 計算檔案的內容hash,以此來確定檔案的唯一性

  2. 將檔案hash傳送到服務端進行查詢,以此來確定該檔案在服務端的儲存情況,這裡可以分為三種: 未上傳、已上傳、上傳部分。(前提:分塊大小固定)

  3. 根據服務端返回的狀態執行不同的上傳策略:

    • 已上傳: 執行秒傳策略,即快速上傳(實際上沒有對該檔案進行上傳,因為服務端已經有這份檔案了),使用者體驗下來就是上傳得飛快,嗖嗖嗖。。。

    • 未上傳、上傳部分: 執行計算待上傳分塊的策略

  4. 併發上傳還未上傳的檔案分塊。

  5. 當傳完最後一個檔案分塊時,向服務端傳送合併的指令,即完成整個大檔案的分塊合併,實現在服務端的儲存。
    整體流程如下:

image.png
總結一下:將大檔案透過切分成N個小檔案,透過併發多個HTTP請求,實現快速上傳;在每次上傳前計算檔案hash,帶著這個檔案hash去服務端查詢該檔案在服務端的儲存狀態,透過狀態來判斷需要上傳的分塊,實現斷點續傳、秒傳。

三、實踐之路

3.1 檔案hash計算

本專案中計算檔案hash的使用spark-md5

import SparkMD5 from 'spark-md5' 

const CHUNK_SIZE = 1024 * 1024 * 5   // 5M
// 對大檔案進行分片
function sliceFile2chunk(file) {
  const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
  const fileChunks = []
  if (file.size <= CHUNK_SIZE) {
    fileChunks.push({ file })
  } else {
    let chunkStartIndex = 0
    while (chunkStartIndex < file.size) {
      fileChunks.push({ file: blobSlice(file, chunkStartIndex, chunkStartIndex + CHUNK_SIZE) })
      chunkStartIndex = chunkStartIndex + CHUNK_SIZE
    }
  }
  return fileChunks
}

function getFileHash(file) {
  let hashProcess = 0
  let fileHash = null
  // 這裡需要使用非同步執行,保證獲取到hash後執行下一步
  return new Promise((resolve) => {
    const fileChunks = sliceFile2Chunk(file)
    const spark = new SparkMD5.ArrayBuffer()
    let hadReadChunksNum = 0
    const readFile = (chunkIndex) => {
      const fileReader = new FileReader()
      fileReader.readAsArrayBuffer(fileChunks[chunkIndex]?.file)
      fileReader.onload = (e) => {
        hadReadChunksNum++
        spark.append(e.target.result)
        if (hadReadChunksNum === fileChunks.length) {
          hashProcess = 100
          fileHash = spark.end()
          fileReader.onload = null
          resolve(fileHash)
        } else {
          hashProcess = Math.floor((hadReadChunksNum / fileChunks.length) * 100);
          readFile(hadReadChunksNum)
        }
      }
    }
    readFile(0)
  })
}
// await 用於表示這裡是一個非同步操作
const fileHash = await getFileHash(file)
const fileChunks = sliceFile2chunk(file)

這裡將檔案hash傳送給服務端,獲取服務端對該檔案的儲存狀態

// 採用表單形式提交資料,不是必須這樣
const fileInfo = new FormData()  
fileInfo.append('fileHash', fileHash)
fileInfo.append('fileName', name)

// getFileStatusFn是向服務端請求的檔案初始狀態的 http 方法, await 標識這裡是一個非同步請求
const res = await getFileStatusFn(fileInfo)  

3.2 根據服務端返回的狀態執行不同的上傳策略

根據服務端返回的狀態,來計算出需要上傳的檔案分塊,以分塊下標來區分不同的塊。

  • 0 未上傳

  • 1 上傳部分

  • 2 上傳完成

    // 這裡的 res 是檔案在服務端的狀態
    function createWait2UploadChunks(res) {
      if (res.data) {
        const wait2UploadChunks = []
        if (res.data.result === 0 ) {
          // 3.1中得到的檔案 chunks
          fileChunks.forEach((item, index) => {
            const chunk = formateChunk(item, index)
            wait2UploadChunks.push(chunk)
          }, this)
        }
        if (res.data.result === 1) {
          const restFileChunksIndex = []
          // tagList 是服務端返回的已上傳的檔案塊標識 型別是Array
          res.data.tagList.forEach((item) => {
            restFileChunksIndex.push(item.index)
          }, this)
          fileChunks.forEach((item, index) => {
            if (!restFileChunksIndex.includes(index)) {
              const chunk = formateChunk(item, index)
              wait2UploadChunks.push(chunk)
            }
          })
        }
        if(res.data.result === 2) {
          console.log('執行自定義的秒傳操作')
        }
        return wait2UploadChunks
      }
    }
    
    // 該函式式對檔案塊進行標準化,這裡可以與後端做協商得出的,看後端需要什麼樣的資料
    function formateChunk(item, index) {
      const chunkFormData = new FormData()
      chunkFormData.append("file", item.file);
      chunkFormData.append("index", index);
      chunkFormData.append("partSize", item.file.size);
      chunkFormData.append("fileHash", fileHash);
      return chunkFormData
    }
    // 入參是 3.2 得到的response, 出參事最終需要上傳的分片
    const wait2UploadChunks = createWait2UploadChunks(res)  

3.3 併發上傳還未上傳的檔案分塊

這一步主要是將待上傳的分塊傳輸到服務端, 這裡採用併發5(頁面資源請求時,瀏覽器會同時和伺服器建立多個TCP連線,在同一個TCP連線上順序處理多個HTTP請求。所以瀏覽器的併發性就體現在可以建立多個TCP連線,來支援多個http同時請求。Chrome瀏覽器最多允許對同一個域名Host建立6個TCP連線,不同的瀏覽器有所區別。)個HTTP請求的方式進行上傳,每當有一個請求完成後就新增一個分塊傳輸請求,確保一直併發5個請求。

   const currentHttpNum = 0
   const maxHttpNum = 5
   const hasUploadedChunkNum = 0 
   const nextChunkIndex = 4
   const uploadProcess = 0
   uploadFileChunks()
   function uploadFileChunks() {
      wait2UploadChunks.slice(0, maxHttpNum).forEach((item) => {
        uploadFileChunk(item)
      }, this)
    }
    async function uploadFileChunk(chunkFormData) {
      try {
        currentHttpNum++
        const res = await uploadChunkFn(chunkFormData)  // uploadChunkFn是執行檔案上傳的HTTP請求
        currentHttpNum--
        if (res.code === 200) {
          if (hasUploadedChunkNum < wait2UploadChunks.length) {
            hasUploadedChunkNum++
          }
          if (wait2UploadChunks.length > ++nextChunkIndex) {
            uploadFileChunk(wait2UploadChunks[nextChunkIndex])
          }
          uploadProcess = Math.floor((hasUploadedChunkNum / wait2UploadChunks.length) * 100)
          if (currentHttpNum <= 0) {
          // 定義在 3.5
            mergeChunks()  // 第五步執行的函式
          }
        }
      } catch (error) {
        console.log(error);
      }
    }

3.4 向服務端傳送合併的指令

當最後一個分塊完成傳輸時,執行合併指令

   async mergeChunks() {
      try {
        const res = await mergeChunkFn({     //mergeChunkFn 是HTTP請求
          fileHash: fileHash,
        })
      } catch (error) {
        console.log(error);
      }
    }

四、可最佳化點

4.1 hash計算最佳化

hash計算可以利用 web worker 協程來計算,這裡提供一下worker的實現:

// worker.js
self.addEventListener('message', function (e) {
  self.postMessage('You said: ' + e.data);
}, false);
self.close()   // self代表子執行緒自身,即子執行緒的全域性物件

//  主執行緒
const worker = new Worker('./worker.js')  // 傳入的是一個指令碼
worker.postMessage('Hello World');
worker.onmessage = function (e) {
	console.log(e.data);
}

4.2 分塊大小合理化

本專案實測用的5M的分片,具體的環境資訊如下:

  1. 網路頻寬: 10M/s

  2. 伺服器: 2臺 4核32G

各位可根據自己的實際條件,根據網路情況, 合理去制定分塊大小。

4.3 多個客戶端上傳同一個檔案包來縮減上傳時間

大家可以考慮一下如何透過多個客戶端來同時上傳一個檔案,以此來實現更快的上傳?


最後歡迎大家交流學習,最佳化方案,共同成長。留下你的贊👍🏻

備註參考資料: 檔案上傳

相關文章