如何手寫一款KOA的中間件來實現斷點續傳

本文實現的斷點續傳只是我對斷點續傳的一個理解。其中有不少不完善的地方,僅僅是記錄了一個我對斷點續傳一個實現過程。你們應該也會發現我用的都是一些H5的api,老得瀏覽器不會支持,以及我並未將跨域考慮入內,還有一些可能出現的一場等~巴啦啦。(怎麼感受這麼多問題???笑~)html

本文參考倉庫:點我前端

這幾天在認認真真地學習KOA框架,瞭解它的原理以及KOA中間件的實現方法。在研究KOA如何處理上傳的表單數據的時候,我靈光一閃,這是否是能夠用於斷點續傳?node

斷點續傳並非服務器端一端的自high,他還須要前端的配合,並且我只準備扒拉一個大體的雛形,因此這個功能我準備:git

  • 後端:手寫KOA中間件處理斷點數據
  • 前端:原生JS

斷點續傳的過程不復雜,可是仍是有許多小知識點須要get,否則很難理解斷點續傳的工做過程。實現斷點續傳的方式有不少,不過我只研究了ajax的方式,因此預備的小知識點以下:github

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-dataweb

form上傳的raw data

在KOA中,server獲取到的data都是raw data也就是未經處理的二進制數據。咱們須要格式化這些數據,提取有效內容。咱們來分析一下如何處理這些raw dataajax

當咱們上傳的時候,咱們會發現一個現象,就是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跨域

  • 它的值是能夠自定義的,不過瀏覽器會幫咱們定義
  • 不能超過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的簡易模板引擎實現方式

相關文章
相關標籤/搜索