前端使用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
中的文件對象,這裡我們假設每次有且僅有一個文件,我們也隻處理這一個文件。
然後我們定義 一個 beginSide
和 range
變量,分別表示每次開始截取文件數據的位置,以及每次截取的片段的大小。
這樣一來,當我們使用 file.slice(beginSide, beginSide + range)
的時候,我們就取得瞭這一次需要上傳的對應的文件數據,之後便可以使用 FormData
封裝這個文件數據,然後調用接口發送到服務器瞭。
接著,我們使用一個循環不斷重復這一過程,直到 beginSide
超過瞭文件本身的大小,這時就表示這個文件的每個片段都已經上傳完成瞭。當然,別忘瞭每次切完片後,將 beginSide
移動到下一個位置。
另外,需要註意的是,我們將文件的片添加到表單數據的時候,總共傳入瞭三個參數。第二個參數沒有什麼好說的,是我們的文件片段,關鍵在於第一個和第三個參數。這兩個參數都會作為 Content-Disposition
中的屬性。
第一個參數,對應的字段名叫做 name
,表示的是這個數據本身對應的名稱,並不區分是什麼數據,因為 FormData
不隻可以用作文件流的傳輸,也可以用作普通 JSON
數據的傳輸,那麼這時候,這個 name
其實就是 JSON
中某個屬性的 key
。
而第二個參數,對應的字段則是 filename
,這個其實才應該真正地叫做文件名。
我們可以使用 wireshark
捕獲一下我們發送地請求以驗證這一點。
我們再觀察上面構建 FormData
的代碼,可以發現,我們 append
進 FormData
實例的每個文件片段,使用的 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
方法將在下一個微任務事件的調用時間點直接被執行。
接著,我們在遍歷文件片段列表的時候,不直接進行讀寫,而是把讀寫操作放到 p
的 then
回調當中,並且將其封裝在一個 Promsie
對象當中。在這個 Promise
對象中,我們把 resolve
方法的執行放在管道流的 finish
事件中,這表示,這個 then
回調返回的 Promise
實例,將會在一個文件片段寫入完成後被修改狀態。此時,我們隻需要將這個 then
回調返回的 Promsie
實例賦值給 p
即可。
這樣一來,在下個遍歷節點,也就是處理第二個文件片段的時候,取得的 p
的值便是上一個文件片段執行完讀寫操作返回的 Promise
實例,而且第二個片段的執行代碼會在第一個片段對應的 Promise
實例 then
方法被觸發,也就是上一個片段的文件寫入完成之後,再添加到微任務隊列。
以此類推,每個片段都會在前一個片段寫入完成之後再進行寫入,保證瞭文件數據先後順序的正確性。
當所有的文件片段讀寫完成後,我們就拿實現瞭將完整的文件保存到瞭服務器。
不過上面的還有許多可以優化的地方,比如:在合並完文件之後,刪除所有的文件片段,節省磁盤空間;
使用一個 Map 來保存真實文件名與 MD5 哈希值的映射關系,避免每次都進行 MD5 運算等等。但這裡隻是給出瞭簡單的實習,具體的優化還請根據實際需求進行調整。
總結
- 使用
slice
方法可以截取file
對象的片段,分次發送文件片段; - 使用
koa-body
保存每個文件片段到一個指定的暫存文件夾,在文件片段全部發送完成之後,將片段合並。
以上就是前端使用koa實現大文件分片上傳的詳細內容,更多關於koa大文件分片上傳的資料請關註WalkonNet其它相關文章!
推薦閱讀:
- 利用node+koa+axios實現圖片上傳和回顯功能
- Vue+NodeJS實現大文件上傳的示例代碼
- JS中的async與await怎麼使用
- es7中的async、await使用方法示例詳解
- 前端常見面試題之async/await和promise的區別