一、方案背景:
在此前的專案中有個需求是使用者需要透過前端頁面上傳大約1.5G的壓縮包,儲存到OSS,後提供給其他使用者下載。於是我開始了大檔案上傳方案的探索。本文主要探究的是前端技術實現,後端給予相應的支援。
二、 原理探索之路
2.1大檔案上傳想要實現的目標
在此專案中,我想實現的目標是
-
能夠
快速
的將1.5G的檔案上傳到服務端, 由服務端進行儲存,之後提供給其他裝置下載。 -
能夠支援在網路條件不好時實現
斷點續傳
。 -
能夠在不同使用者上傳同一個檔案包時執行
秒傳
。
2.2 實現思路
-
spark-md5 計算檔案的內容
hash
,以此來確定檔案的唯一性 -
將檔案
hash
傳送到服務端進行查詢,以此來確定該檔案在服務端的儲存情況,這裡可以分為三種: 未上傳、已上傳、上傳部分。(前提:分塊大小固定) -
根據服務端返回的狀態執行不同的上傳策略:
-
已上傳: 執行秒傳策略,即快速上傳(實際上沒有對該檔案進行上傳,因為服務端已經有這份檔案了),使用者體驗下來就是上傳得飛快,嗖嗖嗖。。。
-
未上傳、上傳部分: 執行計算待上傳分塊的策略
-
-
併發上傳還未上傳的檔案分塊。
-
當傳完最後一個檔案分塊時,向服務端傳送合併的指令,即完成整個大檔案的分塊合併,實現在服務端的儲存。
整體流程如下:
總結一下:將大檔案透過切分成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的分片,具體的環境資訊如下:
-
網路頻寬: 10M/s
-
伺服器: 2臺 4核32G
各位可根據自己的實際條件,根據網路情況, 合理去制定分塊大小。
4.3 多個客戶端上傳同一個檔案包來縮減上傳時間
大家可以考慮一下如何透過多個客戶端來同時上傳一個檔案,以此來實現更快的上傳?
最後歡迎大家交流學習,最佳化方案,共同成長。留下你的贊👍🏻
備註參考資料: 檔案上傳