本文實現的斷點續傳只是我對斷點續傳的一個理解。其中有很多不完善的地方,僅僅是記錄了一個我對斷點續傳一個實現過程。大家應該也會發現我用的都是一些H5的api,老得瀏覽器不會支援,以及我並未將跨域考慮入內,還有一些可能出現的一場等~巴啦啦。(怎麼感覺這麼多問題???笑~)
本文參考倉庫:點我
這幾天在認認真真地學習KOA框架,瞭解它的原理以及KOA中介軟體的實現方法。在研究KOA如何處理上傳的表單資料的時候,我靈光一閃,這是不是可以用於斷點續傳?
斷點續傳並不是伺服器端一端的自high,他還需要前端的配合,而且我只準備扒拉一個大致的雛形,所以這個功能我準備:
- 後端:手寫KOA中介軟體處理斷點資料
- 前端:原生JS
斷點續傳的過程不復雜,但是還是有許多小知識點需要get,不然很難理解斷點續傳的工作過程。實現斷點續傳的方式有很多,不過我只研究了ajax的方式,所以預備的小知識點如下:
KOA部分:
Headers的content-type
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryE1FeIoZcbW92IXSd
複製程式碼
HTML的form元件一共提供三種方式的編碼方法:application/x-www-form-urlencoded
(預設)、multipart/form-data
、text/plain
。前兩種方式比較常見,最後一種不太用,也不推薦使用。前兩種的區別就是預設的方法是無法上傳<input type="file"/>
的。所以如果我們需要上傳檔案,那麼就一定要用multipart/form-data
。
form上傳的raw data
在KOA中,server獲取到的data都是raw data
也就是未經處理的二進位制資料。我們需要格式化這些資料,提取有效內容。我們來分析一下如何處理這些raw data
。
當我們上傳的時候,我們會發現一個現象,就是content-type
還跟了一個小尾巴multipart/form-data; boundary=----WebKitFormBoundarygNnYG0jyz7vh9bjm
,這個長串的字串是用來幹嘛的呢?看一眼完整的raw data
:
------WebKitFormBoundarygNnYG0jyz7vh9bjm
Content-Disposition: form-data; name="size"
668
------WebKitFormBoundarygNnYG0jyz7vh9bjm
Content-Disposition: form-data; name="file"; filename="checked.png"
Content-Type: image/png
------WebKitFormBoundarygNnYG0jyz7vh9bjm--
複製程式碼
大家發現沒每個欄位之間都有------WebKitFormBoundarygNnYG0jyz7vh9bjm
將他們分割開來。所以這裡的boundary
是用來分割欄位的。
關於boundary
- 它的值是可以自定義的,不過瀏覽器會幫我們定義
- 不能超過70個字元
- 在
raw data
中,需要在前方加上--
,也就是這樣--boundary
,如果是結尾的分隔符那麼在末尾也加一個--
,就是這樣--boundary--
更多詳情,請參考The Multipart Content-Type
http中request
的data
和end
監聽事件
傳資料給server,他也要有辦法接受對不?所以這個時候,我們需要配置data
監聽資料的接受,以及end
監聽資料的接受完畢。
每次data
事件觸發,獲取的資料都是一個Buffer型別的資料,然後將獲取到的資料加到buf
陣列中,等結束的時候,再用Buffer.concat
串聯這些Buffer資料,變成一個完整的Buffer。就是這樣,伺服器將客戶端的資料接受完畢了。
這一段就很簡單了,ctx.req
是KOA中封裝的request
。
let buf = [];
let allData;
ctx.req.on("data",(data)=>{
buf.push(data)
});
ctx.req.on("end",(data)=>{
allData=Buffer.concat(buf)
})
複製程式碼
Buffer的處理
重點部分來了,這一部分了坑得我好慘。
我們server獲取到的raw data
不是字串,而是一串Buffer
。Buffer是什麼呢?是二進位制資料。雖然我們可以將Buffer
轉為字串再進行處理,但是遇到編碼問題就會很頭疼,因為toString
預設是utf-8
得編碼格式。如果遇上不是utf-8
的,那麼我們得到的結果就很有問題。所以說如果想要加工Buffer
資料就還是要用Buffer
資料。比如------WebKitFormBoundarygNnYG0jyz7vh9bjm
這一段我想知道再Buffer中這個一段的位置。那麼我麼可以把這一段變成Buffer,然後去逐個查詢。
來一段我和raw data的血淚溝通史(P一下哈哈):
raw data | 我 |
---|---|
我是一段二進位制流 | 我要處理你 |
我要把你變成我最愛的string,人類可讀的語言,然後再分割你 | |
如果我本來是人類可讀,那麼你可以這麼做,萬一我是圖片或者其他格式,emmm | 會有什麼問題嗎 |
那麼你就看不到我原來的樣子了 | ??? |
簡而言之,如果我是圖片,你把我轉成文字,寫入檔案的話,我就是一堆亂碼 | what???(Φ皿Φ) |
所以你只能用我的同類來處理我 | 同類? |
也就是二進位制流 | 也就是說我要把分隔符變成二進位制流,然後來分割你? |
就是這樣~ | 大哥我輸了 |
雖說我是二進位制流,不過你可以用一個熟悉的方法來查詢我 | 咦?有捷徑嗎? |
buf.indexOf(value) 可以幫助你查詢位置 |
哦 |
buf.slice([start[, end]])可以幫助你無損分割我 | 哦 |
我只能幫你到這兒了 | 走好,不送 |
實現程式碼:
function splitBuffer(buffer,sep) {
let arr = [];
let pos = 0;//當前位置
let sepPosIndex = -1;//分隔符的位置
let sepPoslen = Buffer.from(sep).length;//分隔符的長度,以便確定下一個開始的位置
do{
sepPosIndex=buffer.indexOf(sep,pos)
if(sepPosIndex==-1){
//當sepPosIndex是-1的時候,代表已經到末尾了,那麼直接直接一口讀完最後的buffer
arr.push(buffer.slice(pos));
}else{
arr.push(buffer.slice(pos,sepPosIndex));
}
pos = sepPosIndex+sepPoslen
}while(-1!==sepPosIndex)
return arr
}
複製程式碼
前端部分:
H5中fileAPi的slice
方法
slice
之前是用於陣列的一個方法,現在檔案也可以用slice
來分割拉,不過需要注意的是這個方法是一個新的api,也就是很多old的瀏覽器無法使用。
用法很簡單:
//初始位置,長度
//這裡的File物件是一個Blob,一個類似於二進位制的流,所以這裡是以位元組為單位的。
File.slice(startByte, length);
複製程式碼
JS的原生AJAX實現方式XMLHttpRequest
新建一個XMLHttpRequest
xhr = new XMLHttpRequest();
複製程式碼
開啟一個post為請求的連結
xhr.open("post", "/submit", true);
複製程式碼
配置onreadystatechange
,捕獲請求連結的狀態。
xhr.onreadystatechange = function(){
//xhr.readyState
//處理完成的邏輯
};
複製程式碼
readyState | 意義 |
---|---|
0 | 初始化 |
1 | 載入中 |
2 | 載入完成 |
3 | 部分可用 |
4 | 載入完成 |
準備工作都做好了,最後send一下,請求連結。
xhr.send(表單資料);
複製程式碼
下面一節會寫如何生成send中的表單資料
封裝表單資料FormData
FormData
的使用很友好,就是按照健值一個個配對就可以了。
var formData = new FormData();
formData.append("test", "I am FormData");
formData.append("file", 你選擇的檔案);
複製程式碼
雖然簡單,但是卻可以模擬post的資料格式send給伺服器。
斷點續傳
主要邏輯
寫了這麼多有關之後開發斷點續傳的相關知識點,我們可以動手開始寫了。斷點續傳的邏輯並不複雜大概就是這樣的:
客戶端client | 伺服器端server |
---|---|
我想上傳一個檔案 | ok,no problem,不過你只能用post傳給我 |
我的檔案很大直接form 提交可以嗎 |
有多大,如果很大的話,一旦我們的連線斷開,我們就前功盡棄了啊!慎重啊! |
well,well,我把我的檔案slice 成一小塊一小塊慢慢給你行了吧 |
來吧baby~,我不介意你多來幾次 |
第一部分send |
接受中... |
等待中... | 接受完畢,處理接受的Blob,處理完畢已寫入,你可以傳第二部分了~ |
第二部分send |
接受中... |
等待中... | 接受完畢,處理接受的Blob,處理完畢已寫入,你可以傳第三部分了~ |
... | ... |
... | 終於結束了,我去處理下你的檔案 |
... | ok~傳送成功 |
斷點續傳client端的處理方式
從上述邏輯來看,這個前端的流程可以分為:
- 確定檔案大小,根劇相同的長度切片
- 根據切片的數量,進行回撥上傳
切分檔案
斷點續傳是客戶端主動傳送,伺服器端被動接受的一個過程,所以這裡是在客戶端進行一個檔案的切分,把檔案根據range
的大小進行切分,range
的大小可以自定義。這裡我為了防止每次上傳切片都要計算位置,所以提前把所有的位置都放入了currentSlice
的陣列之中。然後按順序取位置。注意:這邊切分全部是以位元組為單位的計算。
createSlices(){
let s=0,e=-1,range=1024;
for(let i = 0;i<Math.ceil(this.file.size/range);i++){
s=i*range,e=e+range
e=e>this.file.size-1?this.file.size-1:e;
this.currentSlice.push([s,e])
}
}
複製程式碼
既然我們知道了切分的碎片有多少片,那麼按照已上傳的碎片除以總碎片就可以得到進度啦,就順手算個進度吧。這邊感覺好像很複雜的樣子,淡定~我只是把介面樣式都加進去了~
updateProcess(){
let process=Math.round(this.currentIndex/this.currentSlice.length*100)
this.fileProcess.innerHTML=`<span class="process"><span style='width:${process}%'></span><b>${process}%</b></span><span>${this.fileSize}</span>`
},
複製程式碼
此外還需注意,檔案的單位是位元組,這個對於使用者來說非常不友好,為了告訴使用者檔案有多大,我們需要轉換一下。這裡我是動態的轉換,並不是固定一個單位,因為如果一個檔案只有幾KB,然後我卻用G的單位來計算,那麼就是滿眼的0了。這裡可以根據檔案大的大小,具體情況具體分析。我這裡只給了一個KB和MB的計算。可以自行elseif加條件。
calculateSize(){
let fileSize=this.fileSize/1024;
if(fileSize<512){
this.fileSize=Math.round(fileSize)+"KB"
} else {
this.fileSize=Math.round(fileSize/1024)+"MB"
}
},
複製程式碼
切分檔案逐個上傳
既然要上傳了,那就不得不召喚XMLHttpRequest
了。進行AJAX上傳檔案。上傳檔案必須要enctype="multipart/form-data"
,因此還需要請出FormData
幫我們建立form表單資料。
先建立一個表單資料吧~,其實我們只需要上傳一個file的blob檔案就可以了,但是伺服器沒有這麼機智,能夠自行給檔案加獨一無二的標識,所以我們在傳檔案的時候要加上檔案的資訊,比如檔名,檔案大小,還有檔案切分的位置。這個部分就是隨意發揮了,看你需要啥就加入啥子段,比如時間啦,使用者id啦,巴啦啦~
createFormData(){
let formData = new FormData();
let start=this.currentSlice[this.currentIndex][0]
let end=this.currentSlice[this.currentIndex][1]
let fileData=this.file.slice(start,end)
formData.append("start", start);
formData.append("end", end);
formData.append("size", this.file.size);
formData.append("fileOriName", this.file.name);
formData.append("file", fileData);
return formData;
}
複製程式碼
終於準備活動做完了,該上傳了。這邊就是一個標準的XMLHttpRequest
的上傳模版,有麼有很親切很友好。這邊不觸及到跨域等那個啥的問題,所以很友好。大家只需在上傳成功之後再回撥此上傳方法。逐個上傳。直至最後一個切分。這裡為了看出上傳的過程,所以我加了一個500ms的延遲,這個僅僅是為了視覺效果,畢竟我只是試了幾MB的檔案,上傳太快了。
createUpload(){
let _=this
let formData=this.createFormData()
let xhr = new XMLHttpRequest();
xhr.open("post", "/submit", true);
xhr.onreadystatechange = function(){
if (xhr.readyState == 4&&parseInt(xhr.status)==200){
_.currentIndex++;
if(_.currentIndex<=_.currentSlice.length-1){
setTimeout(()=>{
_.createUpload()
},500)
}else{
//完成後的處理
}
_.updateProcess()
}
};
xhr.send(formData);
}
複製程式碼
斷點續傳Server端的處理方式
從上述邏輯來看,這個後端的流程可以分為:
- 接受檔案的資料流,加入Buffer
- 接受完畢,提取內容
- 重新命名檔名
- 寫入本地
- 重新從第一步開始獲取檔案,直至所有切片接受完畢。
接收資料流
這估計是整個流程中最簡單的部分了,node監聽一下,組裝一下,搞定!
let buf=[]
ctx.req.on("data",(data)=>{
buf.push(data)
});
ctx.req.on("end",(data)=>{
if(buf.length>0){
string=Buffer.concat(buf)
}
})
複製程式碼
提取內容
大家還記不記得我們傳的是二進位制,而且這個二進位制除了文字欄位,還有檔案的二進位制。這個時候,我們就需要先提取欄位,再將檔案和普通文字分開處理。
先拼裝分隔符,這邊是一個規定,就是content-type
中的boundary
前面需要加上--
。
boundary=ctx.headers["content-type"].split("=")[1]
boundary = '--'+boundary
複製程式碼
上文提到過二進位制的分割只能用二進位制,因此,我麼可以把分隔符變成二進位制,然後再分割接收到的內容。
function splitBuffer(buffer,sep) {
let arr = [];
let pos = 0;//當前位置
let sepPosIndex = -1;//分隔符的位置
let sepPoslen = Buffer.from(sep).length;//分隔符的長度,以便確定下一個開始的位置
do{
sepPosIndex=buffer.indexOf(sep,pos)
if(sepPosIndex==-1){
//當sepPosIndex是-1的時候,代表已經到末尾了,那麼直接直接一口讀完最後的buffer
arr.push(buffer.slice(pos));
}else{
arr.push(buffer.slice(pos,sepPosIndex));
}
pos = sepPosIndex+sepPoslen
}while(-1!==sepPosIndex)
return arr
}
複製程式碼
分割完畢之後~就要開始處理啦!把欄位都提取出來。這邊我們把提取出的內容變成字串,首先這個是為了判斷欄位型別,其次如果不是檔案,那麼可以提取出我們的欄位文字,如果是檔案型別的,那麼就不能任性地toString
了,我們需要把二進位制的檔案內容完美儲存下來。
------WebKitFormBoundaryl8ZHdPtwG2eePQ2F
Content-Disposition: form-data; name="file"; filename="blob"
Content-Type: application/octet-streamk
換行*2
亂碼
換行*1
------WebKitFormBoundaryl8ZHdPtwG2eePQ2F--
複製程式碼
上傳的內容大概長這樣,空行的程式碼是\r\n
,轉化成二進位制就是佔2個位置,所以兩個空行的擷取就可以獲取到欄位資訊和內容。因為末尾也有一個空行,所以在擷取二進位制檔案內容的時候,除了頭部的長度+2換行的長度,末尾的1換行長度也要加上,所以是line.slice(head.length + 4, -2)
這個樣子的。
function copeData(buffer,boundary){
let lines = splitBuffer(buffer,boundary);
lines=lines.slice(1,-1);//去除首尾
let obj={};
lines.forEach(line=>{
let [head,tail] = splitBuffer(line,"\r\n\r\n");
head = head.toString();
if(head.includes('filename')){ // 這是檔案
obj["file"]= line.slice(head.length + 4, -2)
}else{
// 文字
let name = head.match(/name="(\w*)"/)[1];
let value= tail.toString().slice(0,-2);
obj[name]=value
}
});
}
複製程式碼
重新命名檔案
我們上傳的檔案一般不存在原名儲存,萬一大家喜歡傳重名的檔案呢?頭疼啊!這個時候就需要重新命名,我一般喜歡用md5來計算新的檔名。這裡可以拼接我們上傳的一些欄位 比如時間,主要是給一個特殊的標識,以保證當前上傳的檔案區別去其他檔案。畢竟相同的內容用md5計算都是一樣的,相同的檔名md5計算後並沒有起到區分的作用。
當然檔案的字尾不能忘記!不然檔案儲存下來了也打不開。所以記得提取一下檔案字尾。
let fileOriName=crypto.createHash("md5").update(obj.fileOriName).digest("hex")
let fileSuffix=obj.fileOriName.substring(obj.fileOriName.lastIndexOf(".")+1)
複製程式碼
儲存檔案
此處我是按照是否是第一切片為主,看看是新建覆蓋還是重新追加檔案內容。大家注意下,因為如果檔案不存在直接appendFileSync
是會報錯的。但是重複writeFileSync
又會覆蓋內容。所以需要區分一下,大家可以通過判斷檔案是否存在來進行區分~。
if(parseInt(obj.start)===0){
fs.writeFileSync(__dirname+`/uploads/${fileOriName}.${fileSuffix}`,obj.file);
}else{
fs.appendFileSync(__dirname+`/uploads/${fileOriName}.${fileSuffix}`,obj.file);
}
複製程式碼
repeat repeat repeat
重複重複~直至客戶端的切片全部傳送完畢~
附錄:
不理解KOA的可以看看我其他的文章:
本文的基礎,參考KOA,5步手寫一款粗糙的web框架
有關Router的實現思路,這份Koa的簡易Router手敲指南請收下
有關模板實現思路,KOA的簡易模板引擎實現方式