本文会从基础实现开始,根据基础实现可以优化的地方逐步优化,如果很熟悉基础的文件上传,只是好奇标题是如何实现的,可以直接看目录的各个优化开头的分段
基础版切片上传与合并
基础概念
大文件切片上传与合并的基础版本代码其实并不复杂,关键在于File文件对象是继承Blob对象的,都是二进制数据 的一种存储方式,可以把File对象想象成是一个数据流,可以很方便地通过slice方法来切分数据流将其分段,形成切片chunk
对于File对象的上传,一般都使用构建FormData对象,逐一append需要的内容
服务端则用koa来快速地在本地开启一个文件服务器,在服务端安装以下依赖即可,处理路由,传入的body,以及跨域cors
javascript
npm install koa koa-router koa-body @koa/cors
前端代码实现
基础版只需要利用File的slice方法,循环地创建切片并保存在数组中,再通过map方法遍历切片数组,将其转换为axios的请求Promise,就可在Promise.all里面并行地上传多个切片
上传完成后,即可再发送一个合并请求要求服务端进行切片的合并。当然,也可以通过告知切片总数,让服务端自动合并
javascript
function createFileChunk(file, size = SIZE) {
const fileChunkList = [];
let cur = 0;
let index = 0;
while (cur < file.size) {
const chunk = file.slice(cur, cur + size);
const chunkName = `${file.name}-${index}`;
fileChunkList.push({ chunk, chunkName }); //创建切片对象,包含切片文件和chunkName
cur += size;
index++;
}
return fileChunkList;
}
async function uploadChunks(file) {
const fileChunkList = createFileChunk(file);
let requestList = fileChunkList.map(({ chunk, chunkName }) => {
return () => {
//一定要包裹在函数里,否则会直接执行
const formData = new FormData();
formData.append("chunk", chunk);
formData.append("chunkName", chunkName);
formData.append("fileName", file.name);
return axios.post("http://localhost:3000/upload", formData);
};
});
try {
// await promisePoolRequest(requestList, 4);后面实现并发控制
await Promise.all(requestList.map((request,index)=>{
return request();
}));
await mergeFile(file.name, fileChunkList.length);
} catch (error) {
console.error("上传错误", error);
}
}
async function mergeFile(fileName, total) {
try {
await axios.post("http://localhost:3000/merge", {
fileName,
total,
size: SIZE,
});
console.log("File merged successfully");
} catch (error) {
console.error("Error merging file:", error);
}
}
服务端koa实现
启动命令,需要安装nodemon和ts-node,就可以监听ts文件变化来自动重启服务器了
typescript
"scripts": {
"start": "nodemon --exec ts-node app.ts"
},
koa确实轻便许多,把中间件都塞进去就可以了,
typescript
// app.ts
const Koa = require("koa");
const KoaRouter = require("koa-router");
const { koaBody } = require("koa-body");
const uploadFunctions = require("./uploadHandler");
const mergeFunctions = require("./mergeHandler");
const verifyFunctions = require("./verifyHandler");
const cors = require("@koa/cors");
const app = new Koa();
const router = new KoaRouter();
// app.use(cors({ credentials: true, origin: "http://localhost:5173" }));
//如果在axios配置了withCredentials: true,则需要特指而不能通配
app.use(cors())
app.use(koaBody({ multipart: true }));
app.use(async (ctx: any, next: any) => {
// console.log("get request", ctx.request.body);
await next(); //中间件一定要await,不然直接返回404
});
router.post("/upload", uploadFunctions.handleUpload);
router.post("/merge", mergeFunctions.mergeChunks);
router.post("/verify", verifyFunctions.handleVerify);
app.use(router.routes()).use(router.allowedMethods());
const PORT = 3000;
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
接收切片
由于经过koa-body的转换,formdata里的文件数据流被放到了ctx.request.files.【流属性名】里面,需要额外的一行代码取出
末尾的export {}是为了让ts不认为这个文件在全局作用域下,否则会导致不同文件无法声明同一个变量名
typescript
// uploadHandler.ts
const fs = require("fs");
const path = require("path");
const UPLOAD_DIR = path.resolve(__dirname, "uploads");
// 创建上传目录
if (!fs.existsSync(UPLOAD_DIR)) {
fs.mkdirSync(UPLOAD_DIR);
}
async function handleUpload(ctx: any) {
const { chunkName, fileName } = ctx.request.body;
const chunk = ctx.request.files.chunk;
const chunkDir = path.resolve(UPLOAD_DIR, fileName);
// 如果分片目录不存在,创建目录
if (!fs.existsSync(chunkDir)) {
fs.mkdirSync(chunkDir);
}
const chunkPath = path.resolve(chunkDir, chunkName);
// 将分片保存到本地
fs.writeFileSync(chunkPath, fs.readFileSync(chunk.filepath));
ctx.body = {
code: 0,
message: "Chunk uploaded successfully",
};
}
module.exports = {
handleUpload: handleUpload,
};
export {}
并行合并切片
typescript
const fs = require("fs");
const path = require("path");
const MERGE_DIR = path.resolve(__dirname, "uploads");
const STORE_DIR = path.resolve(__dirname, "stores");
// 创建上传目录
if (!fs.existsSync(STORE_DIR)) {
fs.mkdirSync(STORE_DIR);
}
// 合并切片
const mergeFileChunk = async (
filePath: any,
fileName: any,
size: number
) => {
const chunkDir = path.resolve(MERGE_DIR, fileName);
const chunkPaths = fs.readdirSync(chunkDir);
// 根据切片下标进行排序
// 否则直接读取目录的获得的顺序会错乱
chunkPaths.sort((a: any, b: any) => a.split("-")[1] - b.split("-")[1]);
// 合并切片
await Promise.all(
chunkPaths.map((chunkPath: any, index: number) =>
pipeStream(
path.resolve(chunkDir, chunkPath),
// 根据 size 在指定位置创建可写流
fs.createWriteStream(filePath, {
start: index * size,
})
)
)
);
fs.rmdirSync(chunkDir);
};
// 封装写入文件流的函数
const pipeStream = (path: any, writeStream: any) =>
new Promise<void>((resolve) => {
const readStream = fs.createReadStream(path);
readStream.on("end", () => {
fs.unlinkSync(path);
resolve();
});
readStream.pipe(writeStream);
});
async function mergeChunks(ctx:any) {
const { fileName, totalChunks, size } = ctx.request.body;
const targetPath = path.resolve(STORE_DIR, fileName);
// 合并切片
await mergeFileChunk(targetPath, fileName, size);
// 删除空的分片目录
ctx.body = {
code: 0,
message: "File merged successfully",
};
}
module.exports = { mergeChunks };
export {};
这一部分先把前面得到切片进行排序,之后通过Promise.all进行并行的文件写入
为什么是并行呢,因为即使nodejs在JavaScript下是单线程的,但是涉及更底层的操作,比如文件操作,则会进入V8的C++环境中进行并行执行。
这里就是将读到的服务端暂存的chunks文件形成数组,并且通过map将其映射为Promise,在Promise.all中统一执行文件读流的创建,在读结束后进行unlink删除已经读到的块释放存储,之后再写入合并的目标文件
优化:池化Promise限制并行上传文件块数量
先让我们用axios的第三个参数,监控一下分块上传的进度情况和上传数量
typescript
return axios.post("http://localhost:3000/upload", formData, {
cancelToken: cancelTokenSource.token,//后面用到
onUploadProgress: (e) => {
console.log("progress", index, e.loaded / e.total);
},
});
不管index如何,可以看到在单纯使用Promise.all下,chrome中只能建立6个上传请求,实际上chrome默认也只允许6个TCP请求并行执行,不进行对上传数量进行限制,用户如果此时还要发起网络请求或是上传其他文件就会卡顿
因此需要进行promise限制进行优化
typescript
export async function promisePoolRequest(functions, limit = 4) {
let index = 0;
let results = [];
await Promise.all(
[...new Array(limit)].map(async () => {
while (index < functions.length) {
const currentIndex = index;
index++; // 一定先加,不然在await后会导致index更新不及时,导致第一次重复执行全为0
try {
results[currentIndex] = await functions[currentIndex]();
} catch (error) {
throw new Error(error);
}
}
})
);
return results;
}
真正的池是[...new Array(limit)]
这里,创建了长度为limit的空数组,每个空位可以占位执行一个函数,直到while循环把所有函数都执行完毕,当一个在执行时候,因为await,直到结束都不会让同一个while循环进行下一个请求。
也可以认为是js创建了n=limit个while循环调度,从而让浏览器并行执行了n=limit个上传请求。
优化后可以发现只会同时上传limit个切片了
webworker计算MD5文件秒传
这里在webworker里用到spark-md5进行计算
由于wobworker是在浏览器环境下才会创建的,在webworker内的import要么是同样在根目录的public下面,要么是在外部的cdn上,无法引入node_modules等文件,因此需要手动去下载相关库文件进行执行,推荐下载min,js文件来减少存储大小
typescript
//根目录下
public
md5.worker.js
spark-md5.min.js
spark-md5.min.js下载Github地址github.com/satazor/js-...
javascript
//md5.worker.js
// 导入脚本
self.importScripts("/spark-md5.min.js");
//worker里的import要么是同样在本地public下面,要么是在外部的cdn上
// 生成文件 hash
self.onmessage = (e) => {
const { fileChunkList } = e.data;
const spark = new self.SparkMD5.ArrayBuffer();
let percentage = 0;
let count = 0;
const loadNext = (index) => {
//计算每个切片的hash,直到计算完所有切片
const reader = new FileReader();
reader.readAsArrayBuffer(fileChunkList[index].chunk);
reader.onload = (e) => {
count++;
spark.append(e.target.result);
if (count === fileChunkList.length) {
self.postMessage({
percentage: 100,
hash: spark.end(),
});
self.close();
} else {
//计算了一个切片的百分比
percentage += 100 / fileChunkList.length;
self.postMessage({
percentage,
});
loadNext(count);
}
};
};
loadNext(0);
};
在主代码中进行如下修改,在每次计算中都创建一个worker,充分发挥性能
javascript
function calculateHash(fileChunkList) {
const worker = new Worker("/md5.worker.js"); //每个都创建一个worker,多文件上传可以并行计算
return new Promise((resolve, reject) => {
worker.postMessage({ fileChunkList });
worker.onmessage = (e) => {
const { percentage, hash } = e.data;
// console.log('current percentage',percentage);
if (hash) {
resolve(hash);
}
};
});
}
async function verifyUpload( fileHash) {
const { data } = await axios.post("http://localhost:3000/verify", {
fileHash,
});
return data;
}
async function uploadChunks(file) {
const fileChunkList = createFileChunk(file);
const fileHash = await calculateHash(fileChunkList);
let { exist } = await verifyUpload(
fileHash,
);
if (exist ) {
console.log("file exist");
return;
}
//其他代码不变
}
在我们简单的服务端中,只需要存储每次传递的hash,如果找到了则返回存在即可,不再要求上传。处于简单考虑,直接在服务端使用Set进行Hash的存储
javascript
const hashSet = new Set();
async function handleVerify(ctx:any) {
const {fileHash} = ctx.request.body;
if(hashSet.has(fileHash)){
ctx.body = {
code: 0,
message: "file exist",
exist: true,
};
}
else{
ctx.body = {
code: 0,
message: "file not exist",
exist: false,
};
hashSet.add(fileHash);
}
}
module.exports = {
handleVerify,
};
优化:真正异步并行计算md5
在上面的代码中,尽管在worker中计算了md5,但是由于const fileHash = await calculateHash(fileChunkList);
还是进行了等待md5计算完之后,才进行文件操作。
尽管对于大文件而言,md5的计算时间相比于文件的上传时间大部分情况下微不足道,但造成的时间浪费确是实际的 ,更何况在5G等极快网络下,md5计算时间和文件上传时间有可能是差不多的,这对确确实实的第一次上传,服务端根本不会有相关记录的情况出现了时间浪费
比如500MB的docker安装包,计算耗时在分片大小为2MB时候可能是下面的要花6秒!
将切片大小增大,可以提高一些计算速度,但显然作用不大
5MB切片
10MB切片
如何优化呢?现在我们的逻辑是先计算md5再上传,为什么我们不充分利用worker的额外线程,来让浏览器调度 md5计算和文件上传。实现文件上传过程(主线程)与worker(worker线程)计算md5的并行执行
- 实现方法是不用async和await,而是直接用then链式调用,不阻塞文件的上传。
- 同时考虑,服务端已经有相应文件记录,在计算出md5并通知服务端检查时候,已经有一部分切片传递,因此还需要一个清除切片的接口。
- 还需要一个上传取消器 ,当得到已存在时,取消后续请求, 可以通过
const cancelTokenSource = axios.CancelToken.source();
实现
将upload方法修改
javascript
const cancelTokenSource = axios.CancelToken.source();
calculateHash(fileChunkList)
.then((hash) => {
return verifyUpload( hash);
})
.then(({ exist }) => {
if (exist) {
message.success("文件已存在,终止上传");
console.log("终止上传");
cancelTokenSource.cancel("文件已存在");
axios.post("http://localhost:3000/cleanChunks", {
fileName: file.name,
});
return;
}
});
let requestList = fileChunkList.map(({ chunk, chunkName }) => {
return () => {
//一定要包裹在函数里,否则会直接执行
const formData = new FormData();
formData.append("chunk", chunk);
formData.append("chunkName", chunkName);
formData.append("fileName", file.name);
return axios.post("http://localhost:3000/upload", formData, {
cancelToken: cancelTokenSource.token,
},
});
};
});
服务端添加路由
javascript
//app.ts
const chunkCleanFunctions = require("./cleanChunks");
router.post('/cleanChunks', chunkCleanFunctions.cleanChunks)
清除切片代码
typescript
const fs = require("fs");
const path = require("path");
const UPLOAD_DIR = path.resolve(__dirname, "uploads");
function cleanChunks(ctx: any) {
const { fileName } = ctx.request.body;
const chunkDir = path.resolve(UPLOAD_DIR, fileName);
if(!fs.existsSync(chunkDir)){
ctx.body = {
code: 0,
message: "chunk clean",
};
return;
}
const chunkPaths = fs.readdirSync(chunkDir);
chunkPaths.forEach((chunkPath: any) => {
fs.unlinkSync(path.resolve(chunkDir, chunkPath));
//清除每一个分片
});
fs.rmdirSync(chunkDir);
ctx.body = {
code: 0,
message: "clean chunks successfully",
};
}
module.exports = {
cleanChunks,
};
看看修改后的请求样子 如果是第一次请求,则md5的计算不会影响已经上传的切片
但如果文件已上传,则会中断后面的请求,并要求清除!
现在,你的大文件上传就实现了promise池控制上传并行请求数量 ,并且可以不浪费计算md5的时间了!