首页 > 其他分享 >大文件上传和下载解决方案

大文件上传和下载解决方案

时间:2023-10-07 16:00:30浏览次数:38  
标签:文件 const 解决方案 ctx chunks 上传 下载 md5

前言
前端处理 “大” 一直是一个痛点和难点,比如大文件、大数据量。虽然浏览器硬件有限,但是聪明的工程师总是能够最大化利用浏览器的能力和特性,优雅的解决一个个极端问题,满足用户的多样化需求。

断点上传
对于大文件,如果我们直接上传,用户网速够慢的话,可能需要等上几天几夜才能上传完成,这样的用户体验可能导致用户直接放弃,那么有没有一种方式能够更好的上传大文件呢?

首先我们可以想到一些浏览器常见的优化套路:

多线并行处理
缓存结果
按需使用
有了优化思路,那么看看浏览器支持能力:

HTTP 1.x,浏览器可以并行处理请求,比如 Chrome 可以并行处理 6 个请求。HTTP 2.x,理论上可以无限制并行处理请求。
浏览器支持 WebWorker单独子线程来处理一些耗时任务。
HTTP 没有状态,所以我们只能将状态缓存到服务器。
浏览器也提供了支持能力,那么我们怎么把一个文件并发上传,又如何做缓存呢?

文件切割和唯一标识
我们知道,计算机底层数据都是由 0 和 1 的二进制数据构成,文件也不例外,那么我们可以按照字节数将大文件切割成一个个小文件块,然后并行上传。但是切割之后的文件块是无法标识的,所以我们需要为文件确定一个唯一标识,我们常见会使用文件名来标识文件,但是文件名是可修改的,这样的标识是非常不可靠的,所以我们会基于文件内容来做一个标识,也就是计算文件的 md5 值,这样只要文件内容不修改,文件的 md5 值就不会变化。

//【前端代码】文件切割块和计算唯一标识
const CHUNK_SIZE = 10 * 1024 * 1024;
const slice = File.prototype.slice;
// 获取文件块
function getFileChunks(size: number) {const chunks = []const chunkCount = Math.ceil(size / CHUNK_SIZE)for (let i = 1; i < chunkCount; i++) {chunks.push(i * CHUNK_SIZE)}if (chunkCount) {chunks.push(size)}return chunks
}
// 计算 MD5 值
function computedMD5(file: File): Promise<string> {return new Promise((resolve, reject) => {const spark = new SparkMD5.ArrayBuffer()const reader = new FileReader()const chunks = getFileChunks(file.size)let currentChunk = 0reader.onload = (e: any) => {spark.append(e?.target?.result)currentChunk++if (currentChunk < chunks.length) {loadNext()} else {resolve(spark.end())}}reader.onerror = (error) => {console.error(error)reject('computed fail')}function loadNext() {const start =(Math.ceil(chunks[currentChunk] / CHUNK_SIZE) - 1) * CHUNK_SIZEconst end = chunks[currentChunk]reader.readAsArrayBuffer(slice.call(file, start, end))}loadNext()})
}

 

我们通过文件大小和固定文件块大小来计算需要上传的文件块数量和每个块对应字节范围。然后使用 spark-md5库来计算文件的 md5 值,如果注意的是,如果文件较大,计算 md5 时间可能较长,所以需要利用 WebWorker来计算。

文件并行上传和缓存实现
通过对文件切块和 md5 值计算,我们可以并行的上传文件块,并缓存上传的文件块。

//【前端代码】缓存实现和并发请求
// 获取将要上传的块
async function getUploadChunks(file: File, md5: string): Promise<number[]> {// 全部文件块const chunks = getFileChunks(file.size)// 已上传的文件块索引const uploadChunks: number[] | boolean = await getChunks({md5: md5,filename: file.name,})// 秒传if (typeof uploadChunks === 'boolean') {return []}return chunks.filter((chunk, index) => {return uploadChunks.indexOf(index) === -1})
}
// 并发上传
async function uploadParallel(file: File, chunks: number[], md5: string) {const res = chunks.map((chunk: number, index) => {const formData = new FormData()formData.append('md5', md5)formData.append('chunk',slice.call(file,(Math.ceil(chunks[index] / CHUNK_SIZE) - 1) * CHUNK_SIZE,chunks[index]))formData.append('filename', file.name)formData.append('index',String(Math.ceil((chunk - CHUNK_SIZE) / CHUNK_SIZE)))return uploadChunk(formData)})Promise.allSettled(res).then(() => {notifyCombine(file, md5)})
}

这里我们通过一个接口来保持上传的状态。使用File.prototype.slice来进行文件切割,并通过Promise.allSettled来实现并发上传。

//【后端代码】获取上传的文件块和文件上传
const SAVE_DIR = "public";
router.get("/getChunks",validQuery({md5: {type: "string",required: true,},filename: {type: "string",required: true,},}),(ctx) => {const { query } = ctx.request;const { md5, filename } = query;// 如果存在文件,则秒传const ext = path.extname(filename);const bool = existsSync(`${SAVE_DIR}/${md5}${ext}`);if (bool) {ctx.success({data: true,});return;}mkdirSync(`${UPLOAD_DIR}/${md5}`);const files = [];traverseSync(`${UPLOAD_DIR}/${md5}`, (path) => {files.push(+path.replace(`${UPLOAD_DIR}/${md5}/`, ""));});ctx.success({data: files,});}
);
router.post("/uploadChunk",validFiles({chunk: {type: "boolean",required: true,},}),validBody({md5: { type: "string", required: true },index: { type: "string", required: true },}),async (ctx) => {const { body, files } = ctx.request;const { md5, index } = body;await copy(files.chunk.filepath, `${UPLOAD_DIR}/${md5}/${index}`);ctx.success({});}
);

合并文件,文件上传校验

当服务器文件块和本地切割块一致时,则通知服务器进行文件合并。

//【前端代码】文件合并
// 通知文件合并
async function notifyCombine(file: File, md5: string) {const chunks = await getUploadChunks(file, md5)if (chunks.length === 0) {await mergeChunk({md5: md5,filename: file.name,})} else {uploadParallel(file, chunks, md5)}
}

 

 

// 【后端代码】文件合并
router.post("/mergeChunk",validBody({md5: {type: "string",required: true,},filename: {type: "string",required: true,},}),async (ctx) => {const { body } = ctx.request;const { md5, filename } = body;const ext = path.extname(filename);try {const result = await mergeFile(`${UPLOAD_DIR}/${md5}`,`public/${md5}${ext}`);if (result) {rmdir(`${UPLOAD_DIR}/${md5}`);ctx.success({});} else {ctx.fail({});}} catch {ctx.fail({});}}
);

 

至此,断点续传已经完成了。

断点下载

对于大文件上传,上面那节我们给了解法,那么对于大文件下载,我们应该怎么做呢?其实原理也是一样的:利用浏览器请求并发能力和缓存能力。

获取文件信息

首先我们需要获取文件的总大小,从而进行分块下载。

// 【前端代码】
async function download() {const filepath = '34ffeb6eac2cc74423421538b2b35d68.zip'const res = await headDownload({filepath: filepath,})const length = res?.['content-length'] as numberconst filename = getFileName(res?.['content-disposition'] as string)const chunks = getFileChunks(+length)retryDownload(filepath, filename, chunks, [])
}

 

我们使用 head 请求来获取文件的大小和文件名称,从而进行分块下载。

//【后端代码】获取文件信息
router.head("/downloadFile",validQuery({filepath: {type: "string",required: true,},}),(ctx) => {const { query } = ctx.request;const { filepath } = query;const pathname = "public/" + filepath;try {const statObj = statSync(pathname);ctx.set("Content-Disposition",`attachment;filename=${encodeURIComponent(filepath)}`);ctx.body = "success";ctx.length = statObj.size;} catch (error) {console.error(error);ctx.fail({});return;}}
);

 

分块下载和重试机制

我们利用请求头 range来进行分块下载,并添加重试机制。

//【前端代码】分块下载和重试
// 分块下载
async function downloadChunk(filename: string, start: number, end: number) {const buffer = await downloadFile({filepath: filename,},{headers: { range: `bytes=${start}-${end}` },responseType: 'arraybuffer',})return buffer
}
// 分块下载和重试
function retryDownload( downloadPath: string,filename: string,chunks: number[],result: Record<string, any>[] ) {const list = chunks.map((chunk, index) => {return downloadChunk(downloadPath,(Math.ceil(chunks[index] / CHUNK_SIZE) - 1) * CHUNK_SIZE,chunks[index])})Promise.allSettled(list).then((res) => {// 下载完全const successList = res.filter((i, index) => {if (i.status === 'fulfilled') {result[Math.ceil((chunks[index] - CHUNK_SIZE) / CHUNK_SIZE)] = i}return i.status === 'fulfilled'})if (successList.length === list.length) {const buffers: Uint8Array[] = (result || []).map((i) => {return new Uint8Array(i?.value)})const res = mergeBlobChunk(buffers)if (res) {saveAs(filename, res)}} else {// 下载剩余块const failList = res.reduce((acc: number[], cur, index) => {if (cur.status === 'rejected') {acc.push(index)}return acc}, [])const list = chunks.filter((chunk, index) => {return failList.indexOf(index) !== -1})retryDownload(downloadPath, filename, list, result)}})
}

我们通过一个递归函数,每次上传检测下载进度,从而完成下载重试。

// 【后端代码】分块下载
router.post("/downloadFile",validBody({filepath: {type: "string",required: true,},}),(ctx) => {const { headers, body } = ctx.request;const { filepath } = body;const { range } = headers;const pathname = "public/" + filepath;let statObj = {};try {statObj = statSync(pathname);} catch (error) {console.error(error);ctx.fail({});return;}if (range) {let [, start, end] = range.match(/(\d*)-(\d*)/);// 文件总字节数let total = statObj.size;// 处理请求头中范围参数不传的问题start = start ? parseInt(start) : 0;end = end ? parseInt(end) : total - 1;ctx.status = 206;ctx.set("Accept-Ranges", "bytes");ctx.set("Content-Range", `bytes ${start}-${end}/${total}`);ctx.body = fs.createReadStream(pathname, { start, end }).pipe(PassThrough());} else {ctx.body = fs.createReadStream(pathname).pipe(PassThrough());}}
);

 

文件合并和下载

在获取到所有文件数据之后,我们需要对文件进行合并,并下载。

// 【前端代码】文件合并和下载
// 文件合并
function mergeBlobChunk(arrays: Uint8Array[]) {if (!arrays.length) returnconst totalLength = arrays.reduce((acc, value) => acc + value.length, 0)const result = new Uint8Array(totalLength)let length = 0for (const array of arrays) {result.set(array, length)length += array.length}return result
}
// 文件下载
export function saveAs( filename = '',buffers: BlobPart,mime = 'application/octet-stream' ) {const blob = new Blob([buffers], { type: mime })const blobUrl = URL.createObjectURL(blob)const a: HTMLAnchorElement = document.createElement('a')a.download = filenamea.href = blobUrla.click()URL.revokeObjectURL(blobUrl)
}

 

我们获取到的文件数据是ArrayBuffer类型,这个数据是不能直接操作的,所以我们需要使用类型数组来操作它,这里我们使用Unit8Array类型数组来合并文件数据,最后通过生成BlobUrl来进行文件下载。

总结
断点上传和断点下载都是利用常见的优化套路:并行计算和缓存。充分发挥浏览器特性能力,达到更佳的效果。其实大数据渲染也是相似套路,比如懒加载、分片渲染、虚拟列表等等,使用的是按需加载、异步渲染、按需渲染的套路来达到大数据的渲染效果。

参考文章:http://blog.ncmem.com/wordpress/2023/10/07/%e5%a4%a7%e6%96%87%e4%bb%b6%e4%b8%8a%e4%bc%a0%e5%92%8c%e4%b8%8b%e8%bd%bd%e8%a7%a3%e5%86%b3%e6%96%b9%e6%a1%88/

欢迎入群一起讨论

 

 

标签:文件,const,解决方案,ctx,chunks,上传,下载,md5
From: https://www.cnblogs.com/songsu/p/17746537.html

相关文章

  • golang实现一个简单的文件浏览下载功能代码示例
    想省事用Claude(一个依托chatgpt的AI)生成一段golang的文件浏览下载示例,结果给生成的代码大概是这样的(省去了无关部分,主要部分如下):http.HandleFunc("/*",downloadFile)http.HandleFunc("/",showFileList)测试之后,结果发现每次都会走到“/”下去,无论如何都不会......
  • lumion中文官网-lumion软件中文版下载 安装包下载方式
    性能特点1、尽量最好地揭示您的修建愿景:种类操控键位是Lumion11的堆积性能。只需按下它,选择一种种类。您会立即获得一个均衡了光线、深入度和实际感的诱人图象。新的天空光日光摹拟器和柔和精美暗影加强。翻开它们,看着树叶和草变得很是传神。看桌子和书架上面的暗影变得奥妙而柔和......
  • lumion中文官网-lumion软件中文版下载 安装包下载方式
    性能特点1、尽量最好地揭示您的修建愿景:种类操控键位是Lumion11的堆积性能。只需按下它,选择一种种类。您会立即获得一个均衡了光线、深入度和实际感的诱人图象。新的天空光日光摹拟器和柔和精美暗影加强。翻开它们,看着树叶和草变得很是传神。看桌子和书架上面的暗影变得奥妙而柔和......
  • Lumion中文版下载-Lumion(含补丁)下载 安装包下载方式
    Lumion12.0是一个实时的3D可视化工具,用来制作电影和静帧作品,涉及到的领域包括建筑、规划和设计。它也可以传递现场演示。Lumion的强大就在于它能够提供优秀的图像,并将快速和高效工作流程结合在了一起,为你节省时间、精力和金钱。人们能够直接在自己的电脑上创建虚拟现实。通过渲染......
  • Navisworks Manage 2022 软件下载 安装包下
    AutodeskNavisworksManage是一款专门为建筑类工人设计的3d建模服务的软件,是建筑类领先的软件。AutodeskNavisworksManage可以进行冲突检测、高级协作。而且新版本AutodeskNavisworksManage进行了优化调整,鞥家了更多新功能。软件地址:看置顶贴AutodeskNavisworksManage2020......
  • 安装包Matlab-中文科学编程软件-安装包下载方式
    Matlab应用是从外网软件论坛中引进的一款优质数据分析平台,该应用的UI布局设计参考了主流的办公应用,主体是由MATLAB和Simulink组成,内置了多达六百余种的函数运算模式,适合于每天需要处理大规模数据计算的行业,像仿真建模、系统算法的研发以及复杂的工程结构作图等,相信会是满意的选择。......
  • matlab下载-matlab软件官方版下载「编程开发」安装包下载方式
    matlab最新版本是一款非常不错的数学计算软件,这款软件非常的给力有着很强悍的算法以及模型创建能力,此版本更新了很多新的功能,能让你的办公使用更加强悍,快来下载试试吧。软件地址:看置顶贴Matlab2020b软件特色1、Matlab2020b提供用于科学计算及工程设计的高级语言。2、Matlab2020b可......
  • 在非React组件的文件中使用Reudx 会造成的问题及解决方案
    Redux的使用场景函数式组件内(hooks调用redux)其他文件(无法使用hooks调用redux)这里函数式组件内使用Redux不再赘述,站内跳转,Reduxtoolkit使用一、非函数式组件的文件内使用reduxuseDispatch和useSelector这两个hooks只能在函数组件内使用,在非函数组件的文件(以下简称三方文......
  • 如何实现大文件上传
    一、解决方案既然大文件上传不适合一次性上传,那么将文件分片散上传是不是就能减少性能消耗了。分片上传就是将大文件分成一个个小文件(切片),将切片进行上传,等到后端接收到所有切片,再将切片合并成大文件。通过将大文件拆分成多个小文件进行上传,确实就是解决了大文件上传的问题。因为......
  • 视频融合平台EasyCVR利用视频监控系统构建智能读表产品应用解决方案
    安防视频监控平台EasyCVR是一个具有强大拓展性、灵活的视频能力和轻便部署的平台。它支持多种主流标准协议,包括国标GB28181、RTSP/Onvif、RTMP等,还可以支持厂家的私有协议和SDK接入,例如海康Ehome、海大宇等设备的SDK。该平台不仅拥有传统安防视频监控的功能,还具备接入AI智能分析的......