本文實現的斷點續傳只是我對斷點續傳的一個理解。其中有不少不完善的地方,僅僅是記錄了一個我對斷點續傳一個實現過程。你們應該也會發現我用的都是一些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的簡易模板引擎實現方式