<em>Mac</em>Book项目 2009年学校开始实施<em>Mac</em>Book项目,所有师生配备一本<em>Mac</em>Book,并同步更新了校园无线网络。学校每周进行电脑技术更新,每月发送技术支持资料,极大改变了教学及学习方式。因此2011
2021-06-01 09:32:01
一個檔案資源伺服器,很多時候需要儲存的不只是圖片,文字之類的體積相對較小的檔案,有時候,也會需要儲存音視訊之類的大檔案。在上傳這些大檔案的時候,我們不可能一次性將這些檔案資料全部傳送,網路頻寬很多時候不允許我們這麼做,而且這樣也極度浪費網路資源。
因此,對於這些大檔案的上傳,往往會考慮用到分片傳輸。
分片傳輸,顧名思義,也就是將檔案拆分成若干個檔案片段,然後一個片段一個片段的上傳,伺服器也一個片段一個片段的接收,最後再合併成為完整的檔案。
下面我們來一起簡單地實現以下如何進行大檔案分片傳輸。
首先,我們要知道一點:檔案資訊的 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大檔案分片上傳的資料請關注it145.com其它相關文章!
相關文章
<em>Mac</em>Book项目 2009年学校开始实施<em>Mac</em>Book项目,所有师生配备一本<em>Mac</em>Book,并同步更新了校园无线网络。学校每周进行电脑技术更新,每月发送技术支持资料,极大改变了教学及学习方式。因此2011
2021-06-01 09:32:01
综合看Anker超能充系列的性价比很高,并且与不仅和iPhone12/苹果<em>Mac</em>Book很配,而且适合多设备充电需求的日常使用或差旅场景,不管是安卓还是Switch同样也能用得上它,希望这次分享能给准备购入充电器的小伙伴们有所
2021-06-01 09:31:42
除了L4WUDU与吴亦凡已经多次共事,成为了明面上的厂牌成员,吴亦凡还曾带领20XXCLUB全队参加2020年的一场音乐节,这也是20XXCLUB首次全员合照,王嗣尧Turbo、陈彦希Regi、<em>Mac</em> Ova Seas、林渝植等人全部出场。然而让
2021-06-01 09:31:34
目前应用IPFS的机构:1 谷歌<em>浏览器</em>支持IPFS分布式协议 2 万维网 (历史档案博物馆)数据库 3 火狐<em>浏览器</em>支持 IPFS分布式协议 4 EOS 等数字货币数据存储 5 美国国会图书馆,历史资料永久保存在 IPFS 6 加
2021-06-01 09:31:24
开拓者的车机是兼容苹果和<em>安卓</em>,虽然我不怎么用,但确实兼顾了我家人的很多需求:副驾的门板还配有解锁开关,有的时候老婆开车,下车的时候偶尔会忘记解锁,我在副驾驶可以自己开门:第二排设计很好,不仅配置了一个很大的
2021-06-01 09:30:48
不仅是<em>安卓</em>手机,苹果手机的降价力度也是前所未有了,iPhone12也“跳水价”了,发布价是6799元,如今已经跌至5308元,降价幅度超过1400元,最新定价确认了。iPhone12是苹果首款5G手机,同时也是全球首款5nm芯片的智能机,它
2021-06-01 09:30:45