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值,跟后端交互查询文件是否已上传,再根据情况将未上传的分片上传。
javascript
<template>
<div class="container">
<el-upload
class="upload-demo"
drag
multiple
action="/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,//100MB
percentage: 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 = 100
this.$message({
message: '文件已上传!',
type: 'warning'
});
return
}
startIndex = res.data.data.startIndex
this.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值,所以,如果只是修改了文件名称,计算出来的值是一样的,会认为是同一个文件。
java
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/";
@Autowired
private 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 类
java
@Data
@Builder(toBuilder = true)
@NoArgsConstructor
@AllArgsConstructor
public class Result {
private int code;
private String msg;
private ResultData data;
}
ResultData 类
java
@Data
@Builder(toBuilder = true)
@NoArgsConstructor
@AllArgsConstructor
public class ResultData {
private int startIndex;
}
SysFilePo类
java
@Data
@Builder(toBuilder = true)
@NoArgsConstructor
@AllArgsConstructor
public class SysFilePo {
private String md5;
private String fileName;
private String fileType;
private int chunkIndex;
}
xml
<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>
数据库脚本
可根据自身情况修改及增加主键。
sql
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 单独开线程去计算;问题二,就是一开始提到的,分片太多的时候,我们一个个上传太耗时,效率低,我们需要使用并发请求,这两个解决方案会放到下片文章。再次强调,前后端代码写的逻辑性不强,只为展示基础的用法,在实际使用时,可基于实际需求加以优化。后端代码部分为测试所用,已标注,请注意删除!!!