大文件分片上传:vue3+node express实现秒传,断点续传(Promise并发请求控制,web worker性能优化)

356 阅读10分钟

大文件分片上传:秒传,断点续传(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)

可能是你见过最详细的WebWorker实用指南 - 笑人 - 博客园 (cnblogs.com)