Springboot + vue3 实现大文件上传方案:秒传、断点续传、分片上传、前端异步上传

参考:https://juejin.cn/post/6870837414852886542#heading-9

一般计算大文件的md5都是前端来做,因为如果后端来做,那得等到上传成功后才能计算md5值,并且读取的时间也很长。

为了解决文件大传输慢的问题,前端可以通过分片读取的方式来读取文件,并进行分片,将分片的数据传给后端,当传输完成后,由后端完成合并转码的操作。

tips:计算md5和分片传输本质都是分片来完成的,但不应该放一起或有关联,因为只有计算完md5值后我们才知道后端有没有这个大文件,从而实现秒传;有关联是指不应该把读取后的数据放到全局变量中,在分片传输时直接用不需要读,因为这样会导致客户端内存过大

实现了大文件上传的分片、合并、秒传、断点续传

前端
计算大文件md5

使用spark-md5来计算大文件的md5值,Spark-md5实现了在浏览器中对文件进行哈希计算,每次会将填充后的消息和64位表示原始消息长度的数拼接到一起,形成一个新的消息,这也就不会造成读取大文件的内存非常大,我们只需要对该大文件进行分片读并将读取到的分片数据给到 Spark-md5 帮我们计算

计算hash值
抽样计算hash值

代码和策略如下:

/*** 计算文件的hash值,计算的时候并不是根据所用的切片的内容去计算的,那样会很耗时间,我们采取下面的策略去计算:* 1. 第一个和最后一个切片的内容全部参与计算* 2. 中间剩余的切片我们分别在前面、后面和中间取2个字节参与计算* 这样做会节省计算hash的时间*/
const calculateHash = async (fileChunks: Array<{file: Blob}>) => {return new Promise(resolve => {const spark = new sparkMD5.ArrayBuffer()const chunks: Blob[] = []fileChunks.forEach((chunk, index) => {if (index === 0 || index === fileChunks.length - 1) {// 1. 第一个和最后一个切片的内容全部参与计算chunks.push(chunk.file)} else {// 2. 中间剩余的切片我们分别在前面、后面和中间取2个字节参与计算// 前面的2字节chunks.push(chunk.file.slice(0, 2))// 中间的2字节chunks.push(chunk.file.slice(CHUNK_SIZE / 2, CHUNK_SIZE / 2 + 2))// 后面的2字节chunks.push(chunk.file.slice(CHUNK_SIZE - 2, CHUNK_SIZE))}})const reader = new FileReader()reader.readAsArrayBuffer(new Blob(chunks))reader.onload = (e: Event) => {spark.append(e?.target?.result as ArrayBuffer)resolve(spark.end())}})
}
计算全量hash值
异步处理
//计算文件的md5值
const computeMd5 =   (fileItem) => {let file = fileItem.filelet blobSize = File.prototype.slice ||  File.prototype.mozSlice || File.prototype.webkitSlice //获取file对象的slice方法,确保在每个浏览器都能获取到const fileSize = file.sizelet chunks = computeChunks(fileSize)let currentChunkIndex = 0 //当前读到第几个分片,最开始为0let spark = new SparkMD5.ArrayBuffer();let fileReader = new FileReader();let loadNext = () => {let start = currentChunkIndex * chunkSizelet end = start + chunkSize >= fileSize ? fileSize : start + chunkSizefileReader.readAsArrayBuffer(blobSize.call(file, start, end))}loadNext()return new Promise((resolve, reject) => {let resultFile = getFileItemByUid(file.uid)fileReader.onload = (e) => {  //读取成功后调用spark.append(e.target.result)currentChunkIndex++if (currentChunkIndex < chunks) { //继续分片 let percent = Math.floor(currentChunkIndex / chunks * 100)resultFile.md5Progress = percentloadNext()}else{let md5 = spark.end()spark.destroy()resultFile.md5Progress = 100 resultFile.status = STATUS.uploading.valueresultFile.md5 = md5loadNext = nullresolve(resultFile.file.uid)}}fileReader.onerror = (e) =>{  //读取出错调用fileItem.md5Progress = 1fileItem.status = STATUS.fail.valueloadNext = nullresolve(resultFile.file.uid)}}).catch(error=>{loadNext = nullconsole.log(error);return null});
}
web workder 线程处理

这里我们使用js中的多线程worker来计算md5,因为计算md5值很耗费cpu并且会影响页面的性能同时它不能通过异步来解决效率问题,只能一段一段的读取、计算

//hash.js
import SparkMD5 from 'spark-md5'
import { ElMessage } from 'element-plus'const computeChunks = (totalSize, chunkSize) => {return Math.ceil(totalSize / chunkSize)
}
// 生成文件 hash
self.onmessage = e => {let { fileItem } = e.datalet file = fileItem.filelet blobSize = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice //获取file对象的slice方法,确保在每个浏览器都能获取到const fileSize = file.sizeconst chunkSize = 1024 * 1024 * 10  //每个分片10Mlet chunks = computeChunks(fileSize, chunkSize)let currentChunkIndex = 0 //当前读到第几个分片,最开始为0let spark = new SparkMD5.ArrayBuffer();let fileReader = new FileReader();let loadNext = () => {let start = currentChunkIndex * chunkSizelet end = start + chunkSize >= fileSize ? fileSize : start + chunkSizefileReader.readAsArrayBuffer(blobSize.call(file, start, end))}loadNext()fileReader.onload = (e) => {  //读取成功后调用spark.append(e.target.result)currentChunkIndex++if (currentChunkIndex < chunks) { //继续分片 let percent = Math.floor(currentChunkIndex / chunks * 100)self.postMessage({percentage: percent,});loadNext()} else {let md5 = spark.end()spark.destroy()self.postMessage({md5,percentage: 100});loadNext = nullself.close(); // 关闭 worker 线程,线程如果不关闭,则会一直在后台运行着,}}fileReader.onerror = (e) => {  //读取出错调用console.log(e);self.close();ElMessage.error('读取文件出错')loadNext = null}
};

主线程代码:

let worker = new Worker(new URL('./hash.js', import.meta.url))worker.postMessage({ fileItem: fileItem })fileItem = getFileItemByUid(fileItem.file.uid)worker.onmessage = function (event) {const { md5, percentage } = event.datafileItem.md5Progress = percentageif (md5 != null) {fileItem.status = STATUS.uploading.valuefileItem.md5 = md5callBack(fileItem.file.uid)}}worker.onerror = function (event) {console.log(event);worker.terminate()  //出错后关闭子线程}

主线程负责开启子线程并给出文件信息,及时拿到子线程计算的结果即可

分片传输

根据分片结果异步调用后台分片接口实现分片传输

后端
 /*** 分片上传接口*/@PostMapping("/common/uploadFile")public BaseResponse<UploadFileInfoResp> commonUploadFile(MultipartFile multipartFile,String fileMd5,  //使用md5值来命名临时目录Integer chunkIndex, //当前是第几个分片Integer chunks  //总共有多少个分片){if(chunkIndex >= chunks){return ResultUtils.error(CommonErrorEnum.BUSINESS_ERROR.getErrorCode(),"当前分片大于等于总分片");}UploadFileInfoResp uploadFileInfoResp = new UploadFileInfoResp();File tempFileFolder = null;boolean fileIsSuccess = true;  // 文件是否上传成功标志,默认成功try{//暂存临时目录String tempFolderName = filePath + tempFolder;tempFileFolder = new File(tempFolderName+fileMd5);if(!tempFileFolder.exists()){  //创建该目录tempFileFolder.mkdirs();}File newFile = new File(tempFileFolder.getPath() + "/" + chunkIndex);if(newFile.exists() && newFile.length() == multipartFile.getSize()){  //断点续传,不需要在重新上传uploadFileInfoResp.setFileStatus(UploadStatusEnum.UPLOADING.getStatus());return ResultUtils.success(uploadFileInfoResp);}multipartFile.transferTo(newFile);uploadFileInfoResp.setFileStatus(UploadStatusEnum.UPLOADING.getStatus());return ResultUtils.success(uploadFileInfoResp);}catch (Exception e){log.error("文件上传失败 ",e);fileIsSuccess = false;}finally {if(!fileIsSuccess && Objects.nonNull(tempFileFolder)){  // 失败,删除临时目录FileUtil.del(tempFileFolder);}}return ResultUtils.success(uploadFileInfoResp);}

后端根据前端的分片信息和数据建一个临时目录,目录以计算的MD5值为命名,并将前端传输的分片的文件先上传到服务器上

前端

前端根据分片大小对大文件进行分片,得到总共有多少片,循环异步向后端发送分片上传请求

const chunkSize = 1024 * 1024 * 10  //每个分片10M
const computeChunks = (totalSize) => {return Math.ceil(totalSize / chunkSize)
}
//执行分片上传逻辑
const uploadFile = async (fileUid, fromChunkIndex) => {let fileItem = getFileItemByUid(fileUid)if (fileItem == undefined) returnif (fromChunkIndex == null) {  //如果不是点击暂停继续上传const secondResult = await queryUploadFileApi(fileItem.md5)if (secondResult != null && secondResult.fileStatus == STATUS.upload_seconds.value) {  //秒传fileItem.status = STATUS[secondResult.fileStatus].valuefileItem.uploadProgress = 100return;}}let chunkIndex = fromChunkIndex || 0let file = fileItem.filelet fileSize = file.size//分片上传let chunks = computeChunks(fileSize)const taskPool = []const maxTask = 6 //最大异步处理数量for (let i = chunkIndex; i < chunks; i++) {fileItem = getFileItemByUid(fileUid)if (fileItem == null || fileItem.pause) { //处理删除或暂停逻辑await Promise.all(taskPool)if(fileItem != null)recordAllStopChunkIndex.push({uid: file.uid,chunkIndex: i})  //记录暂停的具体信息return;}let start = i * chunkSizelet end = start + chunkSize >= fileSize ? fileSize : start + chunkSizelet chunkFile = file.slice(start, end)const task = uploaderFileApi({file: chunkFile, chunkIndex: i, chunks: chunks, fileMd5: fileItem.md5})task.then(res => {if (res.code == undefined && res.fileStatus === 'uploading' && fileItem != null) {  //计算上传速度fileItem.uploadSize = fileItem.uploadSize + chunkFile.sizefileItem.uploadProgress = Math.floor((fileItem.uploadSize / fileSize) * 100)}taskPool.splice(taskPool.findIndex((item) => item === task),1)  //清除已经完成的分片请求}).catch(error => { console.log(error); taskPool.splice(taskPool.findIndex((item) => item === task),1) })taskPool.push(task)if (taskPool.length >= maxTask) {await Promise.race(taskPool)  //race方法会在这些请求中第一个完成后结束这个阻塞代码,限制最大请求数量}}await Promise.all(taskPool)   //将剩下的分片发送并等待所有分片完成
}

因为这里用到了异步请求,那么就需要控制异步请求的最大并发数量,否则就会因为异步请求过多而对页面造成卡顿,我们可以使用异步的race函数,当有一个请求完成后就返回,循环结束后最后在等待未完成的分片上传完毕就可以执行合并操作了

合并
后端

后端根据临时目录的所有分片数据将他们进行合并,操作:依次按分片文件名从小到大读取每个分片文件,并将它写入到一个新的文件里

分片的文件名是按分片的顺序命名的

/*** 合并分片文件接口*/@PostMapping("/union/uploadFile")public BaseResponse<String> unionFile(@Valid UnionFileReq unionFileReq){ //合并文件String tempFolderName = null;try {String fileName = unionFileReq.getFileName();String fileMd5 = unionFileReq.getFileMd5();//文件夹路径String monthDay = DateUtil.format(new Date(),"yyyy-MM-dd");//真实的文件名String realFileName = IdUtils.simpleUUID() + MimeTypeUtils.getSuffix(fileName);//路径String newFilePath = monthDay +  "/" + realFileName;//临时目录tempFolderName = filePath + tempFolder + fileMd5;//目标目录String targetFolderName = filePath + monthDay;File targetFolder = new File(targetFolderName);if(!targetFolder.exists()){targetFolder.mkdirs();}//目标文件String targetFileName = filePath + newFilePath;union(tempFolderName,targetFileName,realFileName,true);//合并成功,可以在这里记录数据库}catch (Throwable e){if(Objects.nonNull(tempFolderName)){  // 删除临时目录FileUtil.del(tempFolderName);}return ResultUtils.error(ErrorCode.OPERATION_ERROR);}return ResultUtils.success("ok");}public void union(String dirPath, String toFilePath, String fileName, Boolean del){File dir = new File(dirPath);if(!dir.exists()){throw new BusinessException("目录不存在");}File[] fileList = dir.listFiles(); //获取该目录的所有文件// 按文件名从小到大排序,确保合并顺序正确Arrays.sort(fileList, new Comparator<File>() {@Overridepublic int compare(File file1, File file2) {Integer value1 = Integer.valueOf(file1.getName());Integer value2 = Integer.valueOf(file2.getName());return value1 - value2;}});File targetFile = new File(toFilePath);RandomAccessFile writeFile = null;try{writeFile = new RandomAccessFile(targetFile,"rw");byte [] b = new byte[10 * 1024] ;//一次读取1MBfor (int i = 0; i < fileList.length; i++) {File chunkFile = new File(dirPath + "/" +i);RandomAccessFile readFile = null;try {readFile = new RandomAccessFile(chunkFile,"r");while ( readFile.read(b) != -1 ){writeFile.write(b,0, b.length);}}catch (Exception e){log.error("合并分片失败",e);throw  new BusinessException("合并分片失败");}finally {if(Objects.nonNull(readFile))readFile.close();}}} catch (Exception e) {log.error("合并文件:{}失败",fileName,e);throw new RuntimeException(e);}finally {try {if(Objects.nonNull(writeFile))writeFile.close();if(del && dir.exists()){  // 删除临时目录FileUtil.del(dir);}} catch (IOException e) {e.printStackTrace();}}}
前端

在等待所有分片上传完成后调用后端合并接口即可

也就是在原有上传函数在加上一个调用合并接口操作

//所有分片上传完成,执行合并操作unionUploadFileApi({ fileName: file.name, fileMd5: fileItem.md5,pid: fileItem.filePid }).then(res => {if (res == "ok") {fileItem.uploadProgress = 100fileItem.status = STATUS["upload_finish"].value} else {fileItem.status = STATUS["fail"].valuefileItem.errorMsg = res.message}}).catch(error => { console.log(error); fileItem.status = STATUS["fail"].value; fileItem.errorMsg = '合并失败' })
秒传
后端

根据md5去查询数据库有没有上传过即可

    /*** 查询大文件的md5值,秒传逻辑*/@PostMapping("/query/uploadFile")public BaseResponse<UploadFileInfoResp> queryUploadFile(String fileMd5){UploadFileInfoResp uploadFileInfoResp = new UploadFileInfoResp();UploadFile queryByMd5 = uploadFileDao.queryByMd5(fileMd5);//秒传if(Objects.nonNull(queryByMd5)){uploadFileInfoResp.setFileStatus(UploadStatusEnum.UPLOAD_SECONDS.getStatus());return ResultUtils.success(uploadFileInfoResp);}return ResultUtils.success(uploadFileInfoResp);}
前端

上文的代码中已经实现了秒传,在计算md5后最开始的地方调用后端秒传接口即可

        const secondResult = await queryUploadFileApi(fileItem.md5)if (secondResult != null && secondResult.fileStatus == STATUS.upload_seconds.value) {  //秒传fileItem.status = STATUS[secondResult.fileStatus].valuefileItem.uploadProgress = 100return;}
断点续传

断点续传就是用户已经上传过的分片不需要上传,直接返回即可。

我们的临时目录是以文件的MD5值命名的,分片文件的命名逻辑也一样,所以如果服务器中有该文件,并且它的文件大小跟要分片的文件大小一样,那就一定是上传成功的,逻辑分片上传的接口已经实现了,如下:

            File newFile = new File(tempFileFolder.getPath() + "/" + chunkIndex);if(newFile.exists() && newFile.length() == multipartFile.getSize()){  //断点续传,不需要在重新上传uploadFileInfoResp.setFileStatus(UploadStatusEnum.UPLOADING.getStatus());return ResultUtils.success(uploadFileInfoResp);}

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

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

【Linux】ChatGLM-4-9B模型之All Tools

一、摘要 最近在研究GLM4模型&#xff0c;发现自带的All Tools比较感兴趣&#xff0c;它具有完整工具调用能力的对话模式&#xff0c;原生支持网页浏览、代码执行、图表生成、图片生成&#xff0c;并支持自定义工具。它能够满足大模型私有化部署的个性定制&#xff0c;因此记录…

Vue零基础必学教程(16) 计算属性

往期内容&#xff1a; Vue零基础必学教程&#xff08;5&#xff09;挂载 Vue零基础必学教程&#xff08;6&#xff09;基本选项 Vue零基础必学教程&#xff08;7&#xff09;模板 Vue零基础必学教程&#xff08;8&#xff09;模板语法 Vue零基础必学教程&#xff08;9&…

14:30面试,14:08就出来了,面试问的有点变态呀。。。

从小厂出来&#xff0c;没想到在另一家公司又寄了。 到这家公司开始上班&#xff0c;加班是每天必不可少的&#xff0c;看在钱给的比较多的份上&#xff0c;就不太计较了。没想到一纸通知&#xff0c;所有人不准加班&#xff0c;加班费不仅没有了&#xff0c;薪资还要降40%,这…

【Leetcode】1705. 吃苹果的最大数目

文章目录 题目思路代码复杂度分析时间复杂度空间复杂度 结果总结 题目 题目链接&#x1f517; 有一棵特殊的苹果树&#xff0c;一连 n n n 天&#xff0c;每天都可以长出若干个苹果。在第 i i i 天&#xff0c;树上会长出 a p p l e s [ i ] apples[i] apples[i] 个苹果&a…

kimi搜索AI多线程批量生成txt原创文章软件-不需要账号及key

kimi搜索AI多线程批量生成txt原创文章软件介绍&#xff1a; 软件可以设置三种模型写文章&#xff1a;kimi&#xff1a;默认AI模型&#xff0c;kimi-search&#xff1a;联网检索模型 &#xff0c;kimi-research&#xff1a;探索版搜索聚合模型 1、可以设置写联网搜索文章&#…

游戏引擎学习第58天

发现一个vscode Log 断点的用法 回顾 我们正在继续推进工作&#xff0c;之前做了一些测试和清理工作&#xff0c;但还有一件事没有完成&#xff0c;因此我们还没有完全回到功能平衡的状态。昨天我们已经为实体做了空间划分&#xff0c;所以接下来的目标是继续完成这部分工作&a…

day14-16系统服务管理和ntp和防火墙

一、自有服务概述 服务是一些特定的进程&#xff0c;自有服务就是系统开机后就自动运行的一些进程&#xff0c;一旦客户发出请求&#xff0c;这些进程就自动为他们提供服务&#xff0c;windows系统中&#xff0c;把这些自动运行的进程&#xff0c;称为"服务" window…

Idea导入Springboot项目,无法正确加载yml文件,且不为绿色图标的解决办法

一、出现问题的环境 将项目复制新的环境后&#xff0c;.yml 文件不能显示为绿色&#xff0c;导致无法配置数据库。 二、解决办法。 在网上找了多种办法&#xff0c;并不适用&#xff0c;发现resources的显示也有问题&#xff0c;右击resources->Mark->Directory as -&g…

以太网通信--读取物理层PHY芯片的状态

PHY芯片通过MDIO接口进行读写&#xff0c;框图如下所示&#xff1a; 原理很简单&#xff0c;就是按照时序将PHY芯片的指定寄存器信息读出或者写入。 MDC时钟需要输出到PHY芯片&#xff0c;一般不低于80MHz。 MDIO是双向接口&#xff0c;FPGA读出状态信息时为输入&#xff0c;FP…

Doris Tablet 损坏如何应对?能恢复数据吗?

开门见山&#xff0c;能不能修&#xff1f; Doris 的 Tablet 损坏了&#xff0c;到底能不能修呢&#xff1f;数据会不会丢&#xff1f; 这玩意还真不好说&#xff1f; 哎&#xff0c;怎么又不好说了呢&#xff1f; 这个主要是因为下面的原因&#xff1a; Doris 数据的高可…

【Linux】查询磁盘空间被谁占用了

查询磁盘空间被谁占用了 先说下常见的几种原因&#xff1a; 1、删除的文件未释放空间 2、日志或过期文件未及时清理 3、inode导致 4、隐藏文件夹或者目录 6、磁盘碎片 最后一种单独介绍。 环境&#xff1a;情况是根分区&#xff08;/&#xff09;的总容量为44GB&#xf…

Scala课堂小结

(一)数组&#xff1a; 1.不可变数组 2创建数组

GitPuk安装配置指南

GitPuk是一款开源免费的代码管理工具&#xff0c;上篇文章已经介绍了Gitpuk的功能与优势&#xff0c;这篇文章将为大家讲解如何快速安装和配置GitPuk&#xff0c;助力你快速的启动GitPuk管理代码 1. 安装 支持 Windows、Mac、Linux、docker 等操作系统。 1.1 Windows安装 下载…

大恒相机开发(2)—Python软触发调用采集图像

大恒相机开发&#xff08;2&#xff09;—Python软触发调用采集图像 完整代码详细解读和功能说明扩展学习 这段代码是一个Python程序&#xff0c;用于从大恒相机采集图像&#xff0c;通过软件触发来采集图像。 完整代码 咱们直接上python的完整代码&#xff1a; # version:…

本科阶段最后一次竞赛Vlog——2024年智能车大赛智慧医疗组准备全过程——12使用YOLO-Bin

本科阶段最后一次竞赛Vlog——2024年智能车大赛智慧医疗组准备全过程——12使用YOLO-Bin ​ 根据前面内容&#xff0c;所有的子任务已经基本结束&#xff0c;接下来就是调用转化的bin模型进行最后的逻辑控制了 1 .YOLO的bin使用 ​ 对于yolo其实有个简单的办法&#xff0c;也…

Golang的容器化技术实践总结

Golang的容器化技术实践总结 一、容器化技术概述 什么是容器化技术 容器化技术是一种轻量级、可移植的虚拟化解决方案&#xff0c;它将应用程序、运行环境和依赖项打包到一个被称为容器的独立单元中。容器可以在不同的操作系统中运行&#xff0c;具有更高的资源利用率和更快的部…

修改el-select下拉框高度;更新:支持动态修改

文章目录 效果动态修改&#xff1a;效果代码固定高度版本动态修改高度版本&#xff08;2024-12-25 更新&#xff1a; 支持动态修改下拉框高度&#xff09; 效果 动态修改&#xff1a;效果 代码 固定高度版本 注意点&#xff1a; popper-class 尽量独一无二&#xff0c;防止影…

运动控制卡网络通讯的心跳检测之C#上位机编程

本文导读 今天&#xff0c;正运动小助手给大家分享一下如何使用C#上位机编程实现运动控制卡网络通讯的心跳检测功能。 01 ECI2618B硬件介绍 ECI2618B经济型多轴运动控制卡是一款脉冲型、模块化的网络型运动控制卡。控制卡本身最多支持6轴&#xff0c;可扩展至12轴的运动控制…

自动控制系统综合与LabVIEW实现

自动控制系统综合是为了优化系统性能&#xff0c;确保其可靠性、稳定性和灵活性。常用方法包括动态性能优化、稳态误差分析、鲁棒性设计等。结合LabVIEW&#xff0c;可以通过图形化编程、高效数据采集与处理来实现系统综合。本文将阐述具体方法&#xff0c;并结合硬件选型提供实…

lxml提取某个外层标签里的所有文本

html如下 <div data-v-1cf6f280"" class"analysis-content">选项D错误&#xff1a;<strong>在衡量通货膨胀时&#xff0c;</strong><strong>消费者物价指数使用得最多、最普遍</strong>。 </div> 解析html文本 fro…