细说分片上传与极速秒传(SpringBoot+Vue实现)
connygpt 2024-12-18 14:55 4 浏览
预期目标
- 目标:需要突破服务端上传大小限制,实现大视频文件的上传
- 预期:大视频文件上传不受上传大小的限制
评估结果
要想实现大文件上传有两种方式:
1)调大服务端的文件上传限制:在一定长度上可以缓解上传限制问题,但并不是最优解。一方面无限制地调大上传大小会加大服务端的压力;一方面这个限制值调成多少是个需要考量的问题。
2)假设服务端的限制是10M,需要上传的文件是20M,直接上传显然是不可以的,那么分两次呢?把文件切分到符合限制的大小分批发送,这样就可以突破限制,这也就是分片上传。
下面主要就分片上传的方案做阐述。
分片上传
前期准备
首先这里上传功能用antd的上传组件来实现,通过自定义上传动作来完成分片上传;并且做文件切片时需要记录下文件的 md5 信息,以便后续在服务端根据md5值来进行文件合并,这里需要用到spark-md5 库来做文件md5计算,同时使用的 axios 来发起请求,具体依赖如下:
依赖 | 版本 |
vue | ^3.0.0 |
ant-design-vue | ^2.2.8 |
axios | ^0.24.0 |
spark-md5 | ^3.0.2 |
1、前端逻辑
1)上传组件
首先是上传组件部分,使用antd的upload组件,添加一个按钮来操作上传动作,顺便添加一个进度条组件来展示上传情况,具体情况见代码:
<a-upload
:file-list="fileList"
:remove="handleRemove"
:multiple="false"
:before-upload="beforeUpload">
<a-button>
<upload-outlined></upload-outlined>
选择文件
</a-button>
</a-upload>
<a-button
type="primary"
:disabled="fileList.length === 0 || !finishSlice"
:loading="uploading"
style="margin-top: 16px"
@click="handleUpload">
{{ uploading ? "上传中" : "开始上传" }}
</a-button>
<a-progress :percent="Math.round(sliceProgress/sliceCount*100)"
:status="sliceProgress===sliceCount ? 'success':'active'" v-if="showSliceProgress"/>
<a-progress :percent="Math.round(finishCount/sliceCount*100)"
:status="finishCount===sliceCount ? 'success':'active'" v-if="showProgress"/>
复制代码
其中 fileList 代表的是上传文件列表;handleRemove 是操作删除文件选择的方法;beforeUpload 代表的是上传文件之前的预操作方法,这里可以在这里进行文件切片;handleUpload 代表的是开始上传文件的方法。
2)变量定义
接下来是上传相关逻辑的编写,这里使用的是 typescript,先看一下定义的一些变量:
// 文件列表
const fileList = ref<File[]>([]);
// 上传状态
const uploading = ref<boolean>(false);
// 分片完成情况
const finishSlice = ref<boolean>(false);
// 完成上传的分片数量
const finishCount = ref<number>(0);
// 展示上传进度条
const showProgress = ref<boolean>(false);
// 切片数量
const sliceCount = ref<number>(0);
// 切片进度条
const sliceProgress = ref<number>(0);
// 上传失败的数量
const errorCount = ref<number>(0);
// 展示切片进度条
const showSliceProgress = ref<boolean>(false);
// 切片列表
let fileChunkList: any = [];
// 发送的切片数量
const sendCount = ref<number>(0);
// 文件类型
let filetype = "";
// 文件名
let filename = "";
// 文件hash值
let hash = "";
复制代码
3)文件切片
接下来是进行文件的切片操作,这里需要使用到 spark-md5。
import SparkMD5 from 'spark-md5'
复制代码
这里是将文件整体读入计算md5,好处是md5碰撞的概率大大降低,缺点是计算时间会长一些;如果想计算时间短一些,不追求极致的低碰撞率的话,可以考虑读入第一个切片和最后一个切片进行md5计算。这里可以根据实际情况酌情考虑。
const beforeUpload = (file: File) => {
message.info("开始文件切片");
// 显示切片进度条
showSliceProgress.value = true;
// 文件添加到文件列表 这里只展示单文件上传
fileList.value = [file];
// 一些参数的初始化
fileChunkList = [];
finishSlice.value = false;
finishCount.value = 0;
sliceProgress.value = 0;
showProgress.value = false;
sliceCount.value = 0;
errorCount.value = 0;
return new Promise((resolve, reject) => {
// 初始化md5工具对象
const spark = new SparkMD5.ArrayBuffer();
// 用于读取文件计算md5
const fileReader = new FileReader();
// 这里是依据.来对文件和类型进行分割
let fileInfo = file.name.split(".")
filename = fileInfo[0];
// 最后一个.之前的内容都应该认定为文件名称
if (fileInfo.length > 1) {
filetype = fileInfo[fileInfo.length - 1];
for (let i = 1; i < fileInfo.length - 1; i++) {
filename = filename + "." + fileInfo[i];
}
}
// 这里开始做切片
// 设置切片大小 可以根据实际情况设置
const chunkSize = 1024 * 1024 * 1;
// 计算出切片数量
sliceCount.value = Math.ceil(file.size / chunkSize);
let curChunk = 0;
// 切片操作的实际方法【定义】
const sliceNext = () => {
// 使用slice方法进行文件切片
const chunkFile = file.slice(curChunk, curChunk + chunkSize);
// 读取当前切片文件流【这里会触发onload方法】
fileReader.readAsArrayBuffer(chunkFile);
// 加入切片列表
fileChunkList.push({
// 切片文件信息
chunk: chunkFile,
// 文件名
filename: filename,
// 分片索引 这里直接借助sliceProgress来实现
seq: sliceProgress.value + 1,
// 文件类型
type: filetype,
// 状态信息 用于标识是否上传成功
status: false
});
// 切片完成变量自增
sliceProgress.value++;
};
// 进入方法需要进行首次切片操作
sliceNext();
// 读取文件流时会触发onload方法
fileReader.onload = (e: any) => {
// 将文件流加入计算md5
spark.append(e.target.result);
// 修改切片位移
curChunk += chunkSize;
// 说明还没到达最后一个切片 继续切
if (sliceProgress.value < sliceCount.value) {
sliceNext();
} else {
// 说明切片完成了
finishSlice.value = true;
// 读取文件hash值
hash = spark.end();
message.success("文件分片完成");
// 将哈希值作为其中一个属性 写入到分片列表中
fileChunkList.forEach((content: any) => {
content.hash = hash;
})
}
};
})
};
到这里文件的切片和md5计算就完成了,一个大文件也变成了多个小文件的列表。
4)上传分片
接下来介绍的是开始分片上传的逻辑,这里需要注意不能一次性将分片全部上传,如果切片数量太大一次性发送出去会导致客户端卡死崩溃,因此采用递归调用的方式来确保同一时间等待的请求在一定数量,这里限定同时间等待请求数为10。
// 开始执行上传切片逻辑
const startUpload = () => {
return new Promise((resolve, reject) => {
const next = () => {
// 递归出口 分片上传完毕
if (finishCount.value + errorCount.value >= sliceCount.value) {
return;
}
// 记录当前遍历位置
let cur = sendCount.value++;
// 说明越界了 直接退出
if (cur >= sliceCount.value) {
return;
}
// 获取分片信息
let content = fileChunkList[cur];
// 已经上传过了 直接跳过【可用于断点续传】
if (content.status === true) {
if (finishCount.value + errorCount.value < sliceCount.value) {
next();
return;
}
}
// 开始填充上传数据 这里需要使用FormData来存储信息
const formData = new FormData();
formData.append("file", content.chunk);
formData.append("hash", content.hash);
formData.append("filename", content.filename);
formData.append("seq", content.seq);
formData.append("type", content.type);
// 开始上传
axios.post("http://localhost:8080/upload", formData).then((res) => {
// 接收回调信息
const data = res.data;
if (data.success) {
// 成功计数 并设置分片上传状态
finishCount.value += 1;
content.status = true;
} else {
// 失败计数
errorCount.value += 1;
}
// 说明完成最后一个分片上传但上传期间出现错误
if (errorCount.value !== 0 && errorCount.value + finishCount.value === sliceCount.value) {
message.error("上传发生错误,请重传");
showProgress.value = false;
uploading.value = false;
}
// 说明还有分片未上传 需要继续递归
if (finishCount.value + errorCount.value < sliceCount.value) {
next();
}
// 说明所有分片上传成功了 发起合并操作
if (finishCount.value === sliceCount.value) {
merge();
}
}).catch(error => {
// 对于图中发生的错误需要捕获并记录
errorCount.value += 1;
if (errorCount.value !== 0 && errorCount.value + finishCount.value === sliceCount.value) {
message.error("上传发生错误,请重传");
showProgress.value = false;
uploading.value = false;
}
// 当前分片上传失败不应影响下面的分片
if (finishCount.value + errorCount.value < sliceCount.value) {
next();
}
console.log(error)
})
};
// 只允许同时10个任务在等待
while (sendCount.value < 10 && sendCount.value < sliceCount.value) {
next();
}
});
};
5)文件合并
接下来还应该实现 merge 方法的逻辑,主要用于向服务端发送合并请求,服务端接收后进行分片合并操作,那么这里就应该将需要合并的文件的hash值传过去,才可以完成文件的定位。
const merge = () => {
message.success('上传成功,等待服务器合并文件');
// 发起合并请求 传入文件hash值、文件类型、文件名
axios.post("http://localhost:8080/merge", {
hash: hash,
type: filetype,
filename: filename
}).then((res) => {
const data = res.data;
if (data.success) {
message.success(data.message);
// 获取上传成功的文件地址
console.log(data.content);
// 其他业务操作...
} else {
message.error(data.message)
}
uploading.value = false;
}).catch(e => {
message.error('发生错误了');
uploading.value = false;
});
};
6)取消文件
最后完成取消选择文件的逻辑,也就是上面标注的 handleRemove 方法:
const handleRemove = (file: File) => {
const index = fileList.value.indexOf(file);
const newFileList = fileList.value.slice();
let hash = "";
newFileList.splice(index, 1);
fileList.value = newFileList;
// 取消之后需要进行相关变量的重新初始化
fileChunkList = [];
finishSlice.value = false;
finishCount.value = 0;
sliceProgress.value = 0;
showProgress.value = false;
sliceCount.value = 0;
errorCount.value = 0;
};
复制代码
7)极速秒传
实际上到这里我们已经实现了分片上传与合并的功能了,但出于节省资源与提升用户体验的考虑,我们还可以加入极速秒传的逻辑。这一块实际上就是服务端合并文件之后将(hash:file-site)信息存储起来,存储到DB或者Cache中,接下来前端在每次上传文件时都会先请求文件检查接口,如果文件存在则无需执行上传操作。
const handleUpload = async () => {
if (!finishSlice.value) {
alert("文件切片中,请稍等~");
return;
}
// 进度条变更
showSliceProgress.value = false;
// 先检查是否已经上传过
axios.get("http://localhost:8080/check?hash=" + hash).then((res) => {
const data = res.data;
if (data.success) {
message.success(data.message);
console.log(data.content);
} else {
// 开始上传逻辑 相关变量状态更迭
uploading.value = true;
// 这里主要是服务于断点续传 避免重复上传已成功分块
sliceCount.value -= finishCount.value;
errorCount.value = 0;
finishCount.value = 0;
sendCount.value = 0;
showProgress.value = true;
console.log("开始上传")
// 调用上面写好的上传逻辑
startUpload();
}
}).catch(error => {
alert("发生异常了")
console.log(error)
})
}
复制代码
到这里我们就完成了分片上传/极速秒传的前端逻辑,接下来就应该考虑后端的实现了。
2、后端逻辑
后端的基本思路是,接收到分片信息后根据hash值创建文件夹,之后将接收到的同一个hash值的分片信息都存储到同一个文件夹下【这里需要注意存储时要打好序号,才可以按序合并】,待收到合并请求后合并文件,根据合并文件的hash值与源hash值做比较,确保文件无损。
这里后端使用 SpringBoot 实现,依旧是常见的分层模型,Controller 层负责请求接口定义,Service 层负责业务逻辑的编写,由于这里不涉及到数据库的交互因而省略DAO层相关编写。
先确定下来提供的接口数,现在我们需要一个接收分片的接口,一个接受合并请求的接口,最后还要有一个接受文件检查的接口用于极速秒传,具体如下:
接口 | 接口描述 |
uploadSlice | 接收上传切片的接口 |
merge | 接收合并切片请求的接口 |
checkUpload | 检查文件上传状态的接口 |
1)返回实体
先来看看定义的全局返回实体,目的是同一后端返回样式,方便前端获取:
import java.io.Serializable;
/**
* @author h0ss
* @description 用于系统业务响应数据的统一封装
*/
public class CommonResp<T> implements Serializable {
private static final Long serialVersionUID = 205112889857456165L;
/**
* 业务上的成功或失败
*/
private boolean success = true;
/**
* 返回信息
*/
private String message;
/**
* 返回泛型的消息体数据
*/
private T content;
// 省略getter/setter/toString方法
}
复制代码
2)上传接口
接下来是接口的具体定义与内容:
/**
* 上传分片的接口
*
* @param file : 文件信息
* @param hash : 文件哈希值
* @param filename : 文件名
* @param seq : 分片序号
* @param type : 文件类型
*/
@PostMapping("/upload")
public CommonResp<String> uploadSlice(@RequestParam(value = "file") MultipartFile file,
@RequestParam(value = "hash") String hash,
@RequestParam(value = "filename") String filename,
@RequestParam(value = "seq") Integer seq,
@RequestParam(value = "type") String type) {
try {
// 返回上传结果
return uploadService.uploadSlice(file.getBytes(), hash, filename, seq, type);
} catch (IOException e) {
// ...日志记录异常信息...
CommonResp<String> resp = new CommonResp<>();
resp.setSuccess(false);
resp.setMessage("上传失败");
return resp;
}
}
复制代码
接口的信息很简单,就是将参数预处理后调用服务方法将结果返回,接下来看看服务方法:
private static String BASE_DIR = "I:\\";
/**
* 分片上传
*
* @param file : 文件流
* @param hash : 哈希值
* @param filename : 文件名
* @param seq : 分片序号
* @param type : 文件类型
*/
public CommonResp<String> uploadSlice(byte[] file, String hash, String filename, Integer seq, String type) {
CommonResp<String> resp = new CommonResp<>();
RandomAccessFile raf = null;
try {
// 创建目标文件夹
File dir = new File(BASE_DIR + hash);
if (!dir.exists()) {
dir.mkdir();
}
// 创建空格文件 名称带seq用于标识分块信息
raf = new RandomAccessFile(BASE_DIR + hash + "\\" + filename + "." + type + seq, "rw");
// 写入文件流
raf.write(file.getBytes());
} catch (IOException e) {
// 异常处理
// ...打印异常日志...
resp.setSuccess(false);
} finally {
try {
if (raf != null) {
raf.close();
}
} catch (IOException e) {
// ...打印异常日志...
}
}
return resp;
}
复制代码
这样我们就实现了分片信息的写入。
3)分片合并
接下来就应该实现分块合并的逻辑了,对于接受的请求信息我们用一个实体类来包装,免得使用 Map 造成指向不明确:
public class MergeInfo implements Serializable {
private static Long serialVersionUID = 1351063126163421L;
/* 文件名 */
private String filename;
/* 文件类型 */
private String type;
/* 文件哈希值 */
private String hash;
// ...省略setter/getter/toString...
}
复制代码
接下来就可以写请求接口的信息了:
@PostMapping("/merge")
public CommonResp<String> merge(@RequestBody MergeInfo mergeInfo) {
if (mergeInfo!=null) {
String filename = mergeInfo.getFilename();
String type = mergeInfo.getType();
String hash = mergeInfo.getHash();
return uploadService.uploadMerge(filename, type, hash);
}
CommonResp<String> resp = new CommonResp<String>();
resp.setSuccess(false);
resp.setMessage("文件合并失败");
return resp;
}
复制代码
接口还是只对请求参数做预处理,具体看合并的业务层代码:
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 合并文件的业务代码
*
* @param filename : 文件名
* @param hash : 文件哈希值
* @param type : 文件类型
*/
public CommonResp<String> uploadMerge(String filename, String type, String hash) {
CommonResp<String> resp = new CommonResp<>();
// 判断hash对应文件夹是否存在
File dir = new File(BASE_DIR + hash);
if (!dir.exists()) {
resp.setSuccess(false);
resp.setMessage("合并失败,请稍后重试");
System.out.println(resp);
}
// 这里通过FileChannel来实现信息流复制
FileChannel out = null;
// 获取目标channel
try (FileChannel in = new RandomAccessFile(BASE_DIR + filename + '.' + type, "rw").getChannel()) {
// 分片索引递增
int index = 1;
// 开始流位置
long start = 0;
while (true) {
// 分片文件名
String sliceName = BASE_DIR + hash + '\\' + filename + '.' + type + index;
// 到达最后一个分片 退出循环
if (!new File(sliceName).exists()) {
break;
}
// 分片输入流
out = new RandomAccessFile(sliceName, "r").getChannel();
// 写入目标channel
in.transferFrom(out, start, start + out.size());
// 位移量调整
start += out.size();
out.close();
out = null;
// 分片索引调整
index++;
}
// 文件合并完毕
in.close();
// ...执行本地存储服务/第三方存储服务上传 返回文件地址...
// 这里假设是fileSite
String fileSite = "";
resp.setContent(fileSite);
resp.setMessage("上传成功");
// 地址存入redis 实现秒传
stringRedisTemplate.opsForValue().set("upload:finish:hash:" + hash, fileSite);
return resp;
} catch (IOException e) {
// ...记录日志..
} finally {
if (out != null) {
try {
out.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
resp.setSuccess(false);
resp.setMessage("上传失败,请稍后重试");
return resp;
}
复制代码
这样我们就实现了接收分片上传与分片合并的请求了。
4)极速秒传
除此之外还有极速秒传的检查接口,逻辑比较简单,只要判断 Redis 是否存在该文件 hash 值的 key 即可,具体逻辑如下:
/**
* 极速秒传接口
*
* @param hash : 文件哈希值
*/
@Override
public CommonResp<String> fastUpload(String hash) {
return uploadService.fastUpload(hash);
}
复制代码
/**
* 极速秒传业务代码
*
* @param hash : 文件哈希值
*/
public CommonResp<String> fastUpload(String hash) {
CommonResp<String> resp = new CommonResp<>();
String key = "upload:finish:hash:" + hash;
String fileSite = stringRedisTemplate.opsForValue().get(key);
// 文件已存在 直接返回地址
if (fileSite != null) {
resp.setSuccess(true);
resp.setContent(fileSite);
resp.setMessage("极速秒传成功");
} else {
resp.setSuccess(false);
resp.setContent("");
resp.setMessage("极速秒传失败");
}
return resp;
}
复制代码
至此,我们就实现了后端的分片上传合并以及极速秒传的逻辑,到这里前后端代码就可以联调,开始测试了。
总结
1)文件切片时需要注意计算出文件的 hash 值,以便后续进行合并识别;
2)对于分片需要记录下分片的索引信息,否则组装时可能会乱序造成文件损坏;
3)文件信息可暂存在 Redis 中,但建议最终还是持久化到 DB。
作者:玛卡bug卡
链接:https://juejin.cn/post/7085997674033315877
相关推荐
- 3分钟让你的项目支持AI问答模块,完全开源!
-
hello,大家好,我是徐小夕。之前和大家分享了很多可视化,零代码和前端工程化的最佳实践,今天继续分享一下最近开源的Next-Admin的最新更新。最近对这个项目做了一些优化,并集成了大家比较关注...
- 干货|程序员的副业挂,12个平台分享
-
1、D2adminD2Admin是一个完全开源免费的企业中后台产品前端集成方案,使用最新的前端技术栈,小于60kb的本地首屏js加载,已经做好大部分项目前期准备工作,并且带有大量示例代码,助...
- Github标星超200K,这10个可视化面板你知道几个
-
在Github上有很多开源免费的后台控制面板可以选择,但是哪些才是最好、最受欢迎的可视化控制面板呢?今天就和大家推荐Github上10个好看又流行的可视化面板:1.AdminLTEAdminLTE是...
- 开箱即用的炫酷中后台前端开源框架第二篇
-
#头条创作挑战赛#1、SoybeanAdmin(1)介绍:SoybeanAdmin是一个基于Vue3、Vite3、TypeScript、NaiveUI、Pinia和UnoCSS的清新优...
- 搭建React+AntDeign的开发环境和框架
-
搭建React+AntDeign的开发环境和框架随着前端技术的不断发展,React和AntDesign已经成为越来越多Web应用程序的首选开发框架。React是一个用于构建用户界面的JavaScrip...
- 基于.NET 5实现的开源通用权限管理平台
-
??大家好,我是为广大程序员兄弟操碎了心的小编,每天推荐一个小工具/源码,装满你的收藏夹,每天分享一个小技巧,让你轻松节省开发效率,实现不加班不熬夜不掉头发,是我的目标!??今天小编推荐一款基于.NE...
- StreamPark - 大数据流计算引擎
-
使用Docker完成StreamPark的部署??1.基于h2和docker-compose进行StreamPark部署wgethttps://raw.githubusercontent.com/a...
- 教你使用UmiJS框架开发React
-
1、什么是Umi.js?umi,中文可发音为乌米,是一个可插拔的企业级react应用框架。你可以将它简单地理解为一个专注性能的类next.js前端框架,并通过约定、自动生成和解析代码等方式来辅助...
- 简单在线流程图工具在用例设计中的运用
-
敏捷模式下,测试团队的用例逐渐简化以适应快速的发版节奏,大家很早就开始运用思维导图工具比如xmind来编写测试方法、测试点。如今不少已经不少利用开源的思维导图组件(如百度脑图...)来构建测试测试...
- 【开源分享】神奇的大数据实时平台框架,让Flink&Spark开发更简单
-
这是一个神奇的框架,让Flink|Spark开发更简单,一站式大数据实时平台!他就是StreamX!什么是StreamX大数据技术如今发展的如火如荼,已经呈现百花齐放欣欣向荣的景象,实时处理流域...
- 聊聊规则引擎的调研及实现全过程
-
摘要本期主要以规则引擎业务实现为例,陈述在陌生业务前如何进行业务深入、调研、技术选型、设计及实现全过程分析,如果你对规则引擎不感冒、也可以从中了解一些抽象实现过程。诉求从硬件采集到的数据提供的形式多种...
- 【开源推荐】Diboot 2.0.5 发布,自动化开发助理
-
一、前言Diboot2.0.5版本已于近日发布,在此次发布中,我们新增了file-starter组件,完善了iam-starter组件,对core核心进行了相关优化,让devtools也支持对IAM...
- 微软推出Copilot Actions,使用人工智能自动执行重复性任务
-
IT之家11月19日消息,微软在今天举办的Ignite大会上宣布了一系列新功能,旨在进一步提升Microsoft365Copilot的智能化水平。其中最引人注目的是Copilot...
- Electron 使用Selenium和WebDriver
-
本节我们来学习如何在Electron下使用Selenium和WebDriver。SeleniumSelenium是ThoughtWorks提供的一个强大的基于浏览器的开源自动化测试工具...
- Quick 'n Easy Web Builder 11.1.0设计和构建功能齐全的网页的工具
-
一个实用而有效的应用程序,能够让您轻松构建、创建和设计个人的HTML网站。Quick'nEasyWebBuilder是一款全面且轻巧的软件,为用户提供了一种简单的方式来创建、编辑...
- 一周热门
- 最近发表
- 标签列表
-
- kubectlsetimage (56)
- mysqlinsertoverwrite (53)
- addcolumn (54)
- helmpackage (54)
- varchar最长多少 (61)
- 类型断言 (53)
- protoc安装 (56)
- jdk20安装教程 (60)
- rpm2cpio (52)
- 控制台打印 (63)
- 401unauthorized (51)
- vuexstore (68)
- druiddatasource (60)
- 企业微信开发文档 (51)
- rendertexture (51)
- speedphp (52)
- gitcommit-am (68)
- bashecho (64)
- str_to_date函数 (58)
- yum下载包及依赖到本地 (72)
- jstree中文api文档 (59)
- mvnw文件 (58)
- rancher安装 (63)
- nginx开机自启 (53)
- .netcore教程 (53)