欧美bbbwbbbw肥妇,免费乱码人妻系列日韩,一级黄片

前端使用koa實(shí)現(xiàn)大文件分片上傳

 更新時(shí)間:2022年08月26日 10:30:24   作者:樹(shù)下無(wú)光  
這篇文章主要為大家介紹了前端使用koa實(shí)現(xiàn)大文件分片上傳示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪

引言

一個(gè)文件資源服務(wù)器,很多時(shí)候需要保存的不只是圖片,文本之類的體積相對(duì)較小的文件,有時(shí)候,也會(huì)需要保存音視頻之類的大文件。在上傳這些大文件的時(shí)候,我們不可能一次性將這些文件數(shù)據(jù)全部發(fā)送,網(wǎng)絡(luò)帶寬很多時(shí)候不允許我們這么做,而且這樣也極度浪費(fèi)網(wǎng)絡(luò)資源。

因此,對(duì)于這些大文件的上傳,往往會(huì)考慮用到分片傳輸。

分片傳輸,顧名思義,也就是將文件拆分成若干個(gè)文件片段,然后一個(gè)片段一個(gè)片段的上傳,服務(wù)器也一個(gè)片段一個(gè)片段的接收,最后再合并成為完整的文件。

下面我們來(lái)一起簡(jiǎn)單地實(shí)現(xiàn)以下如何進(jìn)行大文件分片傳輸。

前端

拆分上傳的文件流

首先,我們要知道一點(diǎn):文件信息的 File 對(duì)象繼承自 Blob 類,也就是說(shuō), File 對(duì)象上也存在 slice 方法,用于截取指定區(qū)間的 Buffer 數(shù)組。

通過(guò)這個(gè)方法,我們就可以在取得用戶需要上傳的文件流的時(shí)候,將其拆分成多個(gè)文件來(lái)上傳:

<script setup lang='ts'>
import { ref } from "vue"
import { uploadLargeFile } from "@/api"
const fileInput = ref<HTMLInputElement>()
const onSubmit = () => {
  // 獲取文件對(duì)象
  const file = onlyFile.value?.file;
  if (!file) {
    return
  }
  const fileSize = file.size;  // 文件的完整大小
  const range = 100 * 1024; // 每個(gè)區(qū)間的大小
  let beginSide = 0; // 開(kāi)始截取文件的位置
  // 循環(huán)分片上傳文件
  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>

我們先定義一個(gè) onSubmit 方法來(lái)處理我們需要上傳的文件。

onSubmit 中,我們先取得 ref 中的文件對(duì)象,這里我們假設(shè)每次有且僅有一個(gè)文件,我們也只處理這一個(gè)文件。

然后我們定義 一個(gè) beginSiderange 變量,分別表示每次開(kāi)始截取文件數(shù)據(jù)的位置,以及每次截取的片段的大小。

這樣一來(lái),當(dāng)我們使用 file.slice(beginSide, beginSide + range) 的時(shí)候,我們就取得了這一次需要上傳的對(duì)應(yīng)的文件數(shù)據(jù),之后便可以使用 FormData 封裝這個(gè)文件數(shù)據(jù),然后調(diào)用接口發(fā)送到服務(wù)器了。

接著,我們使用一個(gè)循環(huán)不斷重復(fù)這一過(guò)程,直到 beginSide 超過(guò)了文件本身的大小,這時(shí)就表示這個(gè)文件的每個(gè)片段都已經(jīng)上傳完成了。當(dāng)然,別忘了每次切完片后,將 beginSide 移動(dòng)到下一個(gè)位置。

另外,需要注意的是,我們將文件的片添加到表單數(shù)據(jù)的時(shí)候,總共傳入了三個(gè)參數(shù)。第二個(gè)參數(shù)沒(méi)有什么好說(shuō)的,是我們的文件片段,關(guān)鍵在于第一個(gè)和第三個(gè)參數(shù)。這兩個(gè)參數(shù)都會(huì)作為 Content-Disposition 中的屬性。

第一個(gè)參數(shù),對(duì)應(yīng)的字段名叫做 name ,表示的是這個(gè)數(shù)據(jù)本身對(duì)應(yīng)的名稱,并不區(qū)分是什么數(shù)據(jù),因?yàn)?FormData 不只可以用作文件流的傳輸,也可以用作普通 JSON 數(shù)據(jù)的傳輸,那么這時(shí)候,這個(gè) name 其實(shí)就是 JSON 中某個(gè)屬性的 key 。

而第二個(gè)參數(shù),對(duì)應(yīng)的字段則是 filename ,這個(gè)其實(shí)才應(yīng)該真正地叫做文件名。

我們可以使用 wireshark 捕獲一下我們發(fā)送地請(qǐng)求以驗(yàn)證這一點(diǎn)。

我們?cè)儆^察上面構(gòu)建 FormData 的代碼,可以發(fā)現(xiàn),我們 append 進(jìn) FormData 實(shí)例的每個(gè)文件片段,使用的 name 都是固定為這個(gè)文件的真實(shí)名稱,因此,同一個(gè)文件的每個(gè)片,都會(huì)有相同的 name ,這樣一來(lái),服務(wù)器就能區(qū)分哪個(gè)片是屬于哪個(gè)文件的。

filename ,使用 beginSide 除以 range 作為其值,根據(jù)上下文語(yǔ)意可以推出,每個(gè)片的 filename 將會(huì)是這個(gè)片的 序號(hào) ,這是為了在后面服務(wù)端合并文件片段的時(shí)候,作為前后順序的依據(jù)。

當(dāng)然,上面的代碼還有一點(diǎn)問(wèn)題。

在循環(huán)中,我們確實(shí)是將文件切成若干個(gè)片單獨(dú)發(fā)送,但是,我們知道, http 請(qǐng)求是異步的,它不會(huì)阻塞主線程。所以,當(dāng)我們發(fā)送了一個(gè)請(qǐng)求之后,并不會(huì)等這個(gè)請(qǐng)求收到響應(yīng)再繼續(xù)發(fā)送下一個(gè)請(qǐng)求。因此,我們只是做到了將文件拆分成多個(gè)片一次性發(fā)送而已,這并不是我們想要的。

想要解決這個(gè)問(wèn)題也很簡(jiǎn)單,只需要將 onSubmit 方法修改為一個(gè)異步方法,使用 await 等待每個(gè) http 請(qǐng)求完成即可:

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

這樣一來(lái),每個(gè)片都會(huì)等到上一個(gè)片發(fā)送完成才發(fā)送,可以在網(wǎng)絡(luò)控制臺(tái)的時(shí)間線中看到這一點(diǎn):

后端

接收文件片段

這里我們使用的 koa-body 來(lái) 處理上傳的文件數(shù)據(jù):

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()
  }
)

我們的策略是先將同一個(gè) name 的文件片段收集到以這個(gè) name 進(jìn)行 MD5 哈希轉(zhuǎn)換后對(duì)應(yīng)的文件夾名稱的文件夾當(dāng)中,但使用 koa-body 提供的配置項(xiàng)無(wú)法做到這么細(xì)致的工作,所以,我們需要使用自定義 onFileBegin ,即在文件保存之前,將我們期望的工作完成。

首先,我們拼接出我們期望的路徑,并判斷這個(gè)路徑對(duì)應(yīng)的文件夾是否已經(jīng)存在,如果不存在,那么我們先創(chuàng)建這個(gè)文件夾。然后,我們需要修改 koa-body 傳給我們的 file 對(duì)象。因?yàn)閷?duì)象類型是引用類型,指向的是同一個(gè)地址空間,所以我們修改了這個(gè) file 對(duì)象的屬性, koa-body 最后獲得的 file 對(duì)象也就被修改了,因此, koa-body 就能夠根據(jù)我們修改的 file 對(duì)象去進(jìn)行后續(xù)保存文件的操作。

這里我們因?yàn)橐獙⒈4娴奈募付槲覀兤谕穆窂剑孕枰薷?filepath 這個(gè)屬性。

而在上文中我們提到,前端在 FormData 中傳入了第三個(gè)參數(shù)(文件片段的序號(hào)),這個(gè)參數(shù),我們可以通過(guò) file.originalFilename 訪問(wèn)。這里,我們就直接使用這個(gè)序號(hào)字段作為文件片段的名稱,也就是說(shuō),每個(gè)片段最終會(huì)保存到 ${tempDir}/${hashDir}/${序號(hào)} 這個(gè)文件。

由于每個(gè)文件片段沒(méi)有實(shí)際意義以及用處,所以我們不需要指定后綴名。

合并文件片段

在我們合并文件之前,我們需要知道文件片段是否已經(jīng)全部上傳完成了,這里我們需要修改一下前端部分的 onSubmit 方法,以發(fā)送給后端這個(gè)信號(hào):

// 省略一些代碼
const onSubmit = async () => {
  // ......
  while(beginSide < fileSize) {
    const formData = new FormData()
    formData.append(
      file.name, 
      file.slice(beginSide, beginSide + range), 
      (beginSide / range).toString()
    )
    beginSide += range
    // 滿足這個(gè)條件表示文件片段已經(jīng)全部發(fā)送完成,此時(shí)在表單中帶入結(jié)束信息
    if(beginSide >= fileSize) {
      formData.append("over", file.name)
    }
    await uploadLargeFile(formData)
  }
}
// ......

為圖方便,我們直接在一個(gè)接口中做傳輸結(jié)束的判斷。判斷的依據(jù)是:當(dāng) beiginSide 大于等于 fileSize 的時(shí)候,就放入一個(gè) over 字段,并以這個(gè)文件的真實(shí)名稱作為其屬性值。

這樣,后端代碼就可以以是否存在 over 這個(gè)字段作為文件片段是否已經(jīng)全部發(fā)送完成的標(biāo)志:

router.post(
  "/upload/largeFile",
  KoaBody({
    // 省略一些配置
  }),
  async (ctx, next) => {
    if (ctx.request.body.over) { // 如果 over 存在值,那么表示文件片段已經(jīng)全部上傳完成了
      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()
  }
)

我們先取得這個(gè)文件真實(shí)名字的 hash ,這個(gè)也是我們之前用于存放對(duì)應(yīng)文件片段使用的文件夾的名稱。

接著我們獲取該文件夾下的文件列表,這會(huì)是一個(gè)字符串?dāng)?shù)組(并且由于我們前期的設(shè)計(jì)邏輯,我們不需要在這里考慮文件夾的嵌套)。

然后我們遍歷這個(gè)數(shù)組,去拿到每個(gè)文件片段的路徑,以此來(lái)創(chuàng)建一個(gè)讀入流,再以存放合并后的文件的路徑創(chuàng)建一個(gè)寫入流(注意,此時(shí)需要帶上擴(kuò)展名,并且,需要設(shè)置 flags'a' ,表示追加寫入),最后以管道流的方式進(jìn)行傳輸。

但我們知道,這些使用到的流的操作都是異步回調(diào)的??墒?,我們保存的文件片段彼此之間是有先后順序的,也就是說(shuō),我們得保證在前面一個(gè)片段寫入完成之后再寫入下一個(gè)片段,否則文件的數(shù)據(jù)就錯(cuò)誤了。

要實(shí)現(xiàn)這一點(diǎn),需要使用到 Promise 這一api。

首先我們定義了一個(gè) fulfilled 狀態(tài)的 Promise 變量 p ,也就是說(shuō),這個(gè) p 變量的 then 方法將在下一個(gè)微任務(wù)事件的調(diào)用時(shí)間點(diǎn)直接被執(zhí)行。

接著,我們?cè)诒闅v文件片段列表的時(shí)候,不直接進(jìn)行讀寫,而是把讀寫操作放到 pthen 回調(diào)當(dāng)中,并且將其封裝在一個(gè) Promsie 對(duì)象當(dāng)中。在這個(gè) Promise 對(duì)象中,我們把 resolve 方法的執(zhí)行放在管道流的 finish 事件中,這表示,這個(gè) then 回調(diào)返回的 Promise 實(shí)例,將會(huì)在一個(gè)文件片段寫入完成后被修改狀態(tài)。此時(shí),我們只需要將這個(gè) then 回調(diào)返回的 Promsie 實(shí)例賦值給 p 即可。

這樣一來(lái),在下個(gè)遍歷節(jié)點(diǎn),也就是處理第二個(gè)文件片段的時(shí)候,取得的 p 的值便是上一個(gè)文件片段執(zhí)行完讀寫操作返回的 Promise 實(shí)例,而且第二個(gè)片段的執(zhí)行代碼會(huì)在第一個(gè)片段對(duì)應(yīng)的 Promise 實(shí)例 then 方法被觸發(fā),也就是上一個(gè)片段的文件寫入完成之后,再添加到微任務(wù)隊(duì)列。

以此類推,每個(gè)片段都會(huì)在前一個(gè)片段寫入完成之后再進(jìn)行寫入,保證了文件數(shù)據(jù)先后順序的正確性。

當(dāng)所有的文件片段讀寫完成后,我們就拿實(shí)現(xiàn)了將完整的文件保存到了服務(wù)器。

不過(guò)上面的還有許多可以優(yōu)化的地方,比如:在合并完文件之后,刪除所有的文件片段,節(jié)省磁盤空間;

使用一個(gè) Map 來(lái)保存真實(shí)文件名與 MD5 哈希值的映射關(guān)系,避免每次都進(jìn)行 MD5 運(yùn)算等等。但這里只是給出了簡(jiǎn)單的實(shí)習(xí),具體的優(yōu)化還請(qǐng)根據(jù)實(shí)際需求進(jìn)行調(diào)整。

總結(jié)

  • 使用 slice 方法可以截取 file 對(duì)象的片段,分次發(fā)送文件片段;
  • 使用 koa-body 保存每個(gè)文件片段到一個(gè)指定的暫存文件夾,在文件片段全部發(fā)送完成之后,將片段合并。

以上就是前端使用koa實(shí)現(xiàn)大文件分片上傳的詳細(xì)內(nèi)容,更多關(guān)于koa大文件分片上傳的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!

相關(guān)文章

最新評(píng)論