網頁 大檔案分片上傳處理
最近遇見一個需要上傳百兆大檔案的需求,調研了七牛和騰訊雲的切片分段上傳功能,因此在此整理前端大檔案上傳相關功能的實現。
在某些業務中,大檔案上傳是一個比較重要的互動場景,如上傳入庫比較大的Excel表格資料、上傳影音檔案等。如果檔案體積比較大,或者網路條件不好時,上傳的時間會比較長(要傳輸更多的報文,丟包重傳的概率也更大),使用者不能重新整理頁面,只能耐心等待請求完成。
下面從檔案上傳方式入手,整理大檔案上傳的思路,並給出了相關例項程式碼,由於PHP內建了比較方便的檔案拆分和拼接方法,因此服務端程式碼使用PHP進行示例編寫。
控制元件原始碼下載:
測試與配置:
檔案上傳的幾種方式
首先我們來看看檔案上傳的幾種方式。
普通表單上傳
使用PHP來展示常規的表單上傳是一個不錯的選擇。首先構建檔案上傳的表單,並指定表單的提交內容型別為enctype="multipart/form-data"
-
<form action="/index.php" method="POST" enctype="multipart/form-data">
-
<input type="file" name="myfile">
-
<input type="submit">
-
</form>
-
然後編寫index.php
上傳檔案接收程式碼,使用move_uploaded_file
方法即可(php大法好...)
-
$imgName = 'IMG'.time().'.'.str_replace('image/','',$_FILES["myfile"]['type']);
-
$fileName = 'upload/'.$imgName;
-
// 移動上傳檔案至指定upload資料夾下,並根據返回值判斷操作是否成功
-
if (move_uploaded_file($_FILES['myfile']['tmp_name'], $fileName)){
-
echo $fileName;
-
}else {
-
echo "nonn";
-
}
-
form表單上傳大檔案時,很容易遇見伺服器超時的問題。通過xhr,前端也可以進行非同步上傳檔案的操作,一般由兩個思路。
檔案編碼上傳
第一個思路是將檔案進行編碼,然後在服務端進行解碼,其主要實現原理就是將圖片轉換成base64進行傳遞
var imgURL = URL.createObjectURL(file);ctx.drawImage(imgURL, 0, 0);// 獲取圖片的編碼,然後將圖片當做是一個很長的字串進行傳遞var data = canvas.toDataURL("image/jpeg", 0.5);
在服務端需要做的事情也比較簡單,首先解碼base64,然後儲存圖片即可
-
$imgData = $_REQUEST['imgData'];
-
$base64 = explode(',', $imgData)[1];
-
$img = base64_decode($base64);
-
$url = './test.jpg';
-
if (file_put_contents($url, $img)) {
-
exit(json_encode(array(
-
url => $url
-
)));
-
}
-
base64編碼的缺點在於其體積比原圖片更大(因為Base64將三個位元組轉化成四個位元組,因此編碼後的文字,會比原文字大出三分之一左右),對於體積很大的檔案來說,上傳和解析的時間會明顯增加。
更多關於base64的知識,可以參考Base64筆記。
除了進行base64編碼,還可以在前端直接讀取檔案內容後以二進位制格式上傳
-
// 讀取二進位制檔案
-
function readBinary(text){
-
var data = new ArrayBuffer(text.length);
-
var ui8a = new Uint8Array(data, 0);
-
for (var i = 0; i < text.length; i++){
-
ui8a[i] = (text.charCodeAt(i) & 0xff);
-
}
-
console.log(ui8a)
-
}
-
var reader = new FileReader();
-
reader.onload = function(){
-
readBinary(this.result) // 讀取result或直接上傳
-
}
-
// 把從input裡讀取的檔案內容,放到fileReader的result欄位裡
-
reader.readAsBinaryString(file);
-
formData非同步上傳
-
let files = e.target.files // 獲取input的file物件
-
let formData = new FormData();
-
formData.append('file', file);
-
axios.post(url, formData);
-
服務端處理方式與直接form表單請求基本相同。
iframe無重新整理頁面
在低版本的瀏覽器(如IE)上,xhr是不支援直接上傳formdata的,因此只能用form來上傳檔案,而form提交本身會進行頁面跳轉,這是因為form表單的
-
_self,預設值,在相同的視窗中開啟響應頁面
-
_blank,在新視窗開啟
-
_parent,在父視窗開啟
-
_top,在最頂層的視窗開啟
-
framename
,在指定名字的iframe中開啟
如果需要讓使用者體驗非同步上傳檔案的感覺,可以通過framename
指定iframe來實現。把form的target屬性設定為一個看不見的iframe,那麼返回的資料就會被這個iframe接受,因此只有該iframe會被重新整理,至於返回結果,也可以通過解析這個iframe內的文字來獲取。
-
function upload(){
-
var now = +new Date()
-
var id = 'frame' + now
-
$("body").append(`<iframe style="display:none;" name="${id}" id="${id}" />`);
-
var $form = $("#myForm")
-
$form.attr({
-
"action": '/index.php',
-
"method": "post",
-
"enctype": "multipart/form-data",
-
"encoding": "multipart/form-data",
-
"target": id
-
}).submit()
-
$("#"+id).on("load", function(){
-
var content = $(this).contents().find("body").text()
-
try{
-
var data = JSON.parse(content)
-
}catch(e){
-
console.log(e)
-
}
-
})
-
}
大檔案上傳
現在來看看在上面提到的幾種上傳方式中實現大檔案上傳會遇見的超時問題,
-
表單上傳和iframe無重新整理頁面上傳,實際上都是通過form標籤進行上傳檔案,這種方式將整個請求完全交給瀏覽器處理,當上傳大檔案時,可能會遇見請求超時的情形
-
通過fromData,其實際也是在xhr中封裝一組請求引數,用來模擬表單請求,無法避免大檔案上傳超時的問題
-
編碼上傳,我們可以比較靈活地控制上傳的內容
大檔案上傳最主要的問題就在於:在同一個請求中,要上傳大量的資料,導致整個過程會比較漫長,且失敗後需要重頭開始上傳。試想,如果我們將這個請求拆分成多個請求,每個請求的時間就會縮短,且如果某個請求失敗,只需要重新發送這一次請求即可,無需從頭開始,這樣是否可以解決大檔案上傳的問題呢?
綜合上面的問題,看來大檔案上傳需要實現下面幾個需求
-
支援拆分上傳請求(即切片)
-
支援斷點續傳
-
支援顯示上傳進度和暫停上傳
接下來讓我們依次實現這些功能,看起來最主要的功能應該就是切片了。
檔案切片
參考:
編碼方式上傳中,在前端我們只要先獲取檔案的二進位制內容,然後對其內容進行拆分,最後將每個切片上傳到服務端即可。
在JavaScript中,檔案FIle物件是Blob物件的子類,Blob物件包含一個重要的方法slice
,通過這個方法,我們就可以對二進位制檔案進行拆分。
下面是一個拆分檔案的示例
-
function slice(file, piece = 1024 * 1024 * 5) {
-
let totalSize = file.size; // 檔案總大小
-
let start = 0; // 每次上傳的開始位元組
-
let end = start + piece; // 每次上傳的結尾位元組
-
let chunks = []
-
while (start < totalSize) {
-
// 根據長度擷取每次需要上傳的資料
-
// File物件繼承自Blob物件,因此包含slice方法
-
let blob = file.slice(start, end);
-
chunks.push(blob)
-
start = end;
-
end = start + piece;
-
}
-
return chunks
-
}
將檔案拆分成piece
大小的分塊,然後每次請求只需要上傳這一個部分的分塊即可
-
let file = document.querySelector("[name=file]").files[0];
-
const LENGTH = 1024 * 1024 * 0.1;
-
let chunks = slice(file, LENGTH); // 首先拆分切片
-
chunks.forEach(chunk=>{
-
let fd = new FormData();
-
fd.append("file", chunk);
-
post('/mkblk.php', fd)
-
})
伺服器接收到這些切片後,再將他們拼接起來就可以了,下面是PHP拼接切片的示例程式碼
-
$filename = './upload/' . $_POST['filename'];//確定上傳的檔名
-
//第一次上傳時沒有檔案,就建立檔案,此後上傳只需要把資料追加到此檔案中
-
if(!file_exists($filename)){
-
move_uploaded_file($_FILES['file']['tmp_name'],$filename);
-
}else{
-
file_put_contents($filename,file_get_contents($_FILES['file']['tmp_name']),FILE_APPEND);
-
echo $filename;
-
}
測試時記得修改nginx的server配置,否則大檔案可能會提示413 Request Entity Too Large
的錯誤。
-
server {
-
// ...
-
client_max_body_size 50m;
-
}
上面這種方式來存在一些問題
-
無法識別一個切片是屬於哪一個切片的,當同時發生多個請求時,追加的檔案內容會出錯
-
切片上傳介面是非同步的,無法保證伺服器接收到的切片是按照請求順序拼接的
因此接下來我們來看看應該如何在服務端還原切片。
還原切片
在後端需要將多個相同檔案的切片還原成一個檔案,上面這種處理切片的做法存在下面幾個問題
-
如何識別多個切片是來自於同一個檔案的,這個可以在每個切片請求上傳遞一個相同檔案的
context
引數 -
如何將多個切片還原成一個檔案
-
確認所有切片都已上傳,這個可以通過客戶端在切片全部上傳後呼叫
mkfile
介面來通知服務端進行拼接 -
找到同一個context下的所有切片,確認每個切片的順序,這個可以在每個切片上標記一個位置索引值
-
按順序拼接切片,還原成檔案
-
上面有一個重要的引數,即context
,我們需要獲取為一個檔案的唯一標識,可以通過下面兩種方式獲取
-
根據檔名、檔案長度等基本資訊進行拼接,為了避免多個使用者上傳相同的檔案,可以再額外拼接使用者資訊如uid等保證唯一性
-
根據檔案的二進位制內容計算檔案的hash,這樣只要檔案內容不一樣,則標識也會不一樣,缺點在於計算量比較大.
修改上傳程式碼,增加相關引數
-
// 獲取context,同一個檔案會返回相同的值
-
function createContext(file) {
-
return file.name + file.length
-
}
-
let file = document.querySelector("[name=file]").files[0];
-
const LENGTH = 1024 * 1024 * 0.1;
-
let chunks = slice(file, LENGTH);
-
// 獲取對於同一個檔案,獲取其的context
-
let context = createContext(file);
-
let tasks = [];
-
chunks.forEach((chunk, index) => {
-
let fd = new FormData();
-
fd.append("file", chunk);
-
// 傳遞context
-
fd.append("context", context);
-
// 傳遞切片索引值
-
fd.append("chunk", index + 1);
-
tasks.push(post("/mkblk.php", fd));
-
});
-
// 所有切片上傳完畢後,呼叫mkfile介面
-
Promise.all(tasks).then(res => {
-
let fd = new FormData();
-
fd.append("context", context);
-
fd.append("chunks", chunks.length);
-
post("/mkfile.php", fd).then(res => {
-
console.log(res);
-
});
-
});
在mkblk.php
介面中,我們通過context
來儲存同一個檔案相關的切片
-
// mkblk.php
-
$context = $_POST['context'];
-
$path = './upload/' . $context;
-
if(!is_dir($path)){
-
mkdir($path);
-
}
-
// 把同一個檔案的切片放在相同的目錄下
-
$filename = $path .'/'. $_POST['chunk'];
-
$res = move_uploaded_file($_FILES['file']['tmp_name'],$filename);
-
複製程式碼
除了上面這種簡單通過目錄區分切片的方法之外,還可以將切片資訊儲存在資料庫來進行索引。接下來是mkfile.php
介面的實現,這個介面會在所有切片上傳後呼叫
-
// mkfile.php
-
$context = $_POST['context'];
-
$chunks = (int)$_POST['chunks'];
-
//合併後的檔名
-
$filename = './upload/' . $context . '/file.jpg';
-
for($i = 1; $i <= $chunks; ++$i){
-
$file = './upload/'.$context. '/' .$i; // 讀取單個切塊
-
$content = file_get_contents($file);
-
if(!file_exists($filename)){
-
$fd = fopen($filename, "w+");
-
}else{
-
$fd = fopen($filename, "a");
-
}
-
fwrite($fd, $content); // 將切塊合併到一個檔案上
-
}
-
echo $filename;
-
複製程式碼
這樣就解決了上面的兩個問題:
-
識別切片來源
-
保證切片拼接順序
斷點續傳
即使將大檔案拆分成切片上傳,我們仍需等待所有切片上傳完畢,在等待過程中,可能發生一系列導致部分切片上傳失敗的情形,如網路故障、頁面關閉等。由於切片未全部上傳,因此無法通知服務端合成檔案。這種情況下可以通過斷點續傳來進行處理。
斷點續傳指的是:可以從已經上傳部分開始繼續上傳未完成的部分,而沒有必要從頭開始上傳,節省上傳時間。
由於整個上傳過程是按切片維度進行的,且mkfile
介面是在所有切片上傳完成後由客戶端主動呼叫的,因此斷點續傳的實現也十分簡單:
-
在切片上傳成功後,儲存已上傳的切片資訊
-
當下次傳輸相同檔案時,遍歷切片列表,只選擇未上傳的切片進行上傳
-
所有切片上傳完畢後,再呼叫
mkfile
介面通知服務端進行檔案合併
因此問題就落在瞭如何儲存已上傳切片的資訊了,儲存一般有兩種策略
-
可以通過locaStorage等方式儲存在前端瀏覽器中,這種方式不依賴於服務端,實現起來也比較方便,缺點在於如果使用者清除了本地檔案,會導致上傳記錄丟失
-
服務端本身知道哪些切片已經上傳,因此可以由服務端額外提供一個根據檔案context查詢已上傳切片的介面,在上傳檔案前呼叫該檔案的歷史上傳記錄
下面讓我們通過在本地儲存已上傳切片記錄,來實現斷點上傳的功能
-
// 獲取已上傳切片記錄
-
function getUploadSliceRecord(context){
-
let record = localStorage.getItem(context)
-
if(!record){
-
return []
-
}else {
-
try{
-
return JSON.parse(record)
-
}catch(e){}
-
}
-
}
-
// 儲存已上傳切片
-
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
的介面,放任這些切片一直儲存在磁碟顯然是不可靠的,一般情況下,切片上傳都有一段時間的有效期,超過該有效期,就會被清除掉。基於上述原因,斷點續傳也必須同步切片過期的實現邏輯。
上傳進度和暫停
通過xhr.upload中的progress
方法可以實現監控每一個切片上傳進度。
上傳暫停的實現也比較簡單,通過xhr.abort
可以取消當前未完成上傳切片的上傳,實現上傳暫停的效果,恢復上傳就跟斷點續傳類似,先獲取已上傳的切片列表,然後重新發送未上傳的切片。
由於篇幅關係,上傳進度和暫停的功能這裡就先不實現了。
小結
目前社群已經存在一些成熟的大檔案上傳解決方案,如
本文首先整理了前端檔案上傳的幾種方式,然後討論了大檔案上傳的幾種場景,以及大檔案上傳需要實現的幾個功能
-
通過Blob物件的
slice
方法將檔案拆分成切片 -
整理了服務端還原檔案所需條件和引數,演示了PHP將切片還原成檔案
-
通過儲存已上傳切片的記錄來實現斷點續傳
還留下了一些問題,如:合併檔案時避免記憶體溢位、切片失效策略、上傳進度暫停等功能,並沒有去深入或一一實現,繼續學習吧~
預設頁面介面定義:
在瀏覽器中訪問:
資料表中的資料