首頁 > 軟體

前端使用koa實現大檔案分片上傳

2022-08-26 14:03:19

引言

一個檔案資源伺服器,很多時候需要儲存的不只是圖片,文字之類的體積相對較小的檔案,有時候,也會需要儲存音視訊之類的大檔案。在上傳這些大檔案的時候,我們不可能一次性將這些檔案資料全部傳送,網路頻寬很多時候不允許我們這麼做,而且這樣也極度浪費網路資源。

因此,對於這些大檔案的上傳,往往會考慮用到分片傳輸。

分片傳輸,顧名思義,也就是將檔案拆分成若干個檔案片段,然後一個片段一個片段的上傳,伺服器也一個片段一個片段的接收,最後再合併成為完整的檔案。

下面我們來一起簡單地實現以下如何進行大檔案分片傳輸。

前端

拆分上傳的檔案流

首先,我們要知道一點:檔案資訊的 File 物件繼承自 Blob 類,也就是說, File 物件上也存在 slice 方法,用於擷取指定區間的 Buffer 陣列。

通過這個方法,我們就可以在取得使用者需要上傳的檔案流的時候,將其拆分成多個檔案來上傳:

<script setup lang='ts'>
import { ref } from "vue"
import { uploadLargeFile } from "@/api"
const fileInput = ref<HTMLInputElement>()
const onSubmit = () => {
  // 獲取檔案物件
  const file = onlyFile.value?.file;
  if (!file) {
    return
  }
  const fileSize = file.size;  // 檔案的完整大小
  const range = 100 * 1024; // 每個區間的大小
  let beginSide = 0; // 開始擷取檔案的位置
  // 迴圈分片上傳檔案
  while (beginSide < fileSize) {
    const formData = new FormData()
    formData.append(
      file.name, 
      file.slice(beginSide, beginSide + range), 
      (beginSide / range).toString()
    )
    beginSide += range
    uploadLargeFile(formData)
  }
}
</script>
<template>
  <input
    ref="fileInput"
    type="file"
    placeholder="選擇你的檔案"
  >
  <button @click="onSubmit">提交</button>
</template>

我們先定義一個 onSubmit 方法來處理我們需要上傳的檔案。

onSubmit 中,我們先取得 ref 中的檔案物件,這裡我們假設每次有且僅有一個檔案,我們也只處理這一個檔案。

然後我們定義 一個 beginSiderange 變數,分別表示每次開始擷取檔案資料的位置,以及每次擷取的片段的大小。

這樣一來,當我們使用 file.slice(beginSide, beginSide + range) 的時候,我們就取得了這一次需要上傳的對應的檔案資料,之後便可以使用 FormData 封裝這個檔案資料,然後呼叫介面傳送到伺服器了。

接著,我們使用一個迴圈不斷重複這一過程,直到 beginSide 超過了檔案本身的大小,這時就表示這個檔案的每個片段都已經上傳完成了。當然,別忘了每次切完片後,將 beginSide 移動到下一個位置。

另外,需要注意的是,我們將檔案的片新增到表單資料的時候,總共傳入了三個引數。第二個引數沒有什麼好說的,是我們的檔案片段,關鍵在於第一個和第三個引數。這兩個引數都會作為 Content-Disposition 中的屬性。

第一個引數,對應的欄位名叫做 name ,表示的是這個資料本身對應的名稱,並不區分是什麼資料,因為 FormData 不只可以用作檔案流的傳輸,也可以用作普通 JSON 資料的傳輸,那麼這時候,這個 name 其實就是 JSON 中某個屬性的 key

而第二個引數,對應的欄位則是 filename ,這個其實才應該真正地叫做檔名。

我們可以使用 wireshark 捕獲一下我們傳送地請求以驗證這一點。

我們再觀察上面構建 FormData 的程式碼,可以發現,我們 appendFormData 範例的每個檔案片段,使用的 name 都是固定為這個檔案的真實名稱,因此,同一個檔案的每個片,都會有相同的 name ,這樣一來,伺服器就能區分哪個片是屬於哪個檔案的。

filename ,使用 beginSide 除以 range 作為其值,根據上下文語意可以推出,每個片的 filename 將會是這個片的 序號 ,這是為了在後面伺服器端合併檔案片段的時候,作為前後順序的依據。

當然,上面的程式碼還有一點問題。

在迴圈中,我們確實是將檔案切成若干個片單獨傳送,但是,我們知道, http 請求是非同步的,它不會阻塞主執行緒。所以,當我們傳送了一個請求之後,並不會等這個請求收到響應再繼續傳送下一個請求。因此,我們只是做到了將檔案拆分成多個片一次性傳送而已,這並不是我們想要的。

想要解決這個問題也很簡單,只需要將 onSubmit 方法修改為一個非同步方法,使用 await 等待每個 http 請求完成即可:

// 省略一些程式碼
const onSubmit = async () => {
  // ......
  while(beginSide < fileSize) {
    // ......
    await uploadLargeFile(formData)
  }
}
// ......

這樣一來,每個片都會等到上一個片傳送完成才傳送,可以在網路控制檯的時間線中看到這一點:

後端

接收檔案片段

這裡我們使用的 koa-body 來 處理上傳的檔案資料:

import Router = require("@koa/router")
import KoaBody = require("koa-body")
import { resolve } from 'path'
import { publicPath } from "../common";
import { existsSync, mkdirSync } from "fs"
import { MD5 } from "crypto-js"
const router = new Router()
const savePath = resolve(publicPath, 'assets')
const tempDirPath = resolve(publicPath, "assets", "temp")
router.post(
  "/upload/largeFile",
  KoaBody({
    multipart: true,
    formidable: {
      maxFileSize: 1024 * 1024 * 2,
      onFileBegin(name, file) {
        const hashDir = MD5(name).toString()
        const dirPath = resolve(tempDirPath, hashDir)
        if (!existsSync(dirPath)) {
          mkdirSync(dirPath, { recursive: true })
        }
        if (file.originalFilename) {
          file.filepath = resolve(dirPath, file.originalFilename)
        }
      }
    }
  }),
  async (ctx, next) => {
    ctx.response.body = "done";
    next()
  }
)

我們的策略是先將同一個 name 的檔案片段收集到以這個 name 進行 MD5 雜湊轉換後對應的資料夾名稱的資料夾當中,但使用 koa-body 提供的設定項無法做到這麼細緻的工作,所以,我們需要使用自定義 onFileBegin ,即在檔案儲存之前,將我們期望的工作完成。

首先,我們拼接出我們期望的路徑,並判斷這個路徑對應的資料夾是否已經存在,如果不存在,那麼我們先建立這個資料夾。然後,我們需要修改 koa-body 傳給我們的 file 物件。因為物件型別是參照型別,指向的是同一個地址空間,所以我們修改了這個 file 物件的屬性, koa-body 最後獲得的 file 物件也就被修改了,因此, koa-body 就能夠根據我們修改的 file 物件去進行後續儲存檔案的操作。

這裡我們因為要將儲存的檔案指定為我們期望的路徑,所以需要修改 filepath 這個屬性。

而在上文中我們提到,前端在 FormData 中傳入了第三個引數(檔案片段的序號),這個引數,我們可以通過 file.originalFilename 存取。這裡,我們就直接使用這個序號欄位作為檔案片段的名稱,也就是說,每個片段最終會儲存到 ${tempDir}/${hashDir}/${序號} 這個檔案。

由於每個檔案片段沒有實際意義以及用處,所以我們不需要指定字尾名。

合併檔案片段

在我們合併檔案之前,我們需要知道檔案片段是否已經全部上傳完成了,這裡我們需要修改一下前端部分的 onSubmit 方法,以傳送給後端這個訊號:

// 省略一些程式碼
const onSubmit = async () => {
  // ......
  while(beginSide < fileSize) {
    const formData = new FormData()
    formData.append(
      file.name, 
      file.slice(beginSide, beginSide + range), 
      (beginSide / range).toString()
    )
    beginSide += range
    // 滿足這個條件表示檔案片段已經全部傳送完成,此時在表單中帶入結束資訊
    if(beginSide >= fileSize) {
      formData.append("over", file.name)
    }
    await uploadLargeFile(formData)
  }
}
// ......

為圖方便,我們直接在一個介面中做傳輸結束的判斷。判斷的依據是:當 beiginSide 大於等於 fileSize 的時候,就放入一個 over 欄位,並以這個檔案的真實名稱作為其屬性值。

這樣,後端程式碼就可以以是否存在 over 這個欄位作為檔案片段是否已經全部傳送完成的標誌:

router.post(
  "/upload/largeFile",
  KoaBody({
    // 省略一些設定
  }),
  async (ctx, next) => {
    if (ctx.request.body.over) { // 如果 over 存在值,那麼表示檔案片段已經全部上傳完成了
      const _fileName = ctx.request.body.over;
      const ext = _fileName.split(".")[1]
      const hashedDir = MD5(_fileName).toString()
      const dirPath = resolve(tempDirPath, hashedDir)
      const fileList = readdirSync(dirPath);
      let p = Promise.resolve(void 0)
      fileList.forEach(fragmentFileName => {
        p = p.then(() => new Promise((r) => {
            const ws = createWriteStream(resolve(savePath, `${hashedDir}.${ext}`), { flags: "a" })
            const rs = createReadStream(resolve(dirPath, fragmentFileName))
            rs.pipe(ws).on("finish", () => {
              ws.close()
              rs.close();
              r(void 0)
            })
          })
        )
      })
      await p
    }
    ctx.response.body = "done";
    next()
  }
)

我們先取得這個檔案真實名字的 hash ,這個也是我們之前用於存放對應檔案片段使用的資料夾的名稱。

接著我們獲取該資料夾下的檔案列表,這會是一個字串陣列(並且由於我們前期的設計邏輯,我們不需要在這裡考慮資料夾的巢狀)。

然後我們遍歷這個陣列,去拿到每個檔案片段的路徑,以此來建立一個讀入流,再以存放合併後的檔案的路徑建立一個寫入流(注意,此時需要帶上擴充套件名,並且,需要設定 flags'a' ,表示追加寫入),最後以管道流的方式進行傳輸。

但我們知道,這些使用到的流的操作都是非同步回撥的。可是,我們儲存的檔案片段彼此之間是有先後順序的,也就是說,我們得保證在前面一個片段寫入完成之後再寫入下一個片段,否則檔案的資料就錯誤了。

要實現這一點,需要使用到 Promise 這一api。

首先我們定義了一個 fulfilled 狀態的 Promise 變數 p ,也就是說,這個 p 變數的 then 方法將在下一個微任務事件的呼叫時間點直接被執行。

接著,我們在遍歷檔案片段列表的時候,不直接進行讀寫,而是把讀寫操作放到 pthen 回撥當中,並且將其封裝在一個 Promsie 物件當中。在這個 Promise 物件中,我們把 resolve 方法的執行放在管道流的 finish 事件中,這表示,這個 then 回撥返回的 Promise 範例,將會在一個檔案片段寫入完成後被修改狀態。此時,我們只需要將這個 then 回撥返回的 Promsie 範例賦值給 p 即可。

這樣一來,在下個遍歷節點,也就是處理第二個檔案片段的時候,取得的 p 的值便是上一個檔案片段執行完讀寫操作返回的 Promise 範例,而且第二個片段的執行程式碼會在第一個片段對應的 Promise 範例 then 方法被觸發,也就是上一個片段的檔案寫入完成之後,再新增到微任務佇列。

以此類推,每個片段都會在前一個片段寫入完成之後再進行寫入,保證了檔案資料先後順序的正確性。

當所有的檔案片段讀寫完成後,我們就拿實現了將完整的檔案儲存到了伺服器。

不過上面的還有許多可以優化的地方,比如:在合併完檔案之後,刪除所有的檔案片段,節省磁碟空間;

使用一個 Map 來儲存真實檔名與 MD5 雜湊值的對映關係,避免每次都進行 MD5 運算等等。但這裡只是給出了簡單的實習,具體的優化還請根據實際需求進行調整。

總結

  • 使用 slice 方法可以擷取 file 物件的片段,分次傳送檔案片段;
  • 使用 koa-body 儲存每個檔案片段到一個指定的暫存資料夾,在檔案片段全部傳送完成之後,將片段合併。

以上就是前端使用koa實現大檔案分片上傳的詳細內容,更多關於koa大檔案分片上傳的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com