真正并行计算MD5,池化Promise限制上传并行数优化大文件切片上传

本文会从基础实现开始,根据基础实现可以优化的地方逐步优化,如果很熟悉基础的文件上传,只是好奇标题是如何实现的,可以直接看目录的各个优化开头的分段

基础版切片上传与合并

基础概念

大文件切片上传与合并的基础版本代码其实并不复杂,关键在于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的时间了!

相关推荐
田本初15 分钟前
如何修改npm包
前端·npm·node.js
明辉光焱36 分钟前
[Electron]总结:如何创建Electron+Element Plus的项目
前端·javascript·electron
牧码岛1 小时前
Web前端之汉字排序、sort与localeCompare的介绍、编码顺序与字典顺序的区别
前端·javascript·web·web前端
开心工作室_kaic1 小时前
ssm111基于MVC的舞蹈网站的设计与实现+vue(论文+源码)_kaic
前端·vue.js·mvc
晨曦_子画1 小时前
用于在 .NET 中构建 Web API 的 FastEndpoints 入门
前端·.net
慧都小妮子2 小时前
Spire.PDF for .NET【页面设置】演示:在 PDF 文件中添加图像作为页面背景
前端·pdf·.net·spire.pdf
咔咔库奇2 小时前
ES6基础
前端·javascript·es6
Jiaberrr2 小时前
开启鸿蒙开发之旅:交互——点击事件
前端·华为·交互·harmonyos·鸿蒙
bug爱好者2 小时前
如何解决sourcetree 一打开就闪退问题
前端·javascript·vue.js