大三实习找工作中,求内推~
前言
前阵子被一个 base 北京的小厂面到这个问题,我仅知道实现大文件上传可以通过切片的方式,但是当面试官问我具体实现细节的时候我就沉默了,这个问题本来就应该是大厂问的,心想现在行业都这么卷了吗,小厂对实习生问这问题,不行,今天必须把这个文件上传搞明白
中间的实现还是比较复杂的,主要是后端文件和文件夹名字冲突了,最后干脆摒弃文件夹了
手把手教,小白也能放心食用~
建议跟着敲一遍💕
正文
先看下不做任何处理,大文件上传会有什么问题吧~
大文件并没有一个说超过多少 M 就是大文件,我可以说 100M 就是大文件,也可以说 10M 就是大文件
直接上传带来的问题
- 耗时很久
nginx
反向代理映射时间超时- 无法得知上传的进度,无法暂停
文件若是一次性上传,耗时会比较久,切片带来的好处还有个就是你可以得知进度,比如一个文件切成5份,发一份过去就是20%
具体实现
前端 vue ,后端 node
前端
前端要实现的就是点击按钮可以上传文件
那就写一个 input 框,input 框的 type 改成 file
类型,它可以自动拿到本地的文件
xml
<div id="app">
<input type="file" @change="handleChange">
<button>上传</button>
</div>
<script>
const { createApp, ref } = Vue
createApp({
setup() {
const handleChange = (e) => {
console.log(e);
}
return {
handleChange
}
}
}).mount('#app')
</script>
想要清楚他拿到的文件长什么样子我们需要给 input 框绑定一个 change
事件
文件就在事件参数中,e.target.files[0]
展开来看里面的信息,里面有个 lastModified
,协商缓存中提到过这个字段,表示的是上一次文件修改的时间,里面还有文件的大小 12468172 的 size 大小,单位是字节 Byte ,除两次 1024 就是 M 了,这里是 11.8M
这个文件是个视频,视频是去年女朋友给我拍的,当时参加英语配音大赛,如今已经分手了😭,其实这个账号头像也是她给我拍的,很多人都以为是网图
其实我们将文件传输给后端,就是这个 file 对象
我们将这个 file 对象进行响应式处理存起来,拿到文件后进行切片,也就是点击上传时触发这个函数,那就再写一个点击事件 handleUpload
ini
const handleUpload = () => {
if (!uploadFile.value) return
const chunkList = createChunk(uploadFile.value)
}
切片函数 createChunk
我拿出来写
arduino
const createChunk = (file, size = 1 * 1024 * 1024) => {
const chunkList = []
let cur = 0
while (cur < file.size) {
chunkList.push({ file: file.slice(cur, cur + size) })
cur += size
}
return chunkList
}
这个函数才是切片的精髓!函数第二个参数表示默认切片大小为 1M , chunkList
用于存放切片, cur
是切的进度, while
循环切,当切完时 cur = file.size
跳出循环
注意这里, file.slice
???, slice
不是数组和字符串身上的方法吗, file
是个对象啊,别急,我们不防看看这个 file
的原型
文件的原型是一个 Blob
对象,我们再展开 Blob
嚯~原来 slice
是 Blob
身上的啊,我们不妨看下 cdn 上的 blob 介绍
Blob
cdn介绍 :Blob
对象表示一个不可变、原始数据的类文件对象。它的数据可以按文本或二进制的格式进行读取,也可以转换成 ReadableStream
来用于数据操作。
当我们选中一个文件时,浏览器默认会帮我们将文件转成一个 Blob
对象, Blob
上自带一个 slice
方法,Blob.slice()
接受的参数不是下标,而是起始字节
,最终字节
并且 slice
最终返回一个Blob对象
好了, 11.8M 的文件按照 1M 来切就是 12 份,最后一份是 0.8 M,打印看看这个 chunkList
数组
完美!
接下来的逻辑就是拿到朝着后端发请求了,把切片都给过去
发请求
这里用 axios
发请求,自行 cdn
引入
如果想要实现进度条,就自行封装 axios
,请求函数 requestUpload
,请求方法写死 post
,并且axios
人家支持传入一个 onUploadProgress
函数用于计算上传进度
typescript
const requestUpload = ({ url, method = 'post', data, headers = {}, onUploadProgress = (e) => e }) => {
return new Promise((resolve, reject) => {
// axios支持在请求中传入一个回调onUploadProgress,其目的就是为了知道请求的进度
axios[method](url, data, { headers, onUploadProgress })
.then(res => {
resolve(res)
})
.catch(err => {
reject(err)
})
})
}
发请求之前其实还需要对 chunkList
进行一个处理,刚才打印 chunkList
时,里面的每一个切片仅仅只有大小信息,没有其他参数,后端是需要其他信息的,因为网络原因,切片不可能是按照顺序接收的,这里我给 chunkList
再加上下标,还有文件名,切片名,如下
javascript
const handleUpload = () => {
if (!uploadFile.value) return
const chunkList = createChunk(uploadFile.value)
// console.log(chunkList);
// 另外切片需要打上标记,保证后端正确合并
uploadChunkList.value = chunkList.map(({ file }, index) => {
return {
file,
size: file.size,
percent: 0,
chunkName: `${uploadFile.value.name}-${index}`,
fileName: uploadFile.value.name,
index
}
})
console.log(uploadChunkList.value);
// 发请求 把切片一个一个地给后端
uploadChunks()
}
chunkList
的每一项都是个对象,里面的file
才是我们需要的,因此进行解构
uploadFile
里面是有name
属性的,就是文件名
uploadChunkList
就是封装好的切片,这个切片比 chunkList
多了其他后端需要的信息, uploadChunkList
被 map
赋值后就直接发请求, uploadChunks
待会儿来写
我们先打印看下切片是否如预期所示,有这些信息
完美!
好了,现在实现函数 uploadChunks
来发请求
发请求并不是直接将封装好的切片数组 uploadChunkList
交给后端,因为后端并不认识你这个对象格式,我们需要先将其转换成数据流。
方才的 Blob
在 mdn
的介绍中就说到了, Blob
可以按二进制的格式进行读取,也可以用ReadableStream
数据操作
这里我用原生 js 的表单数据流来传递,因此将其转成表单格式的数据流,它是二进制的
go
const uploadChunks = () => {
const formateList = uploadChunkList.value.map(({ file, fileName, index, chunkName }) => {
// 对象需要转成二进制数据流进行传输
const formData = new FormData() // 创建表单格式的数据流
// 将切片转换成了表单的数据流
formData.append('file', file)
formData.append('fileName', fileName)
formData.append('chunkName', chunkName)
return { formData, index }
})
}
formateList
只拿封装好的切片数组中的重要信息file
,fileName
,index
,chunkName
,并且在map
中创建一个二进制表单数据流,将这些信息挂到formData
中,最终赋值给formateList
之前封装好的 uploadChunkList
被转换成了 formateList
,这个切片数组是表单数据格式,不妨看看长啥样
居然看不到长啥样!其实这就是二进制数据,浏览器不会给你看的
为何要转表单格式?
答:
Blob
是 js 独有的,虽是文件类型,但是不便用于传输,后端那么多语言不一定有 Blob 。但是表单格式前后端都有,其实最早前后端传输就是这个表单格式
好了,把格式问题弄好后,现在对每一个 form
表单格式的切片进行发请求,依旧用 map
遍历,每一个表单切片都进行调用方才封装好的请求函数 requestUpload
,这个函数的里面有个进度条回调函数,我也拿出来写下
javascript
const uploadChunks = () => {
const formateList = uploadChunkList.value.map(({ file, fileName, index, chunkName }) => {
// 对象需要转成二进制数据流进行传输
const formData = new FormData() // 创建表单格式的数据流
// 将切片转换成了表单的数据流
formData.append('file', file)
formData.append('fileName', fileName)
formData.append('chunkName', chunkName)
return { formData, index }
})
const requestList = formateList.map(({ formData, index }) => {
return requestUpload({
url: 'http://localhost:3000/upload',
data: formData,
onUploadProgress: createProgress(uploadChunkList.value[index]) // 进度条函数拿出来写
})
})
}
待会儿后端的路由就写
upload
路径
之前的 uploadChunkList
已经准备好了 percent
,createProgress
函数就是用于更改这个 percent
的,拿出来写
javascript
const createProgress = (item) => {
return (e) => {
// 为何函数需要return出来,因为axios的onUploadProgress就是个函数体
// 并且这个函数体参数e就是进度
item.percent = parseInt(String(e.loaded / e.total) * 100) // axios提供的
}
}
axios
提供好了写法,直接用即可
好了,目前前端先写到这里,现在转入后端,其实前端还有接口要写,待会儿再补充
后端
后端要实现的是拿到前端传过来切片进行合并
后端待会儿要安装依赖,自行npm init -y
下
先简单写下,把 http
服务跑起来
javascript
const http = require('http')
const server = http.createServer((req, res) => {
if (req.url === '/upload') {
res.end('hello world')
}
})
server.listen(3000, () => {
console.log('listening on port 3000');
})
不用看,这里前端拿不到数据,还没处理跨域呢,前端用 live server
跑在 5501 端口,后端在 3000 端口
解决跨域
这里用 Cors
解决
arduino
// 解决跨域
res.setHeader('Access-Control-Allow-Origin', '*') // 允许所有的请求源来跨域
res.setHeader('Access-Control-Allow-Headers', '*') // 允许所有的请求头来跨域
需要设置两个,一个 请求源,一个 请求头
预检请求
为了保证跨域请求的安全性,比如 csrf
攻击,这里再写个预检请求。跨域请求时,浏览器默认会发一种 options
请求,用于向服务端请求许可,以确定实际请求是否安全,通过预检请求,服务端可以检查跨域请求的来源,请求的方法,请求的头部等信息,再来决定是否允许请求
ini
// 请求预检
if (req.method === 'OPTIONS') {
res.status = 200
res.end()
return
}
拿到切片
前端请求的方法是 post
,原生 node
想要拿到 post
请求需要用上中间件 body-parser
进行解析,其实这里我们也不需要这个 body-parser
,因为前端传过来的请求数据不是正常的对象,而是表单数据
为何
post
请求要特殊点,因为post
支持更多的编码类型,并且不对数据类型做限制
前端将数据处理成表单数据后发给传给后端,请求头中自动会多出一个Content-Type: multipart/form-data;
字段,目的就是告诉后端此时你要接收的数据是表单格式
后端想要解析这个格式,需要npm i multiparty
,调用 parse
函数,直接把请求体 req
丢给它,它自动帮你解析
多方 - NPM --- multiparty - npm (npmjs.com)
如果直接拿
req.request
你是拿不到的,是undefined
,毕竟是 二进制 数据,不会给你看的
javascript
if (req.url === '/upload') {
const form = new multiparty.Form()
form.parse(req, (err, fields, files) => {
if (err) {
console.log(err);
return
}
console.log(fields, files);
})
}
fields
和 files
就是被解析出来的数据, fields
是文件名和切片名,files
是切片的详细数据,打印下看看
看到没有,切片顺序是乱的,因此待会儿不能直接合并
先把切片,文件名,切片名都拿到
ini
const file = files.file[0]
const fileName = fields.fileName[0]
const chunkName = fields.chunkName[0]
存入切片
这里我存到 server
文件目录下,方便演示
先提前准备一个路径UPLOAD_DIR
用于存放切片
arduino
const UPLOAD_DIR = path.resolve(__dirname, '.', 'chunks') // 准备好地址用来存切片
resolve
的作用是将路径进行合并,__dirname
是当前文件的绝对路径,.
是下一级,文件夹就叫chunks
记得提前引入
path
模块
创建文件夹用 fse
模块,这个模块是 fs
模块的加强版
fse
模块是fs
模块的扩展,它增加了异步递归的操作、Promise
支持以及额外的方法
先要判断UPLOAD_DIR
切片目录是否存在,不存在则创建这个文件夹
scss
if (!fse.existsSync(UPLOAD_DIR)) { // 判断目录是否存在
fse.mkdirsSync(UPLOAD_DIR)
}
node
中很多方法都有同步版本,比如这里mkdirsSync
是同步创建目录以及父目录,Sync
就是表示同步,没有这个就是异步
现在可以运行试试,前端请求是否真实给我们创建一个 chunk
用于存放切片的目录
完美!
其实这里本来应该多创建一层目录的,比如
比赛现场.mp4
视频应该还包裹一个文件夹,但是这个文件夹名会与待会儿存入的切片名相冲突,我就干脆摒弃了
现在文件目录已经创建好了,接下来就是将切片写入这个目录下
刚才打印的 files
,里面有个 path
,细心的小盆友应该发现了,前端传入的切片,先是被默认放入到 C盘 的 temp
目录下,这是操作系统给我们做的,我们现在需要将存放在 temp
下的切片挪到这个 chunks
中
javascript
fse.moveSync(file.path, `${UPLOAD_DIR}/${chunkName}`)
现在再试试看,是否帮我们把每个切片的目录给生成好
很奇怪,明明有12个切片,仅仅给我生成了 6 个,并且下标为 5 的切片还丢了
我这里试了很久,不管怎么试,最终都是 6 个,后面才知道原来这是操作系统的限制问题。操作系统在处理文件和目录时通常会限制每个目录中的子项数量,这是为确保文件系统的稳定性。既然如此,那肯定有其他办法,这里我从简处理,在前端将切片的大小由原来的 1M 改成 2M ,这样最终就是 6 个切片了😂
const createChunk = (file, size = 2 * 1024 * 1024) => ......
切片已经从 C盘 挪到我 chunk
中了,接下来要干的就是合并切片,合并之前一定要将顺序捋正来,从刚刚的打印就可以看出,切片的顺序是乱的,不过我们已经处理好了,因为切片名 chunkName
最后是有个下标的,这个下标和左边的一部分被-
分隔开,因此我们可以用 split
将字符串分割成数组,传入-
就是取到数组第二项,就是下标
合并切片
后端什么时候合并切片呢?
这里有几个方案可以实现
- 后端监听上传请求,当所有的请求都上传完毕时,出发合并请求操作
- 前端发完切片后,最后发一个合并请求
- 后端设置一个预期切片数量,达标后合并切片
普遍方案都是第二个,前端发完切片后发一个合并请求,开干!
后端
ini
if (req.url === '/upload') {
......
} else if (req.url === '/merge') {
......
}
前端
前端刚才已经发完了所有的切片,继续在下面发一个合并切片的请求 mergeChunks
前端用 map
格式化好 formateList
切片数组后得到的 requestList
就是一个一个的切片请求数组,刚好放入 Promise.all
中实现并发, then
中写入请求函数 mergeChunks
,完美!
javascript
const uploadChunks = () => {
......
const requestList = formateList.map(({ formData, index }) => {
......
})
Promise.all(requestList).then(mergeChunks())
}
这也就是切片为何速度更快, Promise.all
实现并发请求
面试官当时问我的就是万一有个切片失败了怎么办,沉默许久,现在心中已经有答案了,后端
fse
用promise
实现了封装,里面可以捕获错误,如果切片上传失败,我可以记录好这个失败切片的索引,告诉前端让其重传😂脑补:面试官追问:一旦断网就要重传,如何解决?
断点续传,它允许传输文件时,若中断或失败,可以从上一次中断的地方继续传输,而非重新上传。等我学完再出期文章专门讲断电~
合并请求 mergeChunks
如下
php
const mergeChunks = () => {
requestUpload({
url: 'http://localhost:3000/merge',
data: JSON.stringify({
fileName: uploadFile.value.name,
size: 2 * 1024 * 1024
})
})
}
后端不是已经有了切片名和切片大小嘛,为何还要再传一次?
再上一次保险,另外可以防止传输过程中不被篡改,这是为了安全性,当然自己写的时候完全可以不写
好了,前后端联调下,若你是前端,你检查网络是很难看到 merge
请求的,因为这相当于是提交表单格式数据,浏览器向服务端发这个请求,页面会重定向刷新的,这需要后端在 merge
路由中看是否有打印
好了,现在前端已经发 merge
请求了,后端需要把前端 post
请求的内容拿到,这里写个函数 resolvePost
去解析 post
请求的内容
我将请求体给到 resolvePost
,希望它能解析出 post
参数
javascript
const resolvePost = (req) => {
return new Promise((resolve, reject) => {
let chunk = '' // 参数数据包
req.on('data', (data) => {
chunk += data
})
req.on('end', () => {
resolve(JSON.parse(chunk))
})
})
}
这里简单提一嘴,像是这种工具函数用
const
声明了,最好写到前面去,没有声明提升
监听请求体的 data
事件来获取参数数据,请求接收时,触发 end
事件,最后将 data
,这个 data
是前端 stringify
的 json
对象,需要 parse
回来,因为拿到数据后还需要进行合并,合并是 I/O
操作, node 中的 I/O
是异步宏,这里又是同步,需要 promise
来捋成同步
arduino
const { fileName, size } = await resolvePost(req)
前端发合并请求时传过来的就是切片名和切片大小,这里解构出来
记得在
http.createServer
中的回调形参前写async
关键字
好,现在去合并切片 mergeFileChunks
,我告诉这个函数路径 chunk
,以及方才解构出的切片名和切片大小,让其帮我合并
scss
await mergeFileChunks(UPLOAD_DIR, fileName, size)
现在实现 mergeFileChunks
函数,这个函数同样需要 return
一个 promise
,这里直接写个 async
关键字,就相当于函数体中 return
了一个 promise
既然要合并切片那就需要先读取
javascript
const mergeFileChunks = async(filePath, fileName, size) => {
// 读取filePath下所有的切片
const chunks = await fse.readdir(filePath) // 读文件夹的所有文件
console.log(chunks)
}
readdir
是读取文件夹中的所有文件,readfile
是读取文件内容
我们打印下看看 readdir
给我们读成了什么样子
刚好是个数组,这次读的顺序是对的,但是网络情况你也不清楚,因此我们需要将其排序
前面已经提到过,用 split
拿到最后的 index
,然后 sort
css
chunks.sort((a, b) => a.split('-')[1] - b.split('-')[1])
sort
会影响原数组,此时的 chunks
已经是有序的了
现在进行合并
这个 chunks
别看打印出来是个数组,里面每个切片是个字符串,其实这是真实的文件资源,有后缀的
一个形象的比喻,这些切片都是冰块,我们需要将其融化成水流,然后才能汇聚在一起
合并之前需要将切片转换成流类型,我再写个函数 pipeStream
将这些切片转成流类型,我们需要告诉这个函数切片的路径以及切片名
arduino
const arr = chunks.map((chunkPath, index) => {
return pipeStream(
path.resolve(filePath, chunkPath),
fse.createWriteStream( // 合并地点
path.resolve(filePath, fileName),
{
start: index * size, // 0 - 2M, 2M - 4M, ......
end: (index + 1) * size
}
)
)
})
这里的 filePath
是UPLOAD_DIR
,需要将其与 chunkPath
合并给到 pipeStream
,这个路径就是合并切片的文件,就在 chunk
下面。第二个参数指定最终合并到哪里去,需要借助createWriteStream
来创建一个可写流,就相当于创建一个杯子,可以倒水进去进行合并,杯子就是合并的地方,这个 杯子 需要接受两个参数,一个是路径,还有个对象,对象里面写入起始位置和终止位置,这就像是给这个水杯打上刻度, 0-2M 一个刻度, 2-4M 一个刻度......
好了,现在去实现 pipeStream
,第一个参数是合并的路径,所以依旧是 filePath
,也就是UPLOAD_DIR
,第二个参数是可写流
合并写入的路径我最终还是写在 chunk
中
javascript
const pipeStream = (filePath, writeStream) => {
console.log(filePath);
return new Promise((resolve, reject) => {
})
}
先看下是否帮我们生成了这个文件
没问题,只不过我们还没将数据合并进去,这个文件还是空的
现在将所有的切片先读到,然后监听 end
事件,把切片都移除掉,读到的流最终汇入到可写流中,也就是第二个参数
javascript
const pipeStream = (filePath, writeStream) => {
console.log(filePath);
return new Promise((resolve, reject) => {
const readStream = fse.createReadStream(filePath)
readStream.on('end', () => {
fse.unlinkSync(filePath) // 移除切片
resolve()
})
readStream.pipe(writeStream) // 将切片读成流汇入到可写流中
})
}
最后赋值得到的 arr
,都是一个个的 promise
对象,保证每个切片 resolve
即可
await Promise.all(arr)
最后见证奇迹的时刻到了
切片汇总了
在文件资源管理器中打开看看
完美!
总结
实现过程
前端拿到整个文件后利用文件 Blob
原型上的 slice
方法进行切割,将得到的切片数组 chunkList
添加一些信息,比如文件名和下标,得到 uploadChunkList
,但是 uploadChunkList
想要传给后端还需要将其转换成表单数据格式,通过 Promise.all
并发发给后端,传输完毕后发送一个合并请求,合并请求带上文件名和切片大小信息
后端拿到前端传过来的表单格式数据需要 multiparty
依赖来解析这个表单数据,然后把切片解析出来去存入切片,存入到提前创建好的目录中,最后将切片按照下标进行排序再来合并切片,合并切片的实现比较复杂,需要创建一个可以写入流的文件,将每个片段读成流类型,再写入到可写流中
切片带来的优点
- 将大文件切割成片实现并发传输,可以提高传输速度
- 可以实现传输进度功能,提高用户体验
丢下代码
前端index.html
xml
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<title>Document</title>
</head>
<body>
<div id="app">
<input type="file" @change="handleChange">
<button @click="handleUpload">上传</button>
</div>
<script>
const { createApp, ref } = Vue
createApp({
setup() {
const uploadFile = ref(null) // 文件
const uploadChunkList = ref([])
const handleChange = (e) => {
if (!e.target.files[0]) return
uploadFile.value = e.target.files[0]
}
const handleUpload = () => {
if (!uploadFile.value) return
const chunkList = createChunk(uploadFile.value)
// console.log(chunkList);
// 另外切片需要打上标记,保证后端正确合并
uploadChunkList.value = chunkList.map(({ file }, index) => {
return {
file,
size: file.size,
percent: 0,
chunkName: `${uploadFile.value.name}-${index}`,
fileName: uploadFile.value.name,
index
}
})
console.log(uploadChunkList.value);
// 发请求 把切片一个一个地给后端
uploadChunks()
}
// 上传切片
const uploadChunks = () => {
const formateList = uploadChunkList.value.map(({ file, fileName, index, chunkName }) => {
// 对象需要转成二进制数据流进行传输
const formData = new FormData() // 创建表单格式的数据流
// 将切片转换成了表单的数据流
formData.append('file', file)
formData.append('fileName', fileName)
formData.append('chunkName', chunkName)
return { formData, index }
})
console.log(formateList); // 浏览器不给你展示二进制流,但是得清楚确实拿到了
// 发接口请求
const requestList = formateList.map(({ formData, index }) => {
return requestUpload({
url: 'http://localhost:3000/upload',
data: formData,
onUploadProgress: createProgress(uploadChunkList.value[index]) // 进度条函数拿出来写
})
})
// 合并切片请求
Promise.all(requestList).then(mergeChunks())
}
// 合并切片
const mergeChunks = () => {
requestUpload({
url: 'http://localhost:3000/merge',
data: JSON.stringify({
fileName: uploadFile.value.name,
size: 2 * 1024 * 1024
})
})
}
// 上传的进度
const createProgress = (item) => {
return (e) => {
// 为何函数需要return出来,因为axios的onUploadProgress就是个函数体
// 并且这个函数体参数e就是进度
item.percent = parseInt(String(e.loaded / e.total) * 100) // axios提供的
}
}
// 为了实现进度条,封装请求
const requestUpload = ({ url, method = 'post', data, headers = {}, onUploadProgress = (e) => e }) => {
return new Promise((resolve, reject) => {
// axios支持在请求中传入一个回调onUploadProgress,其目的就是为了知道请求的进度
axios[method](url, data, { headers, onUploadProgress })
.then(res => {
resolve(res)
})
.catch(err => {
reject(err)
})
})
}
const createChunk = (file, size = 2 * 1024 * 1024) => {
const chunkList = []
let cur = 0 // 当前切片
while (cur < file.size) {
chunkList.push({ file: file.slice(cur, cur + size) })
cur += size
}
return chunkList
}
return {
handleChange,
handleUpload,
createChunk
}
}
}).mount('#app')
</script>
</body>
</html>
后端app.js
javascript
const http = require('http')
const multiparty = require('multiparty')
const path = require('path')
const fse = require('fs-extra')
const UPLOAD_DIR = path.resolve(__dirname, '.', 'chunks') // 准备好地址用来存切片
const resolvePost = (req) => {
return new Promise((resolve, reject) => {
let chunk = '' // 参数数据包
req.on('data', (data) => {
chunk += data
})
req.on('end', () => {
resolve(JSON.parse(chunk))
})
})
}
const server = http.createServer(async (req, res) => {
// 解决跨域
res.setHeader('Access-Control-Allow-Origin', '*') // 允许所有的请求源来跨域
res.setHeader('Access-Control-Allow-Headers', '*') // 允许所有的请求头来跨域
// 请求预检
if (req.method === 'OPTIONS') {
res.status = 200
res.end()
return
}
if (req.url === '/upload') {
const form = new multiparty.Form()
form.parse(req, (err, fields, files) => {
if (err) {
console.log(err);
return
}
const file = files.file[0] // 切片的内容
const fileName = fields.fileName[0]
const chunkName = fields.chunkName[0]
// 拿到切片先存入再合并,存入的目的就是防止顺序错乱
// const chunkDir = path.resolve(UPLOAD_DIR, `${fileName}-chunks`) // 文件名不同,文件目录就不同
if (!fse.existsSync(UPLOAD_DIR)) { // 判断目录是否存在
fse.mkdirsSync(UPLOAD_DIR) // 创建这个文件
}
// 将切片写入到文件夹中
fse.moveSync(file.path, `${UPLOAD_DIR}/${chunkName}`)
})
} else if (req.url === '/merge') { // 让前端请求这个地址表明传输完成去合并切片
console.log('merge');
const { fileName, size } = await resolvePost(req) // 解析前端传过来的参数
console.log(fileName, size);
await mergeFileChunks(UPLOAD_DIR, fileName, size)
res.end('合并成功')
}
})
const mergeFileChunks = async(filePath, fileName, size) => { // 写个async就相当于new promise
// 读取filePath下所有的切片
const chunks = await fse.readdir(filePath) // 读文件夹的所有文件
console.log(chunks);
// 防止切片顺序错乱
chunks.sort((a, b) => a.split('-')[1] - b.split('-')[1]) // sort会影响原数组,无需赋值
// 合并片段:转换成流类型
const arr = chunks.map((chunkPath, index) => {
return pipeStream(
path.resolve(filePath, chunkPath),
fse.createWriteStream( // 合并
path.resolve(filePath, fileName),
{
start: index * size, // 0 - 3M, 3M - 6M, ......
end: (index + 1) * size
}
)
)
})
await Promise.all(arr)
}
const pipeStream = (filePath, writeStream) => {
console.log(filePath);
return new Promise((resolve, reject) => {
const readStream = fse.createReadStream(filePath)
readStream.on('end', () => {
fse.unlinkSync(filePath) // 移除切片
resolve()
})
readStream.pipe(writeStream) // 将切片读成流汇入到可写流中
})
}
server.listen(3000, () => {
console.log('listening on port 3000');
})
最后
大三实习找工作中,求内推💕~
如果你对面试感兴趣,可以加我的个人微信:
Dolphin_Fung
,我和我的小伙伴们有个面试群,可以进群讨论你面试过程中遇到的问题,我们一起解决
另外有不懂之处欢迎在评论区留言,如果觉得文章对你学习有所帮助,还请"点赞+评论+收藏"一键三连,感谢支持!