当前位置: 首页 > news >正文

SpringBoot+Vue实现大文件上传(断点续传-后端控制(一))

SpringBoot+Vue实现大文件上传(断点续传)

1 环境 SpringBoot 3.2.1,Vue 2,ElementUI,spark-md5
2 问题 在前一篇文章,我们写了通过在前端控制的断点续传,但是有两个问题,第一个问题:如果上传过程中,页面意外关闭或者其他原因,导致上传者不知道该文件是否上传成功,则会重复上传;第二个问题,我们将文件分片后,如果分片较多,我们一个一个的上传文件块,效率还是比较低。
3方案 基于前面的问题分析,我们可以将部分判断改到后端。针对第一个问题,我们可以保存每个分片的信息,如果下次再上传相同的文件时发现文件已存在且分片全部上传时,则可直接跳过,存在分片未全部上传时,返回未上传的分片下标;第二个问题,我们前端不再采用异步上传,而是多个分片同时上传,可以较高提升上传速度。本文我们先看下第一个问题怎么解决。

效果图
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
这里我们计算文件MD5值用的是spark-md5,首先需要在控制台执行安装命令:npm install --save spark-md5
然后在需要用的文件里引入:import SparkMD5 from "spark-md5";

前端代码
前端主要做的就是计算文件md5值,跟后端交互查询文件是否已上传,再根据情况将未上传的分片上传。

<template><div class="container"><el-uploadclass="upload-demo"dragmultipleaction="/xml/fileUpload":on-change="handleChange":auto-upload="false"><i class="el-icon-upload"></i><div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div><div class="clearfix"></div><div class="el-upload__tip"><el-progress :style="{ width: percentage + '%' }" :text-inside="true":stroke-width="24":percentage="percentage" :status="uploadStatus"></el-progress></div></el-upload><el-button style="margin-left: 10px;" size="small" type="success" @click="submitUpload">上传到服务器</el-button></div>
</template><script>
import axios from "axios";
import SparkMD5 from "spark-md5";export default {name: 'App',data() {return {file: '',fileList: [],CHUNK_SIZE: 1024 * 1024 * 5,//100MBpercentage: 0,chunkNo: 0,uploadStatus: ''}},watch: {},created() {},methods: {async fileHash(file) {// 1 第一种 计算文件的md5值,可以基于文件的一些基本属性来计算,不过这个存在的问题很明显,就是如果改了内容而文件大小不变的情况下,算出来的md5是一样的,优点就是计算速度快// const fileName = file.name// const fileType = file.name.substring(file.name.lastIndexOf(".") + 1, file.name.length);// const fileSize = file.size// console.log(SparkMD5.hash(fileName + fileType + fileSize))//--------------------------------------------------------------------// 2 第二种读取文件内容来计算,这种方式的优点就是只要文件内容改了算出来的md5值就不一样,缺点就是如果文件太大,一次性读到内存中计算的话会占内存,可能造成卡顿,计算速度相对较慢,// 我们可以增量计算,先计算第一个文件块的hash值,再将这个值和第二个文件块一起计算,如此下去,最终获取整个文件的hash,这样每次只读取一个文件块到内存中//注意此处不能直接在循环里写读取文件,那样会报错:Uncaught (in promise) DOMException: Failed to execute 'readAsArrayBuffer' on 'FileReader': The object is already busy reading Blobs.// 原因:因为每次循环中很快地连续调用 readAsArrayBuffer ,可能上一次的读取还未完成,新的调用就来了,导致冲突。//下面两种写法,一种是封装成异步的,一种是递归调用const totalChunks = Math.ceil(file.size / this.CHUNK_SIZE);const spark = new SparkMD5();for (let i = 0; i < totalChunks; i++) {await new Promise((resolve) => {const start = i * this.CHUNK_SIZE;const end = Math.min(start + this.CHUNK_SIZE, file.size);const chunk = file.slice(start, end);const fileReader = new FileReader();// reader.onload 是为 FileReader 对象的 load 事件添加一个回调函数。当文件读取完成后,这个回调会被触发。// e 是事件对象。// e.target.result 获取到的就是读取文件后得到的结果数据,在这里是一个 ArrayBuffer 对象,它包含了文件的二进制数据。// 将 reader.onload 的处理逻辑写在读取文件操作之前// 这样做是为了提前定义好当文件读取完成这个事件发生时要执行的具体动作。在执行 reader.readAsArrayBuffer(file) 开始读取文件后,一旦读取完成,就会触发 onload 事件,从而执行之前定义好的回调函数。// 如果把这个处理逻辑放在后面,可能会导致在需要使用读取结果时,还没有正确地设置好处理的方式。// 先设置好回调,再触发相关操作,能确保整个流程的逻辑顺序和正确性。fileReader.onload = (e) => {//读取的字节数组const bytes = e.target.result;//增量计算spark.append(bytes);// resolve() 用于在异步操作完成时通知 Promise 状态变为已完成(fulfilled)//当文件读取的 onload 事件触发,表示当前这一块数据读取完成,此时调用 resolve() 来让等待这个 Promise 的后续代码知道可以继续进行下一步操作了。这样就实现了对异步读取过程的有序控制。resolve();};//读取文件块内容fileReader.readAsArrayBuffer(chunk);});}return spark.end()//---------------------------------------------------------------------------------//递归调用的写法// return new Promise((resolve) => {//   const spark = new SparkMD5();//   const totalChunks = Math.ceil(file.size / this.CHUNK_SIZE);////   function hash(index, CHUNK_SIZE) {//     if (index >= totalChunks) {//       //返回最终的结果 在调用的地方获取值:const result = await hash(); result 就是最后的md5值//       resolve(spark.end());//       return//     }//     const start = index * CHUNK_SIZE;//     const end = Math.min(start + CHUNK_SIZE, file.size);//     const chunk = file.slice(start, end);//     const read = new FileReader();//     read.onload = (e) => {//       //读取的字节数组//       const bytes = e.target.result//       spark.append(bytes)//       //递归调用//       hash(index + 1,)//     }//     read.readAsArrayBuffer(chunk)//   }////   //开始第一次计算//   hash(0, this.CHUNK_SIZE)// })},async submitUpload() {//获取上传的文件信息const file = this.fileList[0].raw//生成md5值const md5 = await this.fileHash(file)console.log("文件MD5值:" + md5)const totalChunks = Math.ceil(file.size / this.CHUNK_SIZE);let startIndex = 0;const res = await axios.get('/xml/checkMD5?md5='+md5+'&totalChunks='+totalChunks)if(res.data.code === 200){if(res.data.data.startIndex<0){this.percentage = 100this.$message({message: '文件已上传!',type: 'warning'});return}startIndex = res.data.data.startIndexthis.percentage = Math.ceil(startIndex / totalChunks * 100)}else {this.$message({message: '上传失败,请重试!',type: 'error'});return}//分片this.uploadStatus = 'success'for (let i = startIndex; i < totalChunks; i++) {const start = i * this.CHUNK_SIZE;const end = Math.min(start + this.CHUNK_SIZE, file.size);//将文件切片const chunk = file.slice(start, end);//组装参数const formData = new FormData();formData.append('file', chunk);formData.append('fileName', file.name);formData.append('md5', md5);formData.append('index', i);formData.append('status', 0);try {const res = await axios.post('/xml/bigFileUpload', formData)if (res.data.code === 200) {this.percentage = Math.ceil((i + 1) / totalChunks * 100)this.chunkNo = i + 1} else {this.$message({message: '上传失败',type: 'error'});this.errText = '失败'this.uploadStatus = 'exception'return}} catch (err) {console.log(err);this.$message.error('上传失败');this.uploadStatus = 'exception'return}}//调用合并分片请求await fetch('/xml/merge', {method: 'POST',body: JSON.stringify({fileName: file.name}),headers: {'Content-Type': 'application/json'}});this.$message({message: '文件上传成功!',type: 'success'});},handleChange(file, fileList) {this.fileList = fileList},}
}
</script><style>
.container {display: flex;
}.progress-bar {position: absolute;height: 100%;background-color: #03f80d;transition: width 0.5s ease; /* 平滑过渡效果 */
}.progress-number {position: absolute;right: 5px;top: 0;color: white;transition: opacity 0.5s ease; /* 文字的平滑过渡效果 */
}
</style>

后端代码

后端代码相较之前的多了一步,就是在分片上传时保存分片的一些信息。
如整个文件的md5值、文件名称、文件类型、分片导入时间等,可以根据需要另外增加字段。主要的逻辑就是,首先在前端计算文件的md5,然后跟后端交互,查询该文件是否已上传过,上传过多少分片,如果上传分片的条数跟前端计算的分片数一样,返回-1,否则返回上传分片的条数,前端根据这个数判断是否还需要上传、从哪个分片开始上传,这样就避免了上传过的分片重复上传。
注意:我这里采用的是通过文件内容来计算md5值,所以,如果只是修改了文件名称,计算出来的值是一样的,会认为是同一个文件。

package org.wjg.onlinexml.controller;import io.micrometer.common.util.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.wjg.onlinexml.po.Result;
import org.wjg.onlinexml.po.ResultData;
import org.wjg.onlinexml.po.SysFilePo;
import org.wjg.onlinexml.service.SysFileService;import java.io.File;
import java.io.FileOutputStream;
import java.nio.file.Files;
import java.util.Map;@RestController
public class BigFileControll {// 获取资源文件夹的路径,路径为 项目所在路径/upload/private static final String UPLOAD_DIR = System.getProperty("user.dir") + "/upload/";@Autowiredprivate SysFileService sysFileService;private static String getSuffix(String filePath) {int dotIndex = filePath.lastIndexOf('.');if (dotIndex > 0 && dotIndex < filePath.length() - 1) {return filePath.substring(dotIndex + 1);}return "";}/*** 保存分片** @param file* @param fileName* @param index* @return*/@RequestMapping("/bigFileUpload")private Result bigFileUpload(@RequestParam("file") MultipartFile file, @RequestParam("fileName") String fileName,@RequestParam("md5") String md5, @RequestParam("index") int index, @RequestParam("status") int status) {if (file.isEmpty()) {return Result.builder().code(500).msg("上传失败!").build();}File uploadDir = new File(UPLOAD_DIR);if (!uploadDir.exists()) {uploadDir.mkdirs();}File uploadFile = new File(UPLOAD_DIR + fileName + "_" + index);try {//模拟上传中断-----------------------if (status == 1) {if (index == 2) {return Result.builder().code(500).msg("上传失败").build();}}//-------------------结束------------------file.transferTo(uploadFile);SysFilePo sysFile = SysFilePo.builder().fileName(fileName).fileType(getSuffix(fileName)).md5(md5).chunkIndex(index).build();sysFileService.insert(sysFile);} catch (Exception e) {e.printStackTrace();return Result.builder().code(500).msg("上传失败").build();}return Result.builder().code(200).msg("上传成功").build();}/*** 合并分片** @param request* @return*/@PostMapping("/merge")public Result mergeChunks(@RequestBody Map<String, String> request) {String filename = request.get("fileName");File mergedFile = new File(UPLOAD_DIR + filename);try (FileOutputStream fos = new FileOutputStream(mergedFile)) {//循环获取分片,直到分片不存在为止for (int i = 0; ; i++) {File chunkFile = new File(UPLOAD_DIR + filename + "_" + i);if (!chunkFile.exists()) {break;}//将分片复制到一个文件中,这种方法会追加Files.copy(chunkFile.toPath(), fos);//删除分片chunkFile.delete();}} catch (Exception e) {return Result.builder().code(500).msg("合并失败").build();}return Result.builder().code(200).msg("合并成功").build();}@GetMapping("/checkMD5")public Result mergeChunks(@RequestParam("md5") String md5, @RequestParam("totalChunks") int totalChunks) {if (StringUtils.isBlank(md5)) {return Result.builder().code(500).msg("上传的文件md5值为空!").build();}try {int startIndex = sysFileService.checkMD5(md5, totalChunks);return Result.builder().code(200).msg("MD5校验成功").data(ResultData.builder().startIndex(startIndex).build()).build();} catch (Exception e) {e.printStackTrace();return Result.builder().code(500).msg("校验文件md5值出错!").build();}}
}

Result 类

@Data
@Builder(toBuilder = true)
@NoArgsConstructor
@AllArgsConstructor
public class Result {private int code;private String msg;private ResultData data;
}

ResultData 类

@Data
@Builder(toBuilder = true)
@NoArgsConstructor
@AllArgsConstructor
public class ResultData {private int startIndex;
}

SysFilePo类

@Data
@Builder(toBuilder = true)
@NoArgsConstructor
@AllArgsConstructor
public class SysFilePo {private String md5;private String fileName;private String fileType;private int chunkIndex;
}
    <select id="checkMD5" resultType="java.lang.Integer">select count(0) from sys_file<where><if test="md5!=null and md5 !=''">md5 = #{md5,jdbcType=VARCHAR}</if></where></select><insert id="insert">insert into sys_file(md5, file_name, file_type, chunk_index, insert_time, update_time)values (#{md5}, #{fileName}, #{fileType}, #{chunkIndex}, SYSDATE(), SYSDATE())</insert>

数据库脚本
可根据自身情况修改及增加主键。

CREATE TABLE `sys_file` (`md5` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '文件md5值',`file_name` varchar(255) DEFAULT NULL COMMENT '文件名称',`file_type` varchar(255) DEFAULT NULL COMMENT '文件类型',`chunk_index` int(11) DEFAULT NULL COMMENT '分片下标',`insert_time` datetime DEFAULT NULL COMMENT '插入时间',`update_time` datetime DEFAULT NULL COMMENT '更新时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

总结:本篇相较于上篇,将上传的分片信息保存在数据库中了,这样页面刷新或者上传相同的文件时,可避免重复上传已经上传过的。其实本篇的写法对于一般情况已经满足,不过如果是超大文件,好几个g或者几十g的文件,有点不适用。问题一,计算md5会耗时较长,我们可以使用 webWorker 单独开线程去计算;问题二,就是一开始提到的,分片太多的时候,我们一个个上传太耗时,效率低,我们需要使用并发请求,这两个解决方案会放到下片文章。再次强调,前后端代码写的逻辑性不强,只为展示基础的用法,在实际使用时,可基于实际需求加以优化。后端代码部分为测试所用,已标注,请注意删除!!!

相关文章:

  • 北京网站建设多少钱?
  • 辽宁网页制作哪家好_网站建设
  • 高端品牌网站建设_汉中网站制作
  • 卷积神经网络与小型全连接网络在MNIST数据集上的对比
  • 设计模式—2—单例模式
  • 基于 XILINX FPGA 的 Cameralink Full 模式相机采集系统技术实施方案研究报告
  • WebRTC协议下的视频汇聚融合技术:EasyCVR视频技术构建高效视频交互体验
  • 计算机网络(八股文)
  • 微信小程序rpx和px关系
  • 系统调用之文件操作详解
  • 【科研达人3个月搞定SCI论文:计算机视觉研究的实用计划】
  • 2024高教社杯数学建模国赛ABCDE题选题建议+初步分析
  • 【系统】Linux系统下载 Ubuntu/Debian/Deepin
  • python-Flask搭建简易登录界面
  • C#读取Excel的方法总结
  • Python函数的编写
  • Leetcode22括号生成(java实现)
  • 5个自动化测试用例设计的原则
  • 【译】JS基础算法脚本:字符串结尾
  • JavaScript 如何正确处理 Unicode 编码问题!
  • bootstrap创建登录注册页面
  • codis proxy处理流程
  • gulp 教程
  • Javascript编码规范
  • Laravel Telescope:优雅的应用调试工具
  • ng6--错误信息小结(持续更新)
  • Promise面试题2实现异步串行执行
  • Shadow DOM 内部构造及如何构建独立组件
  • Vue.js-Day01
  • 读懂package.json -- 依赖管理
  • 开源中国专访:Chameleon原理首发,其它跨多端统一框架都是假的?
  • 来,膜拜下android roadmap,强大的执行力
  • 小程序开发中的那些坑
  • 一个普通的 5 年iOS开发者的自我总结,以及5年开发经历和感想!
  • JavaScript 新语法详解:Class 的私有属性与私有方法 ...
  • 带你开发类似Pokemon Go的AR游戏
  • ​sqlite3 --- SQLite 数据库 DB-API 2.0 接口模块​
  • ‌移动管家手机智能控制汽车系统
  • # Redis 入门到精通(八)-- 服务器配置-redis.conf配置与高级数据类型
  • (3)选择元素——(17)练习(Exercises)
  • (8)STL算法之替换
  • (二)【Jmeter】专栏实战项目靶场drupal部署
  • (附源码)springboot 房产中介系统 毕业设计 312341
  • (七)Knockout 创建自定义绑定
  • (切换多语言)vantUI+vue-i18n进行国际化配置及新增没有的语言包
  • (十八)三元表达式和列表解析
  • (算法)Game
  • (转)Android学习系列(31)--App自动化之使用Ant编译项目多渠道打包
  • .Net 访问电子邮箱-LumiSoft.Net,好用
  • .NET 实现 NTFS 文件系统的硬链接 mklink /J(Junction)
  • .NET/C# 中设置当发生某个特定异常时进入断点(不借助 Visual Studio 的纯代码实现)
  • ::
  • @FeignClient 调用另一个服务的test环境,实际上却调用了另一个环境testone的接口,这其中牵扯到k8s容器外容器内的问题,注册到eureka上的是容器外的旧版本...
  • @Transactional类内部访问失效原因详解
  • [000-002-01].数据库调优相关学习
  • [ERR] 1273 - Unknown collation: ‘utf8mb4_0900_ai_ci‘(已解决)
  • [IE技巧] 如何关闭Windows Server版IE的安全限制
  • [jobdu]不用加减乘除做加法