前端使用koa實現大文件分片上傳

引言

一個文件資源服務器,很多時候需要保存的不隻是圖片,文本之類的體積相對較小的文件,有時候,也會需要保存音視頻之類的大文件。在上傳這些大文件的時候,我們不可能一次性將這些文件數據全部發送,網絡帶寬很多時候不允許我們這麼做,而且這樣也極度浪費網絡資源。

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

分片傳輸,顧名思義,也就是將文件拆分成若幹個文件片段,然後一個片段一個片段的上傳,服務器也一個片段一個片段的接收,最後再合並成為完整的文件。

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

前端

拆分上傳的文件流

首先,我們要知道一點:文件信息的 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大文件分片上傳的資料請關註WalkonNet其它相關文章!

推薦閱讀: