居於H5的多檔案、大檔案、多執行緒上傳解決方案
檔案上傳在web應用中是比較常見的功能,前段時間做了一個多檔案、大檔案、多執行緒檔案上傳的功能,使用效果還不錯,總結分享下。
一、 功能性需求與非功能性需求
- 要求操作便利,一次選擇多個檔案進行上傳;
- 支援大檔案上傳(1G),同時需要保證上傳期間使用者電腦不出現卡死等體驗;
- 互動友好,能夠及時反饋上傳的進度;
- 服務端的安全性,不因上傳檔案功能導致JVM記憶體溢位影響其他功能使用;
- 最大限度利用網路上行頻寬,提高上傳速度;
二、 設計分析
- 對於大檔案的處理,無論是使用者端還是服務端,如果一次性進行讀取傳送、接收都是不可取,很容易導致記憶體問題。所以對於大檔案上傳,採用切塊分段上傳
- 從上傳的效率來看,利用多執行緒併發上傳能夠達到最大效率。
- 對於大檔案切塊、多執行緒上傳,需要考慮服務端合併檔案的時間點;
三、解決方案:
在HTML5之前的標準是無法支援上面的功能,因此我們需要把功能實現居於H5提供的新特性上面:
1. H5新標準對file標籤進行了增強,支援同時選擇多個檔案
<input type="file" multiple=true onchange="doSomething(this.files)"/>
- 1
- 1
注意multiple屬性,設定為true;
onchange:一般是選擇檔案確定後的響應事件
this.files:檔案物件集合
2. File物件
H5提供的類似Java的RandomAccessFile的檔案操作物件,其中silce方法允許程式指定檔案的起止位元組進行讀取。利用這個物件,實現對大檔案的切分;
3. XMLHttpRequest
這個物件大家應該很熟悉了,屬於web2.0的標準,我們最常用的ajax請求底層就是居於此物件。本質上XMLHttpRequest是一個執行緒物件,因此我們通過建立一定數量的XMLHttpRequest物件,實現多執行緒並行操作;
4. FormData物件
H5新增物件,可以理解為一個key-value的map,通過把檔案的二進位制流和業務引數封裝到此物件,再交由XMLHttpRequest物件傳送到服務端,服務端可以通過普通的request.getParamter方法獲取這些引數;
5. progress標籤
H5新增的標籤,在頁面顯示一個進度條:
value:當前進度條的值
max:最大值
利用這個標籤,結合XMLHttpRequest的回撥來反饋目前上傳的進度
四、客戶端程式碼示例
- HTML程式碼:
<input type="file" multiple=true onchange="showFileList(this.files)"/>
<input id="uploadBtn" type="button" value="上傳" onclick="doUpload()"/>
- 1
- 2
- 1
- 2
- javascript指令碼:
var quence = new Array();//待上傳的檔案佇列,包含切塊的檔案
/**
* 使用者選擇檔案之後的響應函式,將檔案資訊展示在頁面,同時對大檔案的切塊大小、塊的起止進行計算、入列等
*/
function showFileList(files) {
if(!files) {
return;
}
var chunkSize = 5 * 1024 * 1024; //切塊的閥值:5M
$(files).each(function(idx,e){
//展示檔案列表,略......
if(e.size > chunkSize) {//檔案大於閥值,進行切塊
//切塊傳送
var chunks = Math.max(Math.floor(fileSize / chunkSize), 1)+1;//分割塊數
for(var i=0 ; i<chunks; i++) {
var startIdx = i*chunkSize;//塊的起始位置
var endIdx = startIdx+chunkSize;//塊的結束位置
if(endIdx > fileSize) {
endIdx = fileSize;
}
var lastChunk = false;
if(i == (chunks-1)) {
lastChunk = true;
}
//封裝成一個task,入列
var task = {
file:e,
uuid:uuid,//避免檔案的重名導致服務端無法定位檔案,需要給每個檔案生產一個UUID
chunked:true,
startIdx:startIdx,
endIdx:endIdx,
currChunk:i,
totalChunk:chunks
}
quence.push(task);
}
} else {//檔案小於閥值
var task = {
file:e,
uuid:uuid,
chunked:false
}
quence.push(task);
}
});
}
/**
* 上傳器,繫結一個XMLHttpRequest物件,處理分配給其的上傳任務
**/
function Uploader(name) {
this.url=""; //服務端處理url
this.req = new XMLHttpRequest();
this.tasks; //任務佇列
this.taskIdx = 0; //當前處理的tasks的下標
this.name=name;
this.status=0; //狀態,0:初始;1:所有任務成功;2:異常
//上傳 動作
this.upload = function(uploader) {
this.req.responseType = "json";
//註冊load事件(即一次非同步請求收到服務端的響應)
this.req.addEventListener("load", function(){
//更新對應的進度條
progressUpdate(this.response.uuid, this.response.fileSize);
//從任務佇列中取一個再次傳送
var task = uploader.tasks[uploader.taskIdx];
if(task) {
console.log(uploader.name + ":當前執行的任務編號:" +uploader.taskIdx);
this.open("POST", uploader.url);
this.send(uploader.buildFormData(task));
uploader.taskIdx++;
} else {
console.log("處理完畢");
uploader.status=1;
}
});
//處理第一個
var task = this.tasks[this.taskIdx];
if(task) {
console.log(uploader.name + ":當前執行的任務編號:" +this.taskIdx);
this.req.open("POST", this.url);
this.req.send(this.buildFormData(task));
this.taskIdx++;
} else {
uploader.status=1;
}
}
//提交任務
this.submit = function(tasks) {
this.tasks = tasks;
}
//構造表單資料
this.buildFormData = function(task) {
var file = task.file;
var formData = new FormData();
formData.append("fileName", file.name);
formData.append("fileSize", file.size);
formData.append("uuid", task.uuid);
var chunked = task.chunked;
if(chunked) {//分塊
formData.append("chunked", task.chunked);
formData.append("data", file.slice(task.startIdx, task.endIdx));//擷取檔案塊
formData.append("currChunk", task.currChunk);
formData.append("totalChunk", task.totalChunk);
} else {
formData.append("data", file);
}
return formData;
}
}
/**
*使用者點選“上傳”按鈕
*/
function doUpload() {
//建立4個Uploader上傳器(4條執行緒)
var uploader0 = new Uploader("uploader0");
var task0 = new Array();
var uploader1 = new Uploader("uploader1");
var task1 = new Array();
var uploader2 = new Uploader("uploader2");
var task2 = new Array();
var uploader3 = new Uploader("uploader3");
var task3 = new Array();
//將檔案列表取模hash,分配給4個上傳器
for(var i=0 ; i<quence.length; i++) {
if(i%4==0) {
task0.push(quence[i]);
} else if(i%4==1) {
task1.push(quence[i]);
} else if(i%4==2) {
task2.push(quence[i]);
} else if(i%4==3) {
task3.push(quence[i]);
}
}
/提交任務,啟動執行緒上傳
uploader0.submit(task0);
uploader0.upload(uploader0);
uploader1.submit(task1);
uploader1.upload(uploader1);
uploader2.submit(task2);
uploader2.upload(uploader2);
uploader3.submit(task3);
uploader3.upload(uploader3);
//註冊一個定時任務,每2秒監控檔案是否都上傳完畢
uploadCompleteMonitor = setInterval("uploadComplete()",2000);
}
五、服務端處理:
服務端處理邏輯相對比較傳統,利用輸入輸出流、NIO等把檔案寫到磁碟即可。
這裡需要特別考慮的是關於被切塊檔案的合併。前端在上傳的時候,檔案塊是無序到達服務端,因此我們在每次接收到一個檔案塊的時候需要判斷被切塊的檔案是否都傳輸完畢並進行合併,思路如下:
回到前端,我們在構造被切塊的檔案formData的資料結構:
formData.append("fileName", file.name);
formData.append("fileSize", file.size);
formData.append("uuid", task.uuid);
formData.append("chunked", task.chunked);
formData.append("data", file.slice(task.startIdx, task.endIdx));//擷取檔案塊
formData.append("currChunk", task.currChunk);
formData.append("totalChunk", task.totalChunk);
fileName:檔案的原始名字
fileSize:檔案的大小,KB
uuid:檔案的uuid
chunked:true,標識是分段上傳的檔案塊
data:檔案二進位制流
currChunk:當前上傳的塊編號
totalChunk:總塊數
服務端以檔案的UUID為key,維護一個chunk計數器,每接收到一塊就找到對應的uuid執行計數器+1,同時考慮到併發情況,需採用同步關鍵字,避免出現邏輯錯誤。當計數器等於totalChunk的時候,進行檔案合併