真正并行计算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的时间了!

相关推荐
恋猫de小郭11 小时前
Flutter Zero 是什么?它的出现有什么意义?为什么你需要了解下?
android·前端·flutter
崔庆才丨静觅17 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606118 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了18 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅18 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅19 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅19 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment19 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅19 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊19 小时前
jwt介绍
前端