赞
踩
1. 需求: 10MB以上的文件支持断点续传,需要有实时的进度条.
2. 目前网上有两种实现断点续传的方式, 主要区分于 切割文件的动作 在前端还是在后端. (本文章实现的是在前端实现对文件的切割, 也就是 前端文件流的slice函数, 会将文件转换为 bobl类型的数据传给后端).
3. 其主要通过 分片总数量和当前上传的分片下标 这两个值来通过数据库进行判断和执行.
4.相关参考:
断点续传: https://www.bilibili.com/video/BV1sv411p7Ee/
断点续传的概念: https://blog.csdn.net/yjxkq99/article/details/128942133
5.分片文件上传时看需要进行建立一个单独的文件夹存储分片文件. 断点续传是 已经上传了一部分文件后中断上传, 再次 上传此文件时,会从之前已上传的文件开始继续上传剩余部分文件.
6.文章将介绍实现思路和流程图, 以及实现效果和开发过程中遇到的问题.
获取前端对文件信息的MD5加密, 和文件流. 对文件的大小, 类型, 文件名, 文件大小, 分片大小, 分片总数, 对文件进行计算所需截取的每个分片大小(存储在 分片数据集合 ), 是否是最后一个分片,文件ID 等主要参数, 保存到数据库中此文件的信息, 并会在此接口中返回, 交给前端判断.
将校验文件接口中的 分片数据集合 进行遍历, 调用 分片上传接口, 上传每次 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;

- <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
后端:
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。