大文件分片上传:秒传,断点续传(Promise并发请求控制,web worker性能优化)
在观摩了掘金上一些大佬的大文件分片的博客文章之后,自己的简单实践。当然糅合了一些比较基础的文章内容和一些佬的技术点,再加上自己的想法,做成的稍微完整的大文件分片上传机制。适合新手初学。(在敲完之后写这篇博客作为review and summary)
前端部分
利用input file类型的change事件监听文件内容(File)
<!--基础dom->
<template>
<div>
大文件上传: <input type="file" @change="handleChange" />
<p></p>
<button @click="handleUpload">上传</button>
</div>
</template>
封装基础axios
导出request方法
import axios from 'axios';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
// 配置 NProgress
NProgress.configure({
showSpinner: true, // 开启右上角的旋转图标
});
const service = axios.create({
baseURL: 'http://localhost:3000', //注意你node服务器的本地地址/端口
timeout: 5000
});
// 请求拦截器
service.interceptors.request.use(
(config: any) => {
return config;
},
(error: any) => {
return Promise.reject(error);
}
);
// 响应拦截器
service.interceptors.response.use(
(response: { data: any; }) => {
return response.data;
},
(error: any) => {
return Promise.reject(error);
}
);
export default service;
文件分片
//largeFilesUpload.vue
import type { ChunkInfo } from './types'
import request from '@/axios/index';
const uploadFile = ref<File | null>(null)
const fileHash = ref('') //完整文件hash
//处理上传文件
/**
* @description: 存取文件内容=>切片
* @param {*} e
* @return {*}
* @Author: 张子豪
* @Date: 2024-11-12 21:16:55
*/
//创建web worker线程计算文件hash
const worker = new Worker(new URL('@/workers/hashfilechunksworker', import.meta.url),
{ type: 'module' })
const handleChange = (e: any) => {
if (!e.target.files[0]) {
return
} else {
uploadFile.value = e.target.files[0]
//通知worker计算切片
worker.postMessage({
file: uploadFile.value!
})
//监听worker计算结果
worker.addEventListener('message', e => {
console.log(e.data, 'done');
// 文件分片列表 每个分片的hash值列表 完整文件的hash值
const { chunkList, hashList, fileEntireHash } = e.data;
fileHash.value = fileEntireHash
//生成最终的分片信息列表
uploadChunkList.value = chunkList.map((chunk: Blob, index: number) => {
return {
chunk,
hash: hashList[index],
size: chunk.size,
fileName: uploadFile.value!.name,
chunkName: `${uploadFile.value!.name}-${index}`
}
})
});
//监听worker错误
worker.onerror = (event) => {
worker.terminate() //关闭worker线程
}
}
}
interface ChunkInfo {
chunk: Blob;
hash: string;
size: number;
fileName: string;
chunkName: string;
}
export type {
ChunkInfo
}
在worker线程中分片和计算hash
为什么要计算分片和文件的hash?
·文件命名会有重复,不能作为唯一标识
·利用分片唯一标识进行检测是否成功上传并存在服务端(可以用来确定哪些成功上传哪些还没有上传并进行续传)
为什么要用web worker?
·文件的hash计算属于CPU密集型任务,比较耗时,JS是单线程语言,在JS线程中容易阻塞,导致页面白屏或者加载乱序或者不能正常响应事件,而另开辟一个web worker线程可以解决这些问题。 关于web worker
//hash散列算法: 是一种从任何一种数据中创建小的数字“指纹”的方法。基本原理是将任意长度数据输入,最后输出固定长度的结果。不论输入数据的大小,哈希函数的输出长度是固定的。
//例如,常见的哈希函数如 MD5 和 SHA - 256 生成的哈希值长度分别为 128 位(32个十六进制字符)和 256 位。
//@/workers/hashfilechunksworker.ts
import SparkMD5 from 'spark-md5'; //使用md5进行hash
self.addEventListener('message', async (e) => {
console.log('worker', e.data);
//获取分片列表
const chunkList = createChunk(e.data.file);
//获取分片对应的hash列表
let hashList = chunkList.map((chunk) => getChunkHash(chunk));
hashList = await Promise.all(hashList);
//获取文件hash值
const merkleTree = new MerkleTree(hashList.map(hash => new MerkleTreeNode(hash)));
const fileEntireHash = merkleTree.getRootHash();
self.postMessage({ chunkList, hashList, fileEntireHash });
});
//分片
const createChunk = (file: File, size = 1024 * 512) => {
const chunkList: Blob[] = [];
let curSize = 0;
while (curSize < file.size) {
chunkList.push(file.slice(curSize, Math.min(curSize + size, file.size)))
curSize += size;
}
return chunkList;
}
//获取文件chunk hash值
const getChunkHash = (chunk: Blob) => {
return new Promise((resolve, reject) => {
const spark = new SparkMD5.ArrayBuffer();
const reader = new FileReader(); //SparkMD5是对arraybuffer类型数据加密,用FileReader把blob转为arraybuffer
reader.onload = (e) => {
spark.append(e.target?.result as ArrayBuffer);
}
reader.onloadend = () => {
resolve(spark.end());
}
reader.onerror = reject;
reader.readAsArrayBuffer(chunk);
})
}
//此处利用所有的分片构建默克尔树生成hash,但是还有很多简单的方式毕竟对32个十六进制字符(一个md5哈希值)的加密计算相比很多MB的一个文件要容易的多,所以可以忽略。
//根据分片hash构建默克尔树 获取文件hash值
interface MerkleTreeNode {
hash: string;
left?: MerkleTreeNode | null;
right?: MerkleTreeNode | null;
}
interface MerkleTree {
root: MerkleTreeNode;
leafs: MerkleTreeNode[];
}
class MerkleTreeNode implements MerkleTreeNode {
hash: string;
left: MerkleTreeNode | null;
right: MerkleTreeNode | null;
constructor(hash: string) {
this.hash = hash;
this.left = null;
this.right = null;
}
}
class MerkleTree implements MerkleTree {
root: MerkleTreeNode;
leafs: MerkleTreeNode[];
constructor(leafs: MerkleTreeNode[]) {
this.leafs = leafs;
this.root = this.buildTree(leafs);
}
getRootHash() {
return this.root.hash;
}
//建树思想:一个节点的hash值是其左右子节点的合并hash值
buildTree(leafs: MerkleTreeNode[]): MerkleTreeNode {
if (leafs.length === 0) return null;
if (leafs.length === 1) {
return leafs[0];
}
const mid = Math.floor(leafs.length / 2);
const left = this.buildTree(leafs.slice(0, mid));
const right = this.buildTree(leafs.slice(mid));
const node = new MerkleTreeNode(SparkMD5.hash(left.hash + right.hash));
node.left = left;
node.right = right;
return node;
}
}
文件上传
先考虑边界情况:在分片上文件之前想想:之前上传过的文件有没有必要再传?(秒传)、曾经上传中断,已经上传的分片有没有必要再传?(断点续传)
· 处理方式之一:“预检请求”(当然不是你想象的那个OPTIONS) 在上传之前先问问node服务器当前上传的这个文件的相关情况。
const resumableUpLoadList = ref<ChunkInfo[]>([]) //重传列表(剩余未传的分片列表)
const fileHash = ref('') //文件hash
/**
* @description: 打包分片为FormData类型进行上传
* @return {*}
* @Author: 张子豪
* @Date: 2024-11-12 21:29:47
*/
const handleUpload = async () => {
let res = await request({
url: 'check',
method: 'post',
data: {
hash: fileHash.value, //上传的文件的唯一标识
fileName: uploadFile.value!.name,
}
})
//根据和node服务端确认当前文件已经上传的情况进行上传
if (res.code === 1&& res.exit) { // 文件有分片存在,返回存在的分片的hash
//断点续上传
resumableUpLoadList.value = uploadChunkList.value.filter(item => !res.existedChunks.includes(item.hash)) //过滤出未上传的分片的hash
console.log(resumableUpLoadList.value.length, 'resumableUpLoadList');
//如果都上传了,不用重传了
if (resumableUpLoadList.value.length == 0) {
alert('文件秒传成功!')
return;
}
} else {
//未上传过就老老实实从头开始的上传
resumableUpLoadList.value = uploadChunkList.value
}
//因为涉及到前后端支持的发送和接收文件类型的不同
/*Blob 是 js 独有的,虽是文件类型,但是不便用于传输,后端那么多语言不一定有 Blob 。但是表单格式前后端都有,其实最早前后端传输就是这个表单格式
*/
const formDataList = resumableUpLoadList.value.map((item: ChunkInfo) => {
const formData = new FormData();
formData.append('file', item.chunk);
formData.append('hash', item.hash);
formData.append('fileName', item.fileName);
formData.append('chunkName', item.chunkName);
return formData;
})
// console.log(formDataList, 'formDataList')
//封装请求
const requestList = formDataList.map((item: FormData, index) => {
return () => new Promise<void>(async (resolve) => {
await request({
url: 'upload',
method: 'post',
data: item,
})
NProgress.set(index + 1) //更新上传进度条进度
resolve()
})
})
//并行控制请求
const requestResList: Promise<void>[] = await parallelRun(requestList, 6)
Promise.all(requestResList).then(async () => {
console.log(requestResList);
await mergeChunks()
console.log('all done')
}).catch(err => {
alert('上传失败')
NProgress.done()
console.log(err);
})
}
/**
* @description: 分片全部上传成功后发起合并请求
* @return {*}
* @Author: 张子豪
* @Date: 2024-11-12 21:38:37
*/
const mergeChunks = () => request({
url: 'merge',
method: 'post',
data: {
fileSize: uploadFile.value!.size,
fileName: uploadFile.value!.name,
size: 1024 * 512
}
})
Promise并发请求控制
注意别混淆并发与并行!
浏览器发起的请求最大并发数量一般都是6~8个,这是因为浏览器会限制同一域名下的并发请求数量,以避免对服务器造成过大的压力。
@/utils/parallel
/**
* @description: 并行执行任务
* @param {*} T
* @param {number} max
* @return {*}
* @Author: 张子豪
* @Date: 2024-11-15 12:30:11
*/
const parallelRun = <T>(sourceTask: (() => Promise<T>)[], max: number): Promise<Promise<T>[]> => {
const runningMapPool = new Map(); //请求池
const totalTask = [...sourceTask]; //任务队列
const promiseResult: Promise<T>[] = []; //结果队列
let count: number = 0;
console.log('totalTask.length', sourceTask.length);
//并行控制执行
const inQueue = (totalTask: (() => Promise<T>)[], max: number, resolve: any) => {
//存储正在执行的任务
while (totalTask.length > 0 && runningMapPool.size < max) {//剩余任务数>0 且 正在运行的任务数<最大并发数
const curTask = totalTask.shift(); //取出一个任务进请求池
if (curTask) {
const taskId = Symbol(totalTask.length);
runningMapPool.set(taskId, curTask);
curTask()
.then(result => {
//收集结果
promiseResult.push(Promise.resolve(result));
})
.catch(error => {
promiseResult.push(Promise.reject(error));
})
.finally(() => {
//执行结束清除出请求池
runningMapPool.delete(taskId);
console.log(count++);
// 递归执行
inQueue(totalTask, max, resolve);
});
}
}
if (totalTask.length === 0 && runningMapPool.size === 0) {
resolve(promiseResult); //全部任务执行完毕
return;
}
}
//用Promise包装控制异步,返回结果
return new Promise((resolve) => {
inQueue(totalTask, max, resolve)
})
}
export {
parallelRun
}
思考:将请求池的数据结构换为队列会更好还是更差?
Node后端部分(express框架)
const express = require('express');
const path = require('path');
const fs = require('fs');
const multiparty = require('multiparty'); //接收处理FormData类型数据
const app = express();
const chunkDir = path.resolve(__dirname, `chunks`);
const hashDir = path.resolve(__dirname, `fileHash`);
//创建保存分片和hash文件的文件夹
if (!fs.existsSync(chunkDir)) fs.mkdirSync(chunkDir);
if (!fs.existsSync(hashDir)) fs.mkdirSync(hashDir);
let fileEntireHash = ''; //保存完整文件的hash
//express的中间件处理请求参数
app.use(express.json());
app.use(express.urlencoded({ extended: true }))
//自定义全局中间件处理跨域和可能的预检请求(Chrome有,edge没有)
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*') // 允许所有的请求源来跨域
res.setHeader('Access-Control-Allow-Headers', '*') // 允许所有的请求头来跨域
// 请求预检
if (req.method === 'OPTIONS') {
res.status = 200
res.end()
return
}
next();
});
//hash检测
app.post('/check', (req, res) => {
const { fileName, hash } = req.body;
fileEntireHash = hash;
const fileHashName = `${fileName}-${hash}.txt`;
const filePath = path.resolve(hashDir, fileHashName);
const fileHashList = fs.readdirSync(hashDir);
console.log(fileHashList, fileHashName);
//读取可能存在的存储上传文件hash值的txt文件,如果不存在会报错,用try catch捕获处理。
try {
const existedHashes = fs.readFileSync(filePath).toString('utf-8').split('\n');
if (fileHashList.includes(fileHashName) && existedHashes.length) {
//有这个对应的文件,读取文件存储的hash值并返回
res.send({
code: 1,
exist: true,
existedChunks: existedHashes
});
} else {
res.send({
code: 0,
exist: false
});
}
} catch (error) {
//读取报错 没有这个存储hash的txt文件 老老实实从头传
res.send({
code: 0,
exist: false
});
}
})
//分片上传
app.post('/upload', (req, res) => {
const form = new multiparty.Form();
form.parse(req, (err, fields, files) => {
if (err) {
return res.status(500).send(err);
}
const file = files.file[0] // 切片的内容
const hash = fields.hash[0]
const fileName = fields.fileName[0]
const chunkName = fields.chunkName[0]
fs.renameSync(file.path, `${chunkDir}/${chunkName}`) // 移动文件 因为fs读取的文件会暂存在c盘的TEMP里面,移动到现在存储分片的文件夹下
const filePath = path.resolve(hashDir, `${fileName}-${fileEntireHash}.txt`);
向存储这个文件的分片hash值的txt文件中追加写
fs.appendFileSync(filePath, hash+'\n')
res.send('success');
});
});
//文件合并
app.post('/merge', async (req, res) => {
// console.log('merge', req.body);
const { fileName, size, fileSize } = req.body
await mergeFileChunks(chunkDir, fileName, size,fileSize)
res.send('merge success');
});
// 合并片段:转换成流类型
const mergeFileChunks = (dir, fileName, size,fileSize) => {
const chunks = fs.readdirSync(dir);
chunks.sort((a, b) => a.split('-')[1] - b.split('-')[1])//因为接收的文件顺序不一定与原顺序相同,所以把文件名排序
// console.log(chunks.length,'chunks', chunks)
//把合并的文件存储到以其文件名命名的文件夹下
const filePath = path.resolve(__dirname, fileName);
if (!fs.existsSync(filePath)) fs.mkdirSync(filePath);
const arr = chunks.map((chunkPath, index) => {
return pipeStream(path.resolve(dir, chunkPath), fs.createWriteStream(path.resolve(filePath, fileName), {
start: index * size,
end: Math.min((index + 1) * size, fileSize) //最后一个分片大小一般小于分片大小
}))//可写流配置写入起始范围
})
return Promise.all(arr)
}
//流读写器
const pipeStream = (path, writeStream) => {
// console.log('pipeStream', path)
return new Promise((resolve, reject) => {
const readStream = fs.createReadStream(path)
readStream.pipe(writeStream) // 写入流
writeStream.on('finish', () => {
try {
fs.unlinkSync(path); // 删除读过的分片文件
resolve();
} catch (err) {
reject(new Error(`Error deleting file: ${err.message}`));
}
});
})
}
app.listen(3000, () => {
console.log(`Server listening on http://127.0.0.1:3000`);
});
待优化
1.web worker 线程使用率没有最大化。我只开辟了一个线程计算,如果文件非常大,可以维护一个线程池并行计算,一个线程池的大小可以从navigator.hardwareConcurrency || 4,获取当前设备的硬件并发线程数。默认为4.
2.hash加密处理可能导致内存不够。 在加密前将文件的全部分片转为 ArrayBuffer 数组然后都存到内存中, 从而产生大量内存占用。(此时存在源文件、所有分片文件、ArrayBuffer数据)占用存储空间是原来的3倍!。
参考
个人比较懒,所以对一些细节就不再讲述比如blob类型与File类型的关系,Blob.slice()等等。但是以下参考会有讲到,请悉知。
基础版:
面试官:你如何实现大文件上传提到大文件上传,在脑海里最先想到的应该就是将图片保存在自己的服务器(如七牛云服务器),保存在 - 掘金 (juejin.cn)
中级(主观感觉,本人还是很菜的)
Vue 3 大文件分片上传的技术实现与优化 | PF组件库 (ricardopang.github.io)
进阶版
超详细的大文件分片上传⏫实战与优化⚡(前端部分)支持断点续传; 文件秒传; 使用了 WebWorker 并实现了浏览器的 - 掘金 (juejin.cn)
其他:
面试官:假如有几十个请求,如何去控制并发?面试官:看你简历上做过图片或文件批量下载,那么假如我一次性下载几十个,如何去控 - 掘金 (juejin.cn)