大文件分片上传
前言:
在网上看到许多分片上传的案例,但是很少见前后端通用的代码,于是在吸收了一些思路之后,写下该文章,手把手教你如何写分片上传。
本文章仅实现基础的分片上传,后续断点上传,秒传,防止丢包等操作,如果该文章点赞收藏量高的话,我会在将来写相关的技术文章,如果喜欢的话就点赞收藏吧!
源码和相关参考资料在末尾哦!
哪里有写的不对的,或者不明白的,欢迎讨论或者指出!
技术使用:
使用前后端不分离技术,后端使用node + express搭建
node版本:18.17.0
express版本:4.16.1
hash库:spark-md5
网络通信:fetch
设计思路:
写代码前,要先明确我们的思路,这样有利于提前解决许多将来可能发生的问题,提高我们的效率。
-
首先后端要定义一个接口,用于返回页面供我们调试,通过express框架,将index.html作为根路径下get请求的返回结果即可
-
当前端选中文件上传时,需要做好以下几件事
- 将文件进行分片处理
- 文件进行hash
- 通过指定接口上传分片
-
后端需要做好几件事情
- 收到分片,要进行hash校验,检验文件是否错误
- 没有错误,将分片写入文件对应的位置
代码实现:
前端页面准备:
首先我们先把前端页面和对应的接口写好
xml
// index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>文件分片上传</title>
</head>
<body>
<h1>测试大文件分片上传</h1>
<input type="file" id="uploadFile" />
<button id="submit">开始上传</button>
</body>
<script src="/static/js/index.js" type="module"></script>
</html>
页面很简单,就一个input框,还有一个开始上传的按钮。其中index.js包含了前端的代码逻辑。
后端首页接口实现:
javascript
// router/index.js
const path = require("path");
/* GET home page. */
router.get("/", function (req, res, next) {
res.sendFile(path.resolve(__dirname, "../index.html"));
});
module.exports = router;
绑定相关事件:
javascript
// 获取元素
const btn = document.getElementById("submit");
const uploadFile = document.getElementById("uploadFile");
// 注册事件
btn.addEventListener("click", handler);
uploadFile.addEventListener("change", fileChange);
// 触发上传事件回调
async function handler() {}
// 选中文件时的回调
async function fileChange(e) {}
实现文件分片:
在浏览器中,有一个File类,用于描述一个文件对象,其中有一个方法,可以帮助我们获取到元素的切片
arduino
function createChunk(file, index, size) {
// 定义参数
const start = index * size;
const end = (index + 1) * size;
const blob = file.slice(start, end);
return blob;
}
我们在前端代码中定义一个函数createChunk,用于制造文件分块。函数接收三个参数,file:文件的标识符,一个File类的实例对象。index:当前chunk的序号,表示切割第几块分片,size:表示每块分片的大小。
slice是一个左开右闭的函数,所以我们像上面一样定义即可得到对应的文件分块。
通过如上操作,我们就可以获取到对应的文件二进制流了。
这里有一个小知识点,通过这样的方式得到的文件分片,并不是文件的本体,可以理解为一个标识符,指向文件的本体或者部分内容
实现文件hash:
arduino
// 获取md5
import md5 from "spark-md5"
// 对文件分片并且进行包装后返回
function createChunk(file, index, size) {
// 定义参数
const start = index * size;
const end = (index + 1) * size;
const blob = file.slice(start, end);
// 函数hash
const fileReader = new FileReader();
const spark = new md5.ArrayBuffer();
fileReader.readAsArrayBuffer(blob);
// 获取Promise
const { promise, resolve } = Promise.withResolvers();
// 文件读取完成后进行hash
fileReader.onload = function (e) {
spark.append(e.target.result);
resolve({
blob,
index,
start,
end,
hash: spark.end(),
});
};
return promise;
}
文件hash,我是使用的spark-md5这个库,通过读取文件后的二进制流作为参数传递给对应的对象即可得到相应的hash值。
使用FileReader实例对象读取文件内容,在其回调中我们可以通过promise返回一个相关的数据,其中包含了文件的二进制数据,对应的分块下标,字节的起始位置和终止位置,以及相应的hash值。
开始初步上传:
前端代码:
javascript
// 获取元素
const btn = document.getElementById("submit");
const uploadFile = document.getElementById("uploadFile");
// 定义分片大小
const CHUNK_SIZE = 1024 * 1024 * 5; // 5MB
// 定义当前上传文件的信息
let uploadData = {
file: null,
ext: "",
key: "",
preFlag:false,
chunkArr: []
};
// 注册事件
btn.addEventListener("click", handler);
uploadFile.addEventListener("change", fileChange);
我们初始化定义了分片的大小的全局变量,以及我们作为参数传输的信息。
javascript
// 点击上传事件回调
async function handler() {
// 如果未选中文件,则中断函数
if (!uploadData.preFlag) {
console.log("未选择文件或者文件尚未做好上传准备");
return;
}
// 2. 获取文件分片信息
const chunkArr = uploadData.chunkArr;
let current = 0; // 当前的请求块
const request = () => {
while (current < chunkArr.length) {
// 获取对应分块
const chunk = chunkArr[current++];
// 5. 在postFile方法中,将数据封装到formData中再进行传输
postFile(chunk).then(() => {
console.log(`第${chunk.index}块分片上传完毕`);
});
}
};
// 4. 开始上传
return request();
}
点击上传按钮的回调,在其中将每一个个分块作为请求体发送出去
javascript
// 选中文件时的回调
async function fileChange(e) {
// 重置uploadData
uploadData = {
file: null, // 选中的文件对象
ext: "", // 文件的后缀名
key: "", // 文件的key
preFlag: false, // 文件是否允许上传
chunkArr: [], // 文件分块后的数组
};
// 获取一些基本信息
const file = (uploadData.file = e.target.files[0]); // 选中的文件
const ext = (uploadData.ext = file.name.match(/.(.+)$/)[1]); // 获取文件后缀
const key = (uploadData.key =
"id-" + Math.random().toString(36).substring(2, 11)); // 生成唯一id
// 对文件进行分片
const preTime = new Date();
const result = await cutFile(file);
const nowTime = new Date();
console.log(`耗时${nowTime - preTime}毫秒`);
// 赋值给uploadData
uploadData.chunkArr = result.chunkArr;
// 文件可以上传
uploadData.preFlag = true;
return Promise.resolve(true)
}
在我们选中文件时,要初始化uploadData对象,在取到对应的数据后,调用cutFile文件得到分块后的数组
javascript
// 文件分片函数,返回一个分片后的数组
function cutFile(file) {
// 需要产生多少个分块
const chunkCount = Math.ceil(file.size / CHUNK_SIZE);
// 结果数组
const result = [];
// 已经完成几块分片
let nums = 0;
const { promise, resolve } = Promise.withResolvers();
// 进入循环,给每个线程分配任务,同时判断当分块数量小于线程数量的时候,就终止
for (let i = 0; i < chunkCount; i++) {
createChunk(file, i, CHUNK_SIZE).then((chunk)=>{
result[i] = chunk;
})
}
return promise;
}
通过cutFile判断需要创建多少个chunk,然后循环调用createChunk,这里有一个知识点,为了保证分块的顺序,我们不可以直接调用result.push(chunk)
而是通过result[i] = chunk
的方式来进行写入。这里利用到了Promise的一个新方法,可以了解一下,用于获取到promise和resolve以及reject参数。相关文档:Promise.withResolvers()
javascript
// 定义拦截器,对fetch的结果进行解析
async function interceptor(res) {
const reader = await res.body.getReader();
// 在这里整合数据
let data = { done: false, value: [] };
let value = [];
while (data.done !== true) {
data = await reader.read();
if (data.done) {
break;
}
value.push(...data.value);
}
// 将二进制流转成字符串
let obj = String.fromCharCode(...value);
obj = JSON.parse(obj);
// 如果后端校验失败
if (obj.data !== "ok") {
return Promise.reject("fail");
}
return Promise.resolve(obj);
}
这里定义了一个拦截器,对fetch的请求结果进行解析,因为fetch响应的body是一个readableStream对象,需要通过getReader()方法得到一个迭代器,然后获取二进制流数据,再转换成我们需要的数据。
go
// 文件上传接口
function postFile(chunk) {
const formData = new FormData();
formData.append("file", chunk.blob);
formData.append("index", chunk.index);
formData.append("start", chunk.start);
formData.append("end", chunk.end);
formData.append("hash", chunk.hash);
formData.append("key", uploadData.key);
// 定义循环体
const request = () => {
return fetch("/uploadFile", {
method: "POST",
body: formData,
})
.then((res) => {
return interceptor(res);
})
.catch((error) => {
return Promise.reject(error);
});
};
return request();
}
该函数接收chunk,将数据打包发送出去。
要注意的是,因为传输文件,我们需要通过FormData对象来传递数据和一些键值对
后端代码:
javascript
var express = require("express");
var router = express.Router();
// 应对content-type为multipart/form-data的数据的中间件
const multer = require("multer");
const upload = multer();
const path = require("path");
// 获取md5库
const md5 = require("spark-md5.js");
// uploadFile接口
router.post("/", upload.single("file"), async function (req, res) {
const { hash, key, start, index } = req.body;
const file = req.file;
// 加密文件获取hash进行比较判断是否有误
const flag = await analyzeChunk(file, hash);
// 如果校验通过,则写入
if (flag) {
const result = await appendFile(key, file.buffer, +start);
if (result === "ok") {
// 写入之后,将对应的标识符改为1
const fileMap = keyMap.get(key);
fileMap.hasResolve[index] = 1;
res.send(
JSON.stringify({
code: 200,
data: "ok",
})
);
// 如果每个分块都已写入,那么就关闭fd
if (fileMap.hasResolve.every((item) => item === 1)) {
fileMap.fd.close();
// 将对应key的map删除
keyMap.delete(key);
}
} else {
res.send(
JSON.stringify({
code: 200,
data: "fail",
})
);
}
} else {
res.send(
JSON.stringify({
code: 200,
data: "fail",
})
);
}
});
// 追加文件
async function appendFile(key, buffer, start) {
// 这里是根据请求中的key值保存了文件的描述符,FileHandler对象,后面会将
let fd = keyMap.get(key).fd;
await fd.writev([buffer], start);
return "ok";
}
// 解析分块
function analyzeChunk(chunk, chunkHash) {
// 获取二进制对象
const blob = chunk.buffer;
// 函数hash
const spark = new md5.ArrayBuffer();
// 获取Promise
return new Promise((resolve, reject) => {
spark.append(blob);
if (spark.end() === chunkHash) {
resolve(true);
} else {
reject(false);
}
});
}
module.exports = router;
为了解析multipart/form-data的类型请求,我使用了multer库,相关文档:multer - npm (npmjs.com)
在这个接口中,我们做了三件事:
- 解析请求的数据。
- 对二进制文件进行hash加密,和传来的hash进行对比。
- 对比成功则取到对应key的fileHandler对象,将文件写入。
要注意的是,为什么要用FileHandler,而不能直接使用fs.writeFile等方法,因为前者可以在文件的指定位置写入,中间可以留白。
hasResolve是一个数组,其中保存的是0|1,用于表示对应下标分块是否已经写入,当全部写入完成时,记得关闭FileHandler,貌似有点占用资源
到此为止,我们已经实现了基本的分片上传了。
但是,还有一个问题没有解决:那就是当使用fs.open的时候,我们原本的文件不存在怎么办?
有两个解决办法:
- 在uploadFile接口中,判断当文件不存在时,则创建文件。
- 使用一个preUpload接口,在上传前告知服务器做好准备。
这里我选择的是第二个方法,原因在后面的疑难点部分会提到。
通过preUpload进行预上传处理:
前端代码:
javascript
// 选中文件时的回调
async function fileChange(e) {
// 重置uploadData
uploadData = {
file: null, // 选中的文件对象
ext: "", // 文件的后缀名
key: "", // 文件的key
preFlag: false, // 文件是否允许上传
chunkArr: [], // 文件分块后的数组
};
// 获取一些基本信息
const file = (uploadData.file = e.target.files[0]); // 选中的文件
const ext = (uploadData.ext = file.name.match(/.(.+)$/)[1]); // 获取文件后缀
const key = (uploadData.key =
"id-" + Math.random().toString(36).substring(2, 11)); // 生成唯一id
// 对文件进行分片
const preTime = new Date();
const result = await cutFile(file);
const nowTime = new Date();
console.log(`耗时${nowTime - preTime}毫秒`);
// 赋值给uploadData
uploadData.chunkArr = result.chunkArr;
// 告知服务器做好准备,提前创建好空文件
return fetch("/preUpload", {
method: "POST",
headers: {
// 默认的Content-type的内容是text/plain,这里修改一下
"Content-type": "application/json",
},
body: JSON.stringify({
key,
ext,
length: result.length,
}),
})
.then(interceptor)
.then((res) => {
console.log("服务器响应:", res.data);
uploadData.preFlag = true;
});
}
在这里,我们对fileChange方法进行了修改,我们在最后通过preUpload接口告知服务器进行预上传处理,并将结果返回回来。
后端代码:
ini
// preUpload.js
var express = require("express");
var router = express.Router();
const fs = require("fs/promises");
const path = require("path");
// 在global上挂载一个map,用于保存各个文件的信息
global.keyMap = new Map();
router.post("/", async function (req, res) {
const { key, ext, length } = req.body;
const fileName = `${key}.${ext}`;
const filePath = path.resolve(__dirname, `../public/file/${fileName}`);
await fs.writeFile(filePath, "");
const fd = await fs.open(filePath, "r+");
const value = {
fd,
hasResolve: new Array(length).fill(0),
};
keyMap.set(key, value);
res.send(
JSON.stringify({
code: 200,
data: "ok",
})
);
});
在这里,我们创建了一个接口,以请求的key和ext作为新文件的名字,创建了一个空文件,并且将fd作为value,key作为key,保存在了keyMap中,后续取出来就可以了。
优化方向:
到目前为止,我们已经完成了最基本的文件分片上传的功能,但是目前的功能还是有可以优化的地方的,我们接下来就要对其进行优化
优化点:
- 我们handler中,是直接将chunkArr循环打包发送,但这样会导致并发请求数量过大,给服务器造成巨大负担,甚至可能导致传送失败,所以我们需要控制请求的并发数量,不能一股脑的请求出去。
- 在createChunk中,我们通过md库进行文件hash,这是一个密集型运算任务,当文件过大时,会导致花费太多的时间进行hash计算,利用多线程webWorker,降低运行时间。
控制并发数量:
前端修改:
javascript
// 定义最大连接数量
const MAX_CONCURRENT = 5;
// 定义当前请求数量
let CUR_CONCURRENT = 0;
// 点击事件回调
async function handler() {
// 如果未选中文件,则中断函数
if (!uploadData.preFlag) {
console.log("未选择文件或者文件尚未做好上传准备");
return;
}
btn.textContent = "暂停";
// 2. 获取文件分片信息
const chunkArr = uploadData.chunkArr;
let current = 0; // 当前的请求块
const request = () => {
while (CUR_CONCURRENT < MAX_CONCURRENT && current < chunkArr.length) { // 新增代码
// 获取对应分块
const chunk = chunkArr[current++];
// 5. 在postFile方法中,将数据封装到formData中再进行传输
postFile(chunk).then(() => {
console.log(`第${chunk.index}块分片上传完毕`);
});
}
};
// 4. 开始上传
return request();
}
// 文件上传接口
function postFile(chunk) {
const formData = new FormData();
formData.append("file", chunk.blob);
formData.append("index", chunk.index);
formData.append("start", chunk.start);
formData.append("end", chunk.end);
formData.append("hash", chunk.hash);
formData.append("key", uploadData.key);
// 定义循环体
const request = () => {
// 发送请求前将请求数量自增
CUR_CONCURRENT++; // 新增代码
return fetch("/uploadFile", {
method: "POST",
body: formData,
})
.then((res) => {
// 请求结果回来后将请求数量减少
CUR_CONCURRENT--; // 新增代码
return interceptor(res);
})
.catch((error) => {
return Promise.reject(error);
});
};
return request();
}
定义两个变量,分别设置为最大并发数量以及正在请求的数量,然后在handler的request方法中,对请求数量进行判断即可。
同时我们要注意修改postFile方法,在发起请求和收到响应时,对CUR_CONCURRENT进行修改。
但是,目前的写法依然有问题,那就是在handler方法中,第一次执行request()之后,就不会再执行了。例如:有49个分块,MAX_CONCURRENT = 5,那么只会执行五次请求,因为我们没有在请求响应时重新发起新的请求,上传新的chunk,所以要对request做一些修改:
scss
const request = () => {
while (CUR_CONCURRENT < MAX_CONCURRENT && current < chunkArr.length) {
// 获取对应分块
const chunk = chunkArr[current++];
// 5. 在postFile方法中,将数据封装到formData中再进行传输
postFile(chunk)
// 新增内容
.then(() => {
console.log(`第${chunk.index}块分片上传完毕`);
// 如果数量依旧大于零
if (current < chunkArr.length) {
request();
}
});
}
};
到此为止,我们就已经完成了第一个优化点了。
利用webWorker进行hash计算:
我们新建一个worker.js,利用其进行hash计算:
ini
// 获取md5
// 注意:webWorker中需要通过importScripts来引入脚本
importScripts("/static/js/md5.js");
const md5 = self.SparkMD5;
// 对文件分片并且进行包装后返回
function createChunk(file, index, size) {
// 定义参数
const start = index * size;
const end = (index + 1) * size;
const blob = file.slice(start, end);
// 函数hash
const fileReader = new FileReader();
const spark = new md5.ArrayBuffer();
fileReader.readAsArrayBuffer(blob);
// 获取Promise
const { promise, resolve } = Promise.withResolvers();
// 文件读取完成后进行hash
fileReader.onload = function (e) {
spark.append(e.target.result);
resolve({
blob,
index,
start,
end,
hash: spark.end(),
});
};
return promise;
}
self.addEventListener("message", (message) => {
const { file, size, startIndex, endIndex } = message.data;
const result = [];
let nums = 0;
for (let i = startIndex; i < endIndex; i++) {
createChunk(file, i, size).then((chunk) => {
result[i - startIndex] = chunk;
nums++;
if (nums === endIndex - startIndex) {
self.postMessage(result);
}
});
}
});
我们将createChunk放到了worker.js中,通过监听message事件以及调用postMessage方法,可以对数据进行传输。
这里有一点要注意的是,postMessage的参数必须可以转成字符串传输的变量,例如函数不能通过JSON.stringify转换一样,如果带有函数等属性,是无法作为参数传输的。
接下来是重头戏:
arduino
// 文件分片函数,返回一个分片后的数组
function cutFile(file) {
// 需要产生多少个分块
const chunkCount = Math.ceil(file.size / CHUNK_SIZE);
// 结果数组
const result = [];
let nums = 0;
const { promise, resolve } = Promise.withResolvers();
// 平均每个进程处理几个分块
const threadChunkCount = Math.ceil(chunkCount / THREAD_COUNT);
// 进入循环,给每个线程分配任务,同时判断当分块数量小于线程数量的时候,就终止
for (
let i = 0;
i < THREAD_COUNT && i * threadChunkCount < chunkCount; // 这里需要注意,防止开启不必要的线程(当线程平均处理块数量小于1时)
i++
) {
// start和end表示处理下标为[start, end)的块
const start = i * threadChunkCount;
const end = Math.min(chunkCount, (i + 1) * threadChunkCount);
const worker = new Worker("/static/js/worker.js");
// 传输数据
worker.postMessage({
file: uploadData.file,
size: CHUNK_SIZE,
startIndex: start,
endIndex: end,
});
worker.addEventListener("message", (event) => {
const { data } = event;
for (let i = start; i < end; i++) {
result[i] = data[i - start];
nums++;
}
// 记得关闭线程
worker.terminate();
// 判断是否分片完毕
if (nums === chunkCount) {
resolve({
chunkArr: result,
length: result.length,
});
}
});
}
return promise;
}
好了,到目前为止,我们就已经实现了多线程的优化,并且已经完成了分片上传的功能。
效果展示:
选中文件:
这个文件有221MB,在使用webworker之前需要花费1700毫秒左右,在使用了webWorker之后,只需要300毫秒了,快了五倍不止,说明还是很有效的。
而且我们会发现,响应并不是按顺序返回的,说明并行请求成功了
本次功能实现遇到的疑难点:
Q:当后端收到文件分块后,如何写入?
A:通过fs模块中的open 方法,获得文件相关描述符fd,再通过fd上的writev方法,可以在指定位置写入数据,这样就实现了在文件对应位置放入对应的块的操作。
Q:网络请求是通过fetch进行请求的,和XHRHttpRequest有什么区别吗?A:fetch中的body是一个readableStream 对象,需要调用其身上的getReader 方法获取到查看对象reader ,再通过reader 上的read方法 ,可以得到其中的数据(返回一个Promise),其中有个value 属性,包含返回数据的uint8Array 对象,可以通过String.fromCharCode方法得到字符串
Q:既然返回的是一个uint8Array对象,那么如果遇到中文的情况要怎么处理呢?
A:可以通过对应的库解决,但是原生js并没有内置方法可以直接将字节流转成utf-8字符串,所以此次我就没有对其进行专门的解决,而是通过后端返回英文解决。
Q:在传输过程中,node服务器生成的文件信息错误,是什么原因,怎么解决的?A:
- 第一次遇到这个情况,是因为分片上传时,限制了最大同时请求的数量,但却没有在请求返回时将剩余的块发送出去。只需要在返回时检查是否全部传输完毕,将为传送的继续传送即可
- 解决了第一个情况,发现还有问题,在查找了很久之后,发现是fs.open 方法传的标识符有问题,在菜鸟教程上提到w+ 表示当文件不存在时进行创建。本以为是r+ (以读写模式打开文件,文件不存在则创建)的完美替代品,但未曾想到菜鸟教程有错,在查看了nodejs官方文档后,得知w+ 表示的是文件不存在则创建文件,文件存在则清空内容重新写入 ,导致中间有许多空内容,造成文件损坏。解决思路:还是使用r+ 标识符就好
- 在第三种情况,在解决了第二个问题之后,后面发现还是有问题,经过测试之后,发现每次书写文件,都是在末尾添加,也就是writev 方法的第二个参数position 失效了,但测试了很久之后终于发现,原来formData 传递数据的value 值是string,而writev方法需要的是一个number,将类型转换之后即成功了。
Q:为什么在preUpload接口中,node中无法获取到req.body的信息?A:因为express.json中间件是根据Content-type 字段进行判断的,fetch请求的默认字段是text/plain ,将其修改为application/json即可获取到数据
Q:在使用webWorker的过程中,有遇到什么难题吗?、A:有遇到,首先第一个就是,我们无法在webWorker中像常规方式一样引入脚本,例如esmodule的import关键字或者是node中的require,但好在官方提供了importScript方法,帮助我们在webWorker中引入其他脚本。第二个问题就是,在postMessage方法中,只可以传递简单数据和简单对象,不可以传递函数等对象,这里的内容可以进行相关查询。
Q:那就是当使用fs.open的时候,我们原本的文件不存在怎么办?会发生报错的情况。A:新建一个preUpload接口,在上传服务器前告知服务器做好准备,提前新建好文件即可。
Q:为什么不使用第一种方案呢?
A:有几种原因:
- 每个分块都要进行文件描述符的空判断,多少会有性能上的消耗。
- 这是最重要的,创建文件我是通过writeFile创建的,这是一个异步函数,可能会导致对同一个文件的多次写入,例如我同时到达六个请求,可能会导致writeFile被执行六次,而且该方法会替换为文件,导致前面的请求写入的内容可能被后面的writeFile替换为空了,就会导致文件信息出错。当然可以通过标识符来判定是否正在创建,如果正在执行过程中,就等待执行完成即可,但是这样会导致代码的难度提高,并且不利于理解。于是我选择了第二种方案。
未来可优化方向:
本来是想把这些方案一起做了的,但是因为毕设和春招的原因,最近多了一些笔试和面试,会比较忙,我拖延症可能会导致这篇文章迟迟不能上线,于是我就只好今天加班加点先写出来了,这些是我未来会进行优化的方向,大家可以想想怎么做,给一些建议和思路,也可以自己动手实现哦!
- 当传输失败时(例如网络太差,或者后端hash校验失败)前端可以无感进行重传。
- 实现暂停和继续上传的功能。
- 实现秒传的功能(即如果上传的文件已经在服务器存在了,可以除去上传的过程)。
如果点赞收藏热度高的话,找个空闲时间我一定将后续更新出来!!!可以多多关注!
文章参考:
一个Demo搞定前后端大文件分片上传、断点续传、秒传 - 掘金 (juejin.cn)