1. 程式人生 > >大檔案斷點續傳

大檔案斷點續傳

win10 node: v8.2.1 npm: v5.3.0 multer: v1.3.0

使用

1.由於對multer v1.3.0做了修改,所以不可以通過npm install multer這種形式,需要使用到修改過multer包去覆蓋原來的。
2.對於檔案上傳的介面,比如/upload,需要攜帶引數targetFileName和start。

  • targetFileName: 服務端生成目標檔案的名字。targetFileName可在seg-worker.js中匯出。seg-worker是一個web worker。var w1 = new Worker('seg-worker.js'); w1.postMessage({file: file})
  • start: 寫入到這個檔案中的位置。

3.如果是分段上傳,需要在multer.diskStorage中新增一個欄位seg。

multer.diskStorage({
  destination: cb,
  filename: cb,
  seg: true
})

同時需要修改原始碼。見下文update4

在multerv1.3.0版本中,通過multer.memoryStoragemulter.diskStorage來配置檔案的destinationfilename。具體怎麼寫入的這些細節multer內部做了封裝。下面是實現過程。完整流程圖在最下方。

update1

同一個使用者可能會重複上傳看似相同實則不同的檔案。比如兩個檔案,檔名一樣、大小一樣、相關時間都一樣。但是內容不一樣。這樣伺服器會判斷出兩個檔案是一樣的,禁止使用者重複上傳。

解決這個問題是在前端使用一個spark-md5的庫。該庫會根據檔案內容計算出檔案的md5。

update2

因為是大檔案,要做分段上傳,並且還可以續傳。比如,對於一個2g的大檔案,如果上傳到中途因為斷網需要從頭開始上傳,這是很麻煩的事情。斷點續傳可以解決這個問題。

基於multer,最開始的做法是在檔案上傳之前,在服務端建立一個和原始檔大小相同的新檔案(以檔案md5命名)。因
為段上傳是一個接一個,當multer寫完某個段到磁碟後,該檔案追加這個段。然後響應給客戶端,客戶端再上傳下一個段。這樣保證了順序,但是犧牲了速度。至於如何續傳,可以在上傳前去服務端或者本地快取拿到已經上傳的進度。

update3

因為瀏覽器可以同時發起多個請求,所以上述的一個接一個的請求沒有充分利用瀏覽器的這種特性。所以可以for迴圈,同時傳送所有ajax請求(瀏覽器會限制數量)。那麼如何保證上傳檔案的順序是個問題。這需要用到spark-md5這個庫。這需要計算出每個段的md5以及整個檔案的md5。前者在拼接檔案時用到(保證順序),後者用來標誌檔案的獨一性。

基於multer,最開始的做法是當multer寫完某個段到磁碟後,判斷是否所有的段都上傳完畢。如果上傳完畢了,就新建一個和原始檔相同大小的檔案,然後依次將這些段追加到該檔案。因為每個段都有一個md5,並且服務端事先取到了所有的段的md5。依次遍歷這些段的md5,就可以做到依次追加。追加完成後,刪除該段。最後理想的結果是生成一個與原始檔同樣大小的檔案,並且所有段被刪除。

最原始版本的斷點續傳就這樣完成了。

這樣可能造成的問題有:

  • 最多時佔用兩倍服務端空間,這在使用者量大的時候是非常可怕的
  • 寫完一個段,然後再複製,再追加到新檔案
  • multer對於此的處理有一個bug。當一個請求被取消(重新整理頁面,xhr.abort())的時候,可能該段已經上傳到服務端一部分(幾百k),然後再次上傳,最終能得到完整的檔案。但是這些之前上傳了一部分的段沒有刪除掉。

update4

對於update3中的第三個問題,是因為上傳的檔案流fileStream,也叫做源流,會被新增到目的流。即src.pipe(dest)。當有檔案上傳的時候,就將資料寫到目的流。這時候請求被取消,這個源流到目的流的關係並不會被取消,而是一直保持。只有當一個檔案(段)上傳完畢的時候,這種關係才會結束src.unpipe(dest)

解決這個問題,需要修改原始碼。當一個請求被取消的時候,會觸發reqclose事件。

// multer/lib/make-middleware.js 96行左右
busboy.on('file', function (fieldname, fileStream, filename, encoding, mimetype) {
      // when req is closed, like refresh page or xhr.abort(), remove the destination stream(busboy)
      req.on('close', function() {
        busboy.emit('finish')
      })
      // ......
}

其中,我就添加了上述三行程式碼req.on('close', cb)。busboy的finish事件中有一個操作req.unpipe(busboy)。也就是src.unpipe(dest)。也就是說,當請求被取消的時候,req將busboy從目的流中移除。busboy就不會再佔用這個檔案了。所以可以刪除掉了(無論是程式刪除還是手動刪除)。

對於update3中的前兩個問題,自然而然得想到了:先建立一個和原始檔同等大小的檔案,然後當段檔案到來的時候,直接寫到新建檔案的對應位置。而不是直接寫到磁碟。

但是multer沒有提供這種操作,multer在將檔案寫入到指定資料夾後才暴露出檔案相關的資訊給使用者。所以需要改原始碼。

後端操作如下:
1.當一個檔案上傳前,需要請求pre-uplaod介面。通過檔名+檔案md5(也可加上使用者id)的方式來判斷該檔案是否已經上傳過。global.uploads是一個物件,儲存著檔案相關的資訊(實際中這些資訊在資料庫)。如果檔案沒有上傳,在global.uploads上新增一個key,值是該檔案的相關資訊。返回響應給客戶端,客戶端可以上傳檔案。如果檔案上傳了部分或上傳過,返回不同響應給客戶端。

2.檔案上傳到後端,會進入multer。multer原本的處理是:

finalPath = path.join(destination, filename)
outStream = fs.createWriteStream(finalPath)

更正的部分:

// multer/storage/disk.js 41行左右
if(that.seg) {  
 var targetFileName = req.query.targetFileName
  var start = req.query.start
  if(!targetFileName) throw "query parameter of targetFileName is required"
  if(!start) throw "query parameter of start is required"

  finalPath = path.join(destination, targetFileName)
  outStream = fs.createWriteStream(finalPath, {
    flags: 'r+',
    autoClose: true,
    start: parseInt(req.query.start)
  })
} else {
  finalPath = path.join(destination, filename)
  outStream = fs.createWriteStream(finalPath)
}

在multer.diskStorage配置中添加了一個seg屬性。

var storage = multer.diskStorage({
  destination: cb,
  filename: cb,
  seg: true
}); 

如果是分段上傳,就寫到目標檔案。因為是檔案上傳,前端用的是FormData。如果在FormData中新增資料,後端req.body無法拿到資料(可以藉助其他包)。所以將資料放到了req.query中。targetFileName是必須的,表示要將這個段寫入到那個檔案。start也是必須的,表示要將這個段寫入到目標檔案的那個位置。所以前端需要/upload?targetFileName=xxx&start=xxx

update5

基本功能到這裡就完成了。後面的是優化部分。使用web worker可以開闢另外一個執行緒,防止堵塞主執行緒。在兩處使用了web worker。分別是給檔案分段部分,該部分使用worker意義不大,因為FileReader讀取檔案的操作是非同步的。並不會堵塞主執行緒。第二處是分段完畢後傳送ajax請求部分,因為for迴圈傳送所有請求。檔案很大的情況下會造成堵塞。所以放到web worker。

update6

封裝–為了更方便的呼叫。

update7

在multer2.0.x版本中,暴露出了file stream API,通過這個API可以自己決定將檔案寫到哪裡,而不是修改原始碼。

由於專案在公司完成,所以原始碼不便透露。有問題可以聯絡我微信a127620310。