web前端怎么使用koa实现大文件分片上传
本篇内容介绍了“web前端怎么使用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 运算等等。但这里只是给出了简单的实习,具体的优化还请根据实际需求进行调整。
相关文章