前言
这段时间面试官都挺忙的,频频出现在博客文章标题,虽然我不是特别想蹭热度,但是实在想不到好的标题了-。-,蹭蹭就蹭蹭 ??
事实上我在面试的时候确实被问到了这个问题,而且是一道在线 coding 的编程题,当时虽然思路正确,可惜最终也并不算完全答对
结束后花了一段时间整理了下思路,那么究竟该如何实现一个大文件上传,以及在上传中如何实现断点续传的功能呢?
本文将从零搭建前端和服务端,实现一个大文件上传和断点续传
前端:Vue@2
+ Element-ui
服务端:Nodejs@14
+ multiparty
大文件上传
整体思路
前端
前端大文件上传网上的大部分文章已经给出了解决方案,核心是利用 Blob.prototype.slice
方法,和数组的 slice 方法相似,文件的 slice 方法可以返回原文件的某个切片
预先定义好单个切片大小,将文件切分为一个个切片,然后借助 http 的可并发性,同时上传多个切片。这样从原本传一个大文件,变成了并发
传多个小的文件切片,可以大大减少上传时间
另外由于是并发,传输到服务端的顺序可能会发生变化,因此我们还需要给每个切片记录顺序
服务端
服务端负责接受前端传输的切片,并在接收到所有切片后合并
所有切片
这里又引伸出两个问题
- 何时合并切片,即切片什么时候传输完成
- 如何合并切片
第一个问题需要前端配合,前端在每个切片中都携带切片最大数量的信息,当服务端接受到这个数量的切片时自动合并。或者也可以额外发一个请求,主动通知服务端进行切片的合并
第二个问题,具体如何合并切片呢?这里可以使用 Nodejs 的 读写流(readStream/writeStream),将所有切片的流传输到最终文件的流里
talk is cheap,show me the code
,接着我们用代码实现上面的思路
前端部分
前端使用 Vue 作为开发框架,对界面没有太大要求,原生也可以,考虑到美观使用 Element-ui 作为 UI 框架
上传控件
首先创建选择文件的控件并监听 change 事件,另外就是上传按钮
<template>
? <div>
? ?<input type="file" @change="handleFileChange" />
? ?<el-button @click="handleUpload">upload</el-button>
?</div>
</template>
?
<script>
export default {
?data: () => ({
? ?container: {
? ? ?file: null
? }
}),
?methods: {
? ? handleFileChange(e) {
? ? ?const [file] = e.target.files;
? ? ?if (!file) return;
? ? ?Object.assign(this.$data, this.$options.data());
? ? ?this.container.file = file;
? },
? ?async handleUpload() {}
}
};
</script>
请求逻辑
考虑到通用性,这里没有用第三方的请求库,而是用原生 XMLHttpRequest 做一层简单的封装来发请求
request({
? ? ?url,
? ? ?method = "post",
? ? ?data,
? ? ?headers = {},
? ? ?requestList
? }) {
? ? ?return new Promise(resolve => {
? ? ? ?const xhr = new XMLHttpRequest();
? ? ? ?xhr.open(method, url);
? ? ? ?Object.keys(headers).forEach(key =>
? ? ? ? ?xhr.setRequestHeader(key, headers[key])
? ? ? );
? ? ? ?xhr.send(data);
? ? ? ?xhr.onload = e => {
? ? ? ? ?resolve({
? ? ? ? ? ?data: e.target.response
? ? ? ? });
? ? ? };
? ? });
? }
上传切片
接着实现比较重要的上传功能,上传需要做两件事
-
对文件进行切片
-
将切片传输给服务端
当点击上传按钮时,调用 createFileChunk
将文件切片,切片数量通过文件大小控制,这里设置 10MB,也就是说一个 100 MB 的文件会被分成 10 个 10MB 的切片
createFileChunk 内使用 while 循环和 slice 方法将切片放入 fileChunkList
数组中返回
在生成文件切片时,需要给每个切片一个标识作为 hash,这里暂时使用文件名 + 下标
,这样后端可以知道当前切片是第几个切片,用于之后的合并切片
随后调用 uploadChunks
上传所有的文件切片,将文件切片,切片 hash,以及文件名放入 formData 中,再调用上一步的 request
函数返回一个 proimise,最后调用 Promise.all 并发上传所有的切片
发送合并请求
使用整体思路中提到的第二种合并切片的方式,即前端主动通知服务端进行合并
前端发送额外的合并请求,服务端接受到请求时合并切片
<template>
<div>
? <input type="file" @change="handleFileChange" />
? <el-button @click="handleUpload">upload</el-button>
</div>
</template>
?
<script>
export default {
data: () => ({
? container: {
? ? file: null
? },
? data: []
}),
methods: {
? request() {},
? handleFileChange() {},
? createFileChunk() {},
? async uploadChunks() {
? ? const requestList = this.data
? ? ? .map(({ chunk,hash }) => {
? ? ? ? const formData = new FormData();
? ? ? ? formData.append("chunk", chunk);
? ? ? ? formData.append("hash", hash);
? ? ? ? formData.append("filename", this.container.file.name);
? ? ? ? return { formData };
? ? ? })
? ? ? .map(({ formData }) =>
? ? ? ? this.request({
? ? ? ? ? url: "http://localhost:3000",
? ? ? ? ? data: formData
? ? ? ? })
? ? ? );
? ? await Promise.all(requestList);
+ ? ? // 合并切片
+ ? ? await this.mergeRequest();
? },
+ ? async mergeRequest() {
+ ? ? await this.request({
+ ? ? ? url: "http://localhost:3000/merge",
+ ? ? ? headers: {
+ ? ? ? ? "content-type": "application/json"
+ ? ? ? },
+ ? ? ? data: JSON.stringify({
+ ? ? ? ? filename: this.container.file.name
+ ? ? ? })
+ ? ? });
+ ? }, ? ?
? async handleUpload() {}
}
};
</script>
服务端部分
使用 http 模块搭建一个简单服务端
const http = require("http");
const server = http.createServer();
?
server.on("request", 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;
}
});
?
server.listen(3000, () => console.log("listening port 3000"));
接受切片
使用multiparty处理前端传来的 formData
在 multiparty.parse 的回调中,files 参数保存了 formData 中文件,fields 参数保存了 formData 中非文件的字段
const http = require("http");
const path = require("path");
+ const fse = require("fs-extra");
+ const multiparty = require("multiparty");
?
const server = http.createServer();
+ // 大文件存储目录
+ const UPLOAD_DIR = path.resolve(__dirname, "..", "target");
?
server.on("request", async (req, res) =&g