大文件上傳實踐分享

一、方案背景:

在此前的項目中有個需求是用戶需要通過前端頁面上傳大約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 多個客戶端上傳同一個文件包來縮減上傳時間

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


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

備註參考資料: 文件上傳

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章