本文實現的斷點續傳只是我對斷點續傳的一個理解。其中有不少不完善的地方,僅僅是記錄了一個我對斷點續傳一個實現過程。你們應該也會發現我用的都是一些H5的api,老得瀏覽器不會支持,以及我並未將跨域考慮入內,還有一些可能出現的一場等~巴啦啦。(怎麼感受這麼多問題???笑~)html
本文參考倉庫:點我前端
這幾天在認認真真地學習KOA框架,瞭解它的原理以及KOA中間件的實現方法。在研究KOA如何處理上傳的表單數據的時候,我靈光一閃,這是否是能夠用於斷點續傳?node
斷點續傳並非服務器端一端的自high,他還須要前端的配合,並且我只準備扒拉一個大體的雛形,因此這個功能我準備:git
斷點續傳的過程不復雜,可是仍是有許多小知識點須要get,否則很難理解斷點續傳的工做過程。實現斷點續傳的方式有不少,不過我只研究了ajax的方式,因此預備的小知識點以下:github
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
。web
raw data
在KOA中,server獲取到的data都是raw data
也就是未經處理的二進制數據。咱們須要格式化這些數據,提取有效內容。咱們來分析一下如何處理這些raw data
。ajax
當咱們上傳的時候,咱們會發現一個現象,就是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
是用來分割字段的。api
關於boundary
跨域
raw data
中,須要在前方加上--
,也就是這樣--boundary
,若是是結尾的分隔符那麼在末尾也加一個--
,就是這樣--boundary--
更多詳情,請參考The Multipart Content-Type
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)
})
複製代碼
重點部分來了,這一部分了坑得我好慘。
咱們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
}
複製代碼
slice
方法slice
以前是用於數組的一個方法,如今文件也能夠用slice
來分割拉,不過須要注意的是這個方法是一個新的api,也就是不少old的瀏覽器沒法使用。
用法很簡單:
//初始位置,長度
//這裏的File對象是一個Blob,一個相似於二進制的流,因此這裏是以字節爲單位的。
File.slice(startByte, length);
複製代碼
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~傳送成功 |
從上述邏輯來看,這個前端的流程能夠分爲:
斷點續傳是客戶端主動發送,服務器端被動接受的一個過程,因此這裏是在客戶端進行一個文件的切分,把文件根據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);
}
複製代碼
從上述邏輯來看,這個後端的流程能夠分爲:
這估計是整個流程中最簡單的部分了,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);
}
複製代碼
重複重複~直至客戶端的切片所有傳送完畢~
附錄:
不理解KOA的能夠看看我其餘的文章:
本文的基礎,參考KOA,5步手寫一款粗糙的web框架
有關Router的實現思路,這份Koa的簡易Router手敲指南請收下
有關模板實現思路,KOA的簡易模板引擎實現方式