大檔案上傳原理及實現方案 | 京東物流技術團隊

發表於2024-02-12

一、什麼是大檔案 一般,我們傳送大檔案是指傳送大於100M的檔案,而普通檔案是指小於100M,常見的是20M、30M和50M,兩者主要的區別在於檔案大小上,還有傳送速度上。

一般普通“郵件附件”只能發20M、30M,50M的檔案,而幾百M的照片、檔案、設計圖等大檔案傳送起來就不是那麼容易了。

二、大檔案跟普通檔案上傳時的區別 普通檔案上傳只需要注意兩點

1.指定上傳的介面地址。 2.將請求頭的Content-Type設定成:multipart/form-data,將檔案物件以二進位制流的形式傳給後端

大檔案上傳時會遇到的問題

1.前後端上傳請求超時限制,一次性傳輸大小限制。 2.網路抖動等,失敗後需要重新上傳。 3.http1.1版本, TCP連線預設是open的,所有請求都透過同一個連線進行資料傳輸,如果前面的請求被阻塞了,後面的請求也得不到響應,也叫HTTP/1.1 中的隊頭阻塞問題,除非建立多個連線,但是多個連線會浪費資源。 4.無進度條,使用者體驗極差。

三、大檔案上傳的原理及思路 前端

獲取檔案的二進位制內容,然後對其內容拆分成指定大小的切片檔案,最後將每個切片上傳到服務端即可。

流程:獲取檔案 ➡️ 分片 ➡️ 上傳

需要最佳化的點

•中斷後無需重新上傳(斷點續傳) •上傳過的檔案無需上傳(秒傳) •顯示上傳進度

後端

根據切片檔案的唯一標識在後端將多個相同檔案的切片還原成一個檔案

流程:獲取分片檔案 ➡️ 還原分片 ➡️ 返回拼接好的檔案資訊

需要最佳化的點

•刪除碎片檔案 還原切片時需要注意的問題

•在後端需要將多個相同檔案的切片還原成一個檔案,如果不能識別一個切片是屬於哪一個檔案的,當同時發生多個請求時,追加的檔案內容會出錯。 •切片上傳介面是非同步的,無法保證伺服器接收到的切片是按照請求順序拼接的。 解決辦法

1)如何識別多個切片是來自於同一個檔案的?

這個可以在傳送請求時,為每個切片傳遞一個相同檔案的identifier引數。

2)如何將多個切片還原成一個檔案?

什麼時候開始拼接:確認所有切片都已上傳完後開始進行拼接,這個可以透過客戶端在切片全部上傳後呼叫後端定義的mkfile介面來通知服務端進行拼接,或者前端傳遞切片的總數totalChunks, 服務端判斷接收的切片數量如果等於totalChunks的值就開始進行拼接,無須前端通知後端進行拼接。

怎麼按順序拼接:可以在每個切片上標記一個位置索引值,找到同一個context下的所有切片,根據chunkNumber確認每個切片的順序,這個按順序拼接切片,還原成檔案

上面有幾個重要的引數: identifier ,chunkNumber,totalChunks

identifier :我們需要獲取為一個檔案的唯一標識,可以透過下面兩種方式獲取

  1. 根據檔名、檔案長度等基本資訊進行拼接,為了避免多個使用者上傳相同的檔案,可以再額外拼接使用者資訊如uid等保證唯一性
  2. 根據檔案的二進位制內容計算檔案的hash,這樣只要檔案內容不一樣,則標識也會不一樣,缺點在於計算量比較大.

chunkNumber:當前切片的索引

totalChunks:總的切片數

四、大檔案上傳的實現方案 前端分片程式碼

// 獲取identifier,同一個檔案會返回相同的值 function createIdentifiert(file) { return file.name + file.size }

let file = document.querySelector("[name=file]").files[0]; const LENGTH = 1024 1024 1;//1MB let chunks = slice(file, LENGTH);

// 獲取對於同一個檔案,獲取其identifier let identifier = createIdentifier(file);

let tasks = []; chunks.forEach((chunk, index) => { let fd = new FormData(); //傳遞file物件 fd.append("file",chunk); // 傳遞identifier fd.append("identifier", identifier); // 傳遞切片索引值 fd.append("chunkNumber", index + 1); // 傳遞切片總數 fd.append(“totalChunks”, chunks.length);
tasks.push(post("/mkblk.php", fd)); });

// 所有切片上傳完畢後,呼叫mkfile介面 Promise.all(tasks).then(res => { let fd = new FormData(); fd.append("identifier", identifier); fd.append("totalChunks",chunks.length); post("/mkfile.php", fd).then(res => { console.log(res); }) });

後端還原分片程式碼

// mkblk.php介面 $identifier = $\_POST['identifier']; $path = './upload/' . $identifier; if(!is\_dir($path)){ mkdir($path); } // 把同一個檔案的切片放在相同的目錄下 $filename = $path . '/' . $\_POST\['chunkNumber’\]; // 清除儲存的切片 $res = move\_uploaded\_file($\_FILES\['file'\]\['tmp_name'\], $filename);

//接下來是mkfile.php介面的實現,這個介面會在所有切片上傳後呼叫用來合併檔案

// mkfile.php介面 $identifier = $\_POST['identifier']; $totalChunks= (int)$\_POST['totalChunks'];

//合併後的檔名 $filename = './upload/' . $identifier . '/file.jpg’; // 開始合併檔案 for($i = 1; $i <= $totalChunks; ++$i){ $file = './upload/'.$ identifier. '/' .$i; // 讀取單個切塊 // 獲取檔案內容 $content = file\_get\_contents($file); if(!file_exists($filename)){ //建立一個用於讀寫的空檔案 $fd = fopen($filename, "w+"); }else{ //追加到一個檔案,寫操作向檔案末尾追加資料。如果檔案不存在,則建立檔案。 $fd = fopen($filename, "a"); } fwrite($fd, $content);// 將切塊合併到一個檔案上 }

以上程式碼還需要繼續最佳化的點:斷點續傳、秒傳、上傳進度和暫停

1、斷點續傳

為什麼需要斷點續傳?

即使將大檔案拆分成切片上傳,我們仍需等待所有切片上傳完畢,在等待過程中,可能發生一系列導致部分切片上傳失敗的情形,如網路故障、頁面關閉等。由於切片未全部上傳,因此無法通知服務端合成檔案。這種情況下可以透過斷點續傳來進行處理。

斷點續傳指的是:可以從已經上傳部分開始繼續上傳未完成的部分,而沒有必要從頭開始上傳,節省上傳時間。 怎麼實現斷點續傳?

由於整個上傳過程是按切片維度進行的,且mkfile介面是在所有切片上傳完成後由客戶端主動呼叫的,因此斷點續傳的實現也十分簡單:

在切片上傳成功後,儲存已上傳的切片資訊

當下次傳輸相同檔案時,遍歷切片列表,只選擇未上傳的切片進行上傳

所有切片上傳完畢後,再呼叫mkfile介面通知服務端進行檔案合併

因此問題就落在瞭如何儲存已上傳切片的資訊了,儲存一般有兩種策略

1.可以透過locaStorage等方式儲存在前端瀏覽器中,這種方式不依賴於服務端,實現起來也比較方便,缺點在於如果使用者清除了本地檔案,會導致上傳記錄丟失

2.服務端本身知道哪些切片已經上傳,因此可以由服務端額外提供一個根據檔案context查詢已上傳切片的介面,在上傳檔案前呼叫該檔案的歷史上傳記錄 前端斷點續傳程式碼

// 獲取已上傳切片記錄 function getUploadSliceRecord(context){ let record = localStorage.getItem(context) if(!record){ return [] }else { return JSON.parse(record) } }

// 儲存已上傳切片 function saveUploadSliceRecord(context, sliceIndex){ let list = getUploadSliceRecord(context) list.push(sliceIndex) localStorage.setItem(context, JSON.stringify(list)) }

let context = createContext(file);

// 獲取上傳記錄 let record = getUploadSliceRecord(context); let tasks = []; chunks.forEach((chunk, index) => { // 已上傳的切片則不再重新上傳 if(record.includes(index)){ return }

let fd = new FormData();
fd.append("file", chunk);
fd.append("context", context);
fd.append("chunk", index + 1);

let task = post("/mkblk.php", fd).then(res=>{
    // 上傳成功後儲存已上傳切片記錄
    saveUploadSliceRecord(context, index)
    record.push(index)
})
tasks.push(task);

}); ...

後端斷點續傳程式碼

服務端實現斷點續傳的邏輯基本相似,只要在getUploadSliceRecord內部呼叫服務端的查詢介面獲取已上傳切片的記錄即可,因此這裡不再展開。

後端程式碼最佳化:清除切片的時機

此外斷點續傳還需要考慮切片過期的情況

如果呼叫了mkfile介面,則磁碟上的切片內容就可以清除掉了,如果客戶端一直不呼叫mkfile的介面,放任這些切片一直儲存在磁碟顯然是不可靠的,一般情況下,切片上傳都有一段時間的有效期,超過該有效期,就會被清除掉。基於上述原因,斷點續傳也必須同步切片過期的實現邏輯。 2、秒傳

什麼是秒傳?

已經上傳過的檔案,並且在後端已經拼接完成,如果再次上傳的話後端不做處理,直接返回拼接好的檔案的資訊即可,這裡主要後端實現,由於篇幅關係,這裡不做過多描述。 3、上傳進度和暫停

透過xhr.upload中的progress方法可以實現監控每一個切片上傳進度。

上傳暫停的實現也比較簡單,透過xhr.abort可以取消當前未完成上傳切片的上傳,實現上傳暫停的效果,恢復上傳就跟斷點續傳類似,先獲取已上傳的切片列表,然後重新傳送未上傳的切片。

由於篇幅關係,上傳進度和暫停的功能這裡就先不實現了。

五、目前成熟的大檔案上傳方案 目前社群已經存在一些成熟的大檔案上傳解決方案,也許並不需要我們手動去實現一個簡陋的大檔案上傳庫,但是瞭解其原理還是十分有必要的。

推薦的前端vue元件:vue-simple-uploader,支援vue2,vue3

vue-simple-uploader是基於simple-Uploader.js封裝的大檔案上傳元件,具有以下優點:

  1. 支援單檔案、多檔案、資料夾上傳;支援拖拽檔案、資料夾上傳
  2. 可暫停、繼續上傳
  3. 錯誤處理
  4. 支援“秒傳”,透過檔案判斷服務端是否已存在從而實現“秒傳”
  5. 分塊上傳
  6. 支援進度、預估剩餘時間、出錯自動重試、重傳等操作

vue-simple-uploader 內部的實現也很簡單,有興趣的同學可以去看一下原始碼

六、總結 本文首先介紹了什麼是大檔案,以及大檔案跟普通檔案在上傳時的區別,最後透過分析大檔案上傳的原理和思路給出簡單的實現方案,並且推薦了一個成熟的vue大檔案上傳元件:vue-simple-uploader,希望對大家有所幫助。

作者:京東物流 於俊嬌

來源:京東雲開發者社群 自猿其說 Tech 轉載請註明來源

相關文章