如何手寫一款KOA的中介軟體來實現斷點續傳

小美娜娜發表於2018-09-02

如何手寫一款KOA的中介軟體來實現斷點續傳

本文實現的斷點續傳只是我對斷點續傳的一個理解。其中有很多不完善的地方,僅僅是記錄了一個我對斷點續傳一個實現過程。大家應該也會發現我用的都是一些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-datatext/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中requestdataend監聽事件

傳資料給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的簡易模板引擎實現方式

相關文章