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

news2024/11/17 2:56:39

前言

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

断点上传

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

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

  • 多线并行处理
  • 缓存结果
  • 按需使用

有了优化思路,那么看看浏览器支持能力:

  • 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来进行文件下载。

总结

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

最后

最近还整理一份JavaScript与ES的笔记,一共25个重要的知识点,对每个知识点都进行了讲解和分析。能帮你快速掌握JavaScript与ES的相关知识,提升工作效率。



有需要的小伙伴,可以点击下方卡片领取,无偿分享

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/192661.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Linux中的磁盘管理与打包命令

✅作者简介&#xff1a;热爱国学的Java后端开发者&#xff0c;修心和技术同步精进。 &#x1f34e;个人主页&#xff1a;Java Fans的博客 &#x1f34a;个人信条&#xff1a;不迁怒&#xff0c;不贰过。小知识&#xff0c;大智慧。 &#x1f49e;当前专栏&#xff1a;Java案例分…

2022.11.29(面经五,笔试+技术面)

2022.11.29&#xff08;面经五&#xff09; 笔试题目不难&#xff0c;多刷力扣就成 1.什么是面向对象&#xff1f; 面向对象&#xff1a;是把构成问题的事务分解成各个对象&#xff0c;而建立对象的目的也不是为了完成一个个步骤&#xff0c;而是为了描述某个事物在解决整个问…

应用笔记 | TSMaster核心功能之标定数据的管理

概述标定模块中&#xff0c;标定数据的管理也是其核心功能。主要包括以下方面的内容&#xff1a;标定数据的载入、标定数据导出、标定数据的刷写&#xff0c;以及配套应用程序的刷写等。下面来详细介绍下这些功能。一、标定数据的载入标定数据的载入路径如下&#xff1a;选择目…

Linux网络设备驱动框架

1. 网络设备驱动框架 1.1网际协议分层 优点&#xff1a; 便于封装&#xff1b; 1.2 网络设备驱动程序结构分层 协议接口层&#xff1a; 向网络协议提供统一的数据包发送接口&#xff0c;上层任何形式的协议都通过dev_queue_xmit()发送&#xff0c;通过netif_rx()接收&#xf…

一种用于IDC机房数据挖掘的应用实现

&#xff08;作者单位&#xff1a;华北石油通信有限公司&#xff09;摘要&#xff1a;介绍了适用于数据中心可预定义、自定义场景的轻量级应用实现。现实中监测系统的数据大多沉淀在数据库中&#xff0c;且获取不同设备的信号数据并把这些数据展示出来&#xff0c;多受检测系统…

LabVIEW更高的吞吐量与更少的延迟A

LabVIEW更高的吞吐量与更少的延迟1在设计系统时&#xff0c;“速度”有两个含义。“需要多快采集样品&#xff1f;”通常转化为吞吐量。“样本后需要多快获得结果&#xff1f;”通常转化为延迟。在大多数测量或控制应用中&#xff0c;目标是将真实世界的数据从信号中获取到某种…

LeetCode哈希表相关解法

哈希表1. 理论哈希碰撞的解决方法拉链法线性探测法2. 有效的字母异位词[242. 有效的字母异位词](https://leetcode.cn/problems/valid-anagram/)3. 两个数组的交集[349. 两个数组的交集](https://leetcode.cn/problems/intersection-of-two-arrays/)4. 快乐数[202. 快乐数](htt…

16_tomcat

tomcat 一、jsp一句话木马 这个东西网上百度就有 <%!class U extends ClassLoader {U(ClassLoader c) {super(c);}public Class g(byte[] b) {return super.defineClass(b, 0, b.length);}}public byte[] base64Decode(String str) throws Exception {try {Class clazz …

Coresight - HW Assisted Tracing on ARM

文章目录一、Introduction二、Acronyms and Classification2.1 Acronyms2.2 Classification三、Device Tree Bindings四、Framework and implementation五、Device Naming scheme六、Topology Representation七、How to use the tracer modules7.1 Using the sysFS interface7.…

如何实现RTMP协议

认识rtmp rtmp是Adobe公司出品的流媒体传输协议&#xff0c;它的全称是Real Time Messaging Protocol&#xff0c;是一个实时消息传输协议&#xff0c;学习RTMP一定要抓住 一个关键点&#xff1a;消息。 rtmp协议的原文可以在Adobe官网下载&#xff0c;内容十分精简&#xff…

用户身份管理(CIAM)如何帮助业务持续增长?|身份云研究院

精明的决策者很早就意识到&#xff0c;数字化转型的核心是为用户提供完善的“数字旅程”&#xff0c;这里的用户包括“员工”和“客户”&#xff0c;而“数字旅程”的核心则是持续提供优质的「数字用户体验&#xff08;DCX&#xff09;」。本文将主要探讨如何制定完善“客户数字…

window版Docker打包镜像并上传到服务器使用

背景&#xff1a;利用jmeter实现自动化进行线上监视&#xff0c;要部署于多台服务器上监视&#xff0c;为了节省时间&#xff0c;方便使用&#xff0c;最终决定使用docker将自动化脚本打包成镜像&#xff0c;这样只要服务器上安装docker环境&#xff0c;直接下载镜像就可以使用…

2023全新SF授权系统源码 V3.7全开源无加密版本

内容目录一、详细介绍二、效果展示1.部分代码2.效果图展示三、学习资料下载一、详细介绍 SF多应用综合验证授权系统 V4.0更新内容 采用ThinkPHP 6.0 EasyWebAdmin 支持自定义判断规则&#xff08;默认提供域名QQ机器码规则&#xff09; 支持在线充值&#xff0c;用户Api授权&…

(免费分享)springboot人事管理系统

基础环境&#xff1a;1. JDK:1.82. MySQL:5.73. Maven3.01. 核心框架&#xff1a;Spring Boot 2.2.13.RELEASE2. ORM框架&#xff1a;MyBatisPlus 3.1.23. 数据库连接池&#xff1a;Druid 1.2.84. 安全框架&#xff1a;Apache Shiro 1.8.05. 日志&#xff1a;SLF4J &#xff0c…

最近邻插值法

文章目录前言一、最近邻插值法二、代码实现总结本章节进入图像处理&#xff0c;利用python语言来实现各种图像处理的方法&#xff0c;从软件角度去理解图像处理方法&#xff0c;为后期的FPGA处理图像做准备。 前言 一、最近邻插值法 最近邻插值就是在目标像素点上插入离对应原…

界面控件DevExpress WinForm中文教程 - 如何应用Windows 11 UI?

DevExpress WinForm拥有180组件和UI库&#xff0c;能为Windows Forms平台创建具有影响力的业务解决方案。DevExpress WinForm能完美构建流畅、美观且易于使用的应用程序&#xff0c;无论是Office风格的界面&#xff0c;还是分析处理大批量的业务数据&#xff0c;它都能轻松胜任…

全网最详细的org.springframework.jdbc.UncategorizedSQLException的多种解决方法

文章目录1. 引出问题2. 分析问题3. 解决问题4. 解决该问题的其他方法4.1 方法14.2 方法24.3 方法34.4 方法4如果你遇到的问题不是我所遇到的问题&#xff0c;可以使用最下面的方法解决你遇到的这个错误。 1. 引出问题 今天在写“Mybatis-Plus中分页插件PaginationInterceptor…

利用Python读取外部数据文件

名字&#xff1a;阿玥的小东东 学习&#xff1a;python、c 主页&#xff1a;阿玥的小东东 目录 一、读取文本文件的数据 二、读取电子表格文件 三、读取统计软件生成的数据文件 不论是数据分析&#xff0c;数据可视化&#xff0c;还是数据挖掘&#xff0c;一切的一切全都是以…

java常用类: Arrays类的常用方法

java常用类型: Ineteger等包装类 String类&#xff0c;StringBuffer类和StringBuilder类 Math类及常用方法 System类及常用方法 Arrays类及常用方法 BigInteger类和BigDecimal类及常用方法 日期类Date类,Calender类和LocalDateTime类 文章目录ArraysArrays常用方法Arrays.sort(…

全排列问题的解题思路

假设有这么个正整数n&#xff0c;要求输出1到n的所有排列&#xff1f;   输入&#xff1a;3 输出&#xff1a;123&#xff0c;132&#xff0c;213&#xff0c;231&#xff0c;312&#xff0c;321 一、无脑循环求解&#xff1f; 拿到这个问题&#xff0c;当然我的第一个想法就…