序言:
1. 需求: 10MB以上的文件支持断点续传,需要有实时的进度条.
2. 目前网上有两种实现断点续传的方式, 主要区分于 切割文件的动作 在前端还是在后端. (本文章实现的是在前端实现对文件的切割, 也就是 前端文件流的slice函数, 会将文件转换为 bobl类型的数据传给后端).
3. 其主要通过 分片总数量和当前上传的分片下标 这两个值来通过数据库进行判断和执行.
4.相关参考:
断点续传: https://www.bilibili.com/video/BV1sv411p7Ee/
断点续传的概念: https://blog.csdn.net/yjxkq99/article/details/128942133
5.分片文件上传时看需要进行建立一个单独的文件夹存储分片文件. 断点续传是 已经上传了一部分文件后中断上传, 再次 上传此文件时,会从之前已上传的文件开始继续上传剩余部分文件.
6.文章将介绍实现思路和流程图, 以及实现效果和开发过程中遇到的问题.
思路:
1. 第一步校验文件接口:
获取前端对文件信息的MD5加密, 和文件流. 对文件的大小, 类型, 文件名, 文件大小, 分片大小, 分片总数, 对文件进行计算所需截取的每个分片大小(存储在 分片数据集合 ), 是否是最后一个分片,文件ID 等主要参数, 保存到数据库中此文件的信息, 并会在此接口中返回, 交给前端判断.
2. 第二步断点续传接口:
将校验文件接口中的 分片数据集合 进行遍历, 调用 分片上传接口, 上传每次 slice函数截取的文件流, 文件ID. 其中截取文件大小的计算逻辑由后端完成(精确到byte字节). 后端在每次上传完成后会返回是否是文件最后一个分片的标识
大文件断点续传流程图:
数据库设计:
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for sys_file
-- ----------------------------
DROP TABLE IF EXISTS `sys_file`;
CREATE TABLE `sys_file` (
`file_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '主键ID',
`business_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '业务主键ID',
`bus_mode` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT 'SYSTEM' COMMENT '业务模块类型',
`server_file_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '对象存储服务器中的文件名',
`file_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '文件名称',
`file_type` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '文件类型',
`file_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '文件路径',
`file_size` decimal(30, 0) NULL DEFAULT NULL COMMENT '文件大小(byte字节数)',
`source_type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT 'MANUAL' COMMENT '来源类型',
`chunk_flag` tinyint NULL DEFAULT 0 COMMENT '分片标记: 分片-1;不分片-0;',
`chunk_folder` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '分片文件夹',
`chunk_md5` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '分片的MD5加密',
`shard_total` decimal(10, 0) NULL DEFAULT NULL COMMENT '分片总数量',
`shard_index` decimal(10, 0) NULL DEFAULT 0 COMMENT '当前分片编号',
`shard_size` decimal(30, 0) NULL DEFAULT NULL COMMENT '每个分片大小',
PRIMARY KEY (`file_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin COMMENT = '附件表' ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;
前端核心代码实现:
vue3上传文件组件:
<el-form
:label-position="top"
:inline=true
:model="fileForm"
label-width="200px"
>
<el-form-item label="文件信息">
<el-upload
class="upload-demo"
:on-change="onChange"
:on-remove="onRemove"
:auto-upload="false"
>
<el-button size="small" type="primary">选择文件</el-button>
</el-upload>
</el-form-item>
<el-form-item>
<el-button type="danger" size="large" @click="onUpload" round>上传文件</el-button>
</el-form-item>
</el-form>
上传文件方法:
onUpload() {
// 在上传文件的时候携带其他参数进行提交内容
// 先请求 校验文件接口, 验证文件是否已上传,和上传到了哪个分片
let realFile = this.fileForm.file.raw;
let fileType = realFile.name.substring(realFile.name.lastIndexOf(".") + 1, realFile.name.length);
let filePwd = realFile.name + realFile.size + fileType + realFile.lastModified;
let filePwdMd5 = CryptoJS.MD5(filePwd).toString();
let verifyData = new FormData();
verifyData.append('file', realFile);
verifyData.append('fileName', realFile.name);
verifyData.append('fileSize', realFile.size);
verifyData.append('lastModified', realFile.lastModified);
verifyData.append('chunkMd5', filePwdMd5);
// 业务相关字段
verifyData.append('businessId', '1111111');
verifyData.append('busMode', 'DEV_MODEL');
// 校验文件, 并返回文件的分片数量,文件大小,文件ID,文件分片所需截取信息
verifyFile(verifyData).then(res => {
this.fileInfo = res.data.data;
// 最后一个分片标识
if (this.fileInfo.endFlag) {
// 进度条
this.Progress.progress = 100;
} else {
// 进度条计算
this.Progress.progress = ((this.fileInfo.shardIndex / this.fileInfo.shardTotal) * 0.1) * 1000;
}
this.uploadShardFile(realFile);
});
},
断点续传方法:
uploadShardFile(realFile) {
// 对文件流按照校验接口的返回值进行截取,使用for循环 重复调用断点续传文件接口
const asyncUpload = async () => {
for (let i = 0; i < this.fileInfo.shardList.length; i++) {
let item = this.fileInfo.shardList[i]
let shardFile = realFile.slice(item.shardStart, item.shardEnd);
let data = new FormData();
data.append('fileId', this.fileInfo.fileId);
data.append('file', shardFile);
data.append('shardIndex', item.shardIndex);
const res = await breakpointUpload(data)
let uploadData = res.data.data;
console.log(uploadData)
// 最后一个分片标识
if (uploadData.endFlag) {
// 进度条计算
this.Progress.progress = 100;
} else {
// 进度条计算
this.Progress.progress = Math.round(((uploadData.shardIndex / uploadData.shardTotal) * 0.1) * 1000);
}
console.log(((uploadData.shardIndex / uploadData.shardTotal) * 0.1) * 1000)
console.log(uploadData.shardIndex)
}
}
asyncUpload()
this.getTableDate();
},
后端核心代码实现:
校验文件接口:
public FileVerify verifyFile(FileVerify fileVerify) {
// 校验文件时将保存文件信息到 数据库中.
log.info("fileVerify-->{}", fileVerify);
LambdaQueryWrapper<FileInfo> queryWrapper = Wrappers.lambdaQuery(new FileInfo());
queryWrapper.eq(StrUtil.isNotBlank(fileVerify.getChunkMd5()), FileInfo::getChunkMd5, fileVerify.getChunkMd5());
queryWrapper.eq(StrUtil.isNotBlank(fileVerify.getFileId()), FileInfo::getFileId, fileVerify.getFileId());
FileInfo existFile = fileMapper.selectOne(queryWrapper);
// 在编辑文件的时, 其 文件ID和文件MD5加密都在传参中,此时还是没有找到文件, 则抛出异常.
Assert.isFalse(StrUtil.isNotBlank(fileVerify.getChunkMd5()) && StrUtil.isNotBlank(fileVerify.getFileId()) && Objects.isNull(existFile), "文件ID和文件MD5加密都在传参中,此时还是没有找到文件, 则抛出异常.");
if (Objects.isNull(existFile)) {
// 将文件信息保存到数据库中 并返回文件ID和MD5
existFile = getFileInfoByVerifyFile(fileVerify);
fileMapper.insert(existFile);
}
fileVerify.setFileId(existFile.getFileId());
fileVerify.setChunkMd5(existFile.getChunkMd5());
fileVerify.setChunkFlag(existFile.getChunkFlag());
fileVerify.setShardTotal(existFile.getShardTotal());
fileVerify.setShardSize(existFile.getShardSize());
fileVerify.setShardIndex(existFile.getShardIndex());
if (existFile.getShardIndex().equals(existFile.getShardTotal())) {
fileVerify.setChunkFlag(Boolean.TRUE);
}
fileVerify.setFile(null);
// 生成文件分片数值列表
List<FileVerify.ChunkShard> shardList = getShardList(fileVerify);
fileVerify.setShardList(shardList);
fileVerify.setEndFlag(CollUtil.isEmpty(shardList));
return fileVerify;
}
/**
* 构建所需要对文件进行截取的分片开始值和结束值, 用于前端 for循环调用分片上传接口
*/
private List<FileVerify.ChunkShard> getShardList(FileVerify fileVerify) {
BigDecimal shardTotal = fileVerify.getShardTotal();
List<FileVerify.ChunkShard> shardList = CollUtil.newArrayList();
for (int i = fileVerify.getShardIndex().intValue(); i < shardTotal.intValue(); i++) {
FileVerify.ChunkShard chunkShard = new FileVerify.ChunkShard();
chunkShard.setShardTotal(fileVerify.getShardTotal());
chunkShard.setShardIndex(BigDecimal.valueOf(i));
chunkShard.setShardStart(BigDecimal.valueOf(i).multiply(fileVerify.getShardSize()));
chunkShard.setShardEnd(BigDecimal.valueOf(i).multiply(fileVerify.getShardSize()).add(fileVerify.getShardSize()));
if (i == shardTotal.intValue() - 1) {
//如果是最后一个分片则对截取文件的大小
chunkShard.setShardEnd(BigDecimal.valueOf(fileVerify.getFileSize()));
}
shardList.add(chunkShard);
}
return shardList;
}
// 每 20MB 作为一个分片
private int shardSizeInt = 10 * 1024 * 1024;
/**
* 封装校验文件接口所需要的参数信息
*/
private FileInfo getFileInfoByVerifyFile(FileVerify fileVerify) {
FileInfo fileInfo = new FileInfo();
fileInfo.setBusinessId(fileVerify.getBusinessId());
fileInfo.setBusMode(fileVerify.getBusMode());
fileInfo.setFileName(fileVerify.getFileName());
fileInfo.setFileType(FileNameUtil.getSuffix(fileVerify.getFileName()));
fileInfo.setFileSize(BigDecimal.valueOf(fileVerify.getFileSize()));
fileInfo.setServerFileName(UUID.randomUUID().toString(true).toUpperCase() + "." + fileInfo.getFileType());
fileInfo.setChunkMd5(fileVerify.getChunkMd5());
fileInfo.setChunkFlag(Boolean.TRUE);
fileInfo.setChunkFolder(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")) + "/" + RandomUtil.randomString(6).toUpperCase());
fileInfo.setShardIndex(BigDecimal.ZERO);
// 以 每 20MB进行上传文件
// 字节数除以20MB, 即 除以 20*1024*1024
BigDecimal shardSize = BigDecimal.valueOf(shardSizeInt);
fileInfo.setShardSize(shardSize);
BigDecimal shardTotal = fileInfo.getFileSize().divide(shardSize, 0, RoundingMode.HALF_UP);
fileInfo.setShardTotal(shardTotal);
return fileInfo;
}
断点续传接口:
public FileVerify breakpointUpload(FileVerify fileVerify) {
Assert.notBlank(fileVerify.getFileId(), "文件ID不能为空.");
FileInfo existFile = fileMapper.selectById(fileVerify.getFileId());
Assert.notNull(existFile, "未找到此文件信息");
fileVerify.setShardTotal(existFile.getShardTotal());
// 上传文件
MultipartFile file = fileVerify.getFile();
// 自定义分片的名称以及上传路径
String shardFileName = getShardFileName(existFile, fileVerify.getShardIndex());
MinioUtils.me().upLoaderShardFile(file, shardFileName);
log.info("上传成功的分片文件名是->{}", shardFileName);
// 更新文件信息
FileInfo fileInfo = new FileInfo();
fileInfo.setFileId(fileVerify.getFileId());
// 更新上传的文件分片下标
fileInfo.setShardIndex(fileVerify.getShardIndex());
fileMapper.updateById(fileInfo);
log.error("更新文件分片信息到数据库-->{}", JSONUtil.toJsonStr(fileInfo));
fileVerify.setFile(null);
// 如果上传的分片下标正好等于 (总数的-1) 则认为是最后一个文件的分片已经上传成功了. 可以进行对所有的分片文件进行合并.
if (fileVerify.getShardIndex().equals(existFile.getShardTotal().subtract(BigDecimal.ONE))) {
fileVerify.setEndFlag(Boolean.TRUE);
// 开始进行对分片文件进行合并
log.error("此处所有分片文件都已上传, 进行合并文件...");
mergeShardFile(existFile);
}
return fileVerify;
}
/**
* 将多个分片文件进行合并.
*/
private void mergeShardFile(FileInfo existFile) {
// 获取所有的分片文件流,进行组装为一个流, 并写入到远程文件中.
BigDecimal shardTotal = existFile.getShardTotal();
byte[] allFileByte = new byte[existFile.getFileSize().intValue()];
int startLength = 0;
for (int i = 0; i < shardTotal.intValue(); i++) {
existFile.setShardIndex(BigDecimal.valueOf(i));
// 构建 文件服务器中的所有分片文件的名称
// 通过文件名从文件服务器中获取文件流, 进行合并到一个文件流中.
String fileName = getShardFileName(existFile, BigDecimal.valueOf(i));
byte[] fileByte = MinioUtils.me().getFileStream(fileName);
/**
* System.arraycopy(src, srcPos, dest, destPos, length)
* 参数解析:
* src:byte源数组
* srcPos:截取源byte数组起始位置(0位置有效)
* dest,:byte目的数组(截取后存放的数组)
* destPos:截取后存放的数组起始位置(0位置有效)
* length:截取的数据长度
*/
System.arraycopy(fileByte, 0, allFileByte, startLength, fileByte.length);
startLength = startLength + fileByte.length;
}
InputStream inputStream = new ByteArrayInputStream(allFileByte);
// 然后将文件流上传至minio服务器
// 指定存储的文件名称
String finalFileName = getFinalFileName(existFile);
// 进行上传文件
MinioUtils.me().upLoaderFileByByte(inputStream, finalFileName);
log.info("上传成功的分片文件名是->{}", finalFileName);
// 更新文件信息
FileInfo fileInfo = new FileInfo();
fileInfo.setFileId(existFile.getFileId());
// 更新上传的文件分片下标
fileInfo.setFileUrl(finalFileName);
log.error("更新成功数据-->{}", JSONUtil.toJsonStr(fileInfo));
fileMapper.updateById(fileInfo);
}
/**
* 生成最终的需要的文件名+路径
*/
private String getFinalFileName(FileInfo existFile) {
return existFile.getChunkFolder() + "/" + existFile.getServerFileName();
}
/**
* 生成文件分片名称+路径
*/
private String getShardFileName(FileInfo fileInfo, BigDecimal shardIndex) {
if (Objects.isNull(shardIndex)) {
return fileInfo.getChunkFolder() + "/" + fileInfo.getServerFileName();
}
return fileInfo.getChunkFolder() + "/" + fileInfo.getServerFileName() + "." + shardIndex;
}
过程中遇到的问题:
1. 对于文件加密的过程在前端还是在后端? 需要在前端进行计算.
2. 对于文件的分片大小限定多少? 网上找的一些示例: 5MB-10MB, 此处定为 10MB.
3. 在前端循环分片数据集合调用接口时, 会出现接口调用时长不一致, 先上传了其中某一个分片, 在更新数据库时更新数据错误? 前端使用 async + await 进行异步阻塞式的获取接口返回参数, 保证是按照分片数据集合的顺序 调用分片上传接口的.
4. 对于文件分片的计算放在前端?还是后端? 放在后端使用 bigdecmail 类型, 计算时向上取整不保留小数位.
最终实现的效果:
项目源码地址:
前端: hulunbuir-front/src/views/pages/FilePageView.vue - Gitee.com
后端: