前言
本篇文章是基于其他文章的基础上结合自己的理解写出来的,如果哪里有问题请指出!
详细教程
秒传
1、什么是秒传
通俗的说,你把要上传的东西上传,服务器会先做MD5校验,如果服务器上有它就会进入秒传,想要不秒传,其实只要让MD5改变,就是对文件本身做一下修改(改名字不行),例如一个文本文件,你多加几个字,MD5就变了,就不会秒传了.
2、本文实现的秒传核心逻辑
a、利用redis的set方法存放文件上传状态,其中key为文件上传的md5,value为是否上传完成的标志位,
b、当标志位true为上传已经完成,此时如果有相同文件上传,则进入秒传逻辑。如果标志位为false,则表示当前分片没有上传,则进入上传逻辑.
分片上传
1.什么是分片上传
分片上传,就是将所要上传的文件,按照一定的大小,将整个文件分隔成多个数据块(我们称之为Part)来进行分别上传,上传完之后再由服务端对所有上传的文件进行汇总整合成原始的文件。
2.分片上传的场景
1.大文件上传
2.网络环境环境不好,存在需要重传风险的场景.
断点续传
1、什么是断点续传
断点续传是在下载或上传时,将下载或上传任务(一个文件或一个压缩包)人为的划分为几个部分,每一个部分采用一个线程进行上传或下载,如果碰到网络故障,可以从已经上传或下载的部分开始继续上传或者下载未完成的部分,而没有必要从头开始上传或者下载。本文的断点续传主要是针对断点上传场景。
2、应用场景
断点续传可以看成是分片上传的一个衍生,因此可以使用分片上传的场景,都可以使用断点续传。
3、实现断点续传的核心逻辑
在分片上传的过程中,如果因为系统崩溃或者网络中断等异常因素导致上传中断,这时候服务端需要记录上传的进度。在之后支持再次上传时,可以继续从上次上传中断的地方进行继续上传。(秒传逻辑已经记录在了redis,就不用记录了.)
为了避免客户端在上传之后的进度数据被删除而导致重新开始从头上传的问题,服务端提供相应的接口便于客户端对分片数据进行查询,从而使客户端知道已经上传的分片数据,从而从下一个没有上传的分片数据开始继续上传。
4、实现流程步骤
1.本文实现的步骤
-
前端(客户端)需要根据固定大小对文件进行分片,请求后端(服务端)时要带上分片序号和大小
-
服务端创建conf文件用来记录分块位置,conf文件长度为总分片数,每上传一个分块即向conf文件中写入一个127,那么没上传的位置就是默认的0,已上传的就是Byte.MAX_VALUE 127(这步是实现断点续传和秒传的核心步骤)
-
服务器按照请求数据中给的分片序号和每片分块大小(分片大小是固定且一样的)算出开始位置,与读取到的文件片段数据,写入文件。
2.分片上传/断点上传代码实现
a、前端框架使用的是vue+饿了么UI,进行分片。
b、后端实现文件写入,是用RandomAccessFile,和MappedByteBuffer实现。
前端(客户端)大概流程:
对文件进行指定大小分片->循环调用分片次数->计算文件内容的MD5值->调用服务器接口将MD5值传递查询服务器是否有相同文件->否?->进入上传逻辑->是?->什么也不干,进入下个分片检测
前端代码片段(代码垃圾,大佬勿怪,有哪里需要改的帮忙指出下谢谢)
async function updateFile(file) {// 将文件切分成小块进行上传// 每个分片的大小,这里设置的全局大小const chunkSize = store.state.config.fileChunkSize;const fileSize = file.size;// 总分片数totalChunks.value = Math.ceil(fileSize / chunkSize);// 截取文件名称等参数const parts = file.name.split(".");const fileName = parts[0];const fileType = parts[parts.length - 1];//判断是不是秒传let secCount = 0;for (var i = 0; i < totalChunks.value; i++) {// 当前分片的起始位置let start = currentChunk.value * chunkSize;// 当前分片的结束位置let end = Math.min(start + chunkSize, fileSize);console.log("当前位置" + currentChunk.value + "开始位置:" + start + "===============结束位置:" + end);//获取文件片段var partFile = file.slice(start, end);//计算文件md5var md5 = await computeFileSliceMD5(partFile);//服务器验证md5是否相同var verify = true;//验证MD5await checkFileMd5({ "md5": md5 }).then(result => {console.log(typeof (result.data));if (result.code == 200) {//返回true表示上传过verify = result.data;currentChunk.value++;} else {console.log("分片md5验证失败!");progressData.progressBar = false;}})//没上传过在调用上传接口if (verify) {secCount++;console.log("已经上传过进入秒传模式!");if (secCount == totalChunks.value) {console.log("全部都是秒传");progressData.percentage = 100;ElMessage.success("上传成功");progressData.progressBar = false;}else {//计算进度条进度progressData.percentage = Math.floor((100 / totalChunks.value)) * secCount + Math.floor((100 / totalChunks.value));}} else {//组装数据const formData = new FormData();formData.append('md5', md5);formData.append('file', partFile);formData.append('chunk', i);formData.append('chunkSize', chunkSize);formData.append('totalChunks', totalChunks.value);formData.append('fileType', fileType);formData.append('fileName', fileName);formData.append('fileSize', fileSize);//开始上传await fileUpload(formData).then(result => {console.log(result);if (result.code == 200) {if (i == totalChunks.value - 1) {progressData.progressBar = false;progressData.percentage = 100;}console.log("分片上传成功!");} else {console.log("分片上传失败!");}});}}}/**计算分片文件MD5* @param {Object} fileSlice*/async function computeFileSliceMD5(fileSlice) {return new Promise((resolve, reject) => {const reader = new FileReader();reader.onload = function (e) {const arrayBuffer = e.target.result;const spark = new SparkMD5.ArrayBuffer();spark.append(arrayBuffer);const blockMD5 = spark.end();resolve(blockMD5);};reader.onerror = function (error) {console.error('Error reading file:', error);};reader.readAsArrayBuffer(fileSlice);});}
后端(服务端)大概流程:
获取到后端传递的参数->将format参数转换对象->提前将当前文件的MD5存到redis和数据库状态为为上传状态->通过偏移量计算出文件位置,将当前数据写入到服务器->检测文件是否完成
后端代码片段(代码垃圾,大佬勿怪,有哪里需要改的帮忙指出下谢谢)
public boolean fileShardingUpload(MultipartFile file, MultiValueMap<String, Object> formData) {//对象转换FileUploadRequest param = converData(formData);//上传文件逻辑MappedByteBuffer mappedByteBuffer = null;try (RandomAccessFile tempRaf = new RandomAccessFile(FileUploadUtil.createTmpFile(param), "rw"); FileChannel fileChannel = tempRaf.getChannel()) {//新增分片记录saveFileShardingRecord(param);//设置默认值long chunkSize = Objects.isNull(param.getChunkSize()) ? 5 * 1024 * 1024 : param.getChunkSize();//写入该分片数据//计算该分片数据的偏移量long offset = chunkSize * param.getChunk();//读取文件数据byte[] fileData = file.getBytes();//映射文件mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, offset, fileData.length);//写数据mappedByteBuffer.put(fileData);//判断是否上传完成boolean isok = FileUploadUtil.checkAndSetUploadProgress(param);if (isok) {//上传成功,记录文件的完整记录,并且将文件丢到minio(后面看看有没有直接存到minio然后合并的)saveFileInfo(param);}//保存数据库操作(分片信息也要记录)updateFileShardingRecord(param);return isok;} catch (IOException e) {log.error(e.getMessage(), e);throw new RuntimeException("文件上传失败!");} finally {//这是一个坑不关闭,会一直占用try {Method m = FileChannelImpl.class.getDeclaredMethod("unmap", MappedByteBuffer.class);m.setAccessible(true);m.invoke(FileChannelImpl.class, mappedByteBuffer);} catch (Exception e) {log.error(e.getMessage(), e);}}}/*** 转换数据* @param formData* @return*/private FileUploadRequest converData(MultiValueMap<String, Object> formData) {FileUploadRequest req = new FileUploadRequest();req.setMd5(formData.getFirst("md5").toString());req.setChunk(Integer.parseInt(formData.getFirst("chunk").toString()));req.setChunkSize(Long.valueOf((formData.getFirst("chunkSize").toString())));req.setTotalChunks(Integer.parseInt(formData.getFirst("totalChunks").toString()));req.setFileType(formData.getFirst("fileType").toString());req.setFileName(formData.getFirst("fileName").toString());req.setFileSize(formData.getFirst("fileSize").toString());req.setFileTepPath(fileTemPath);return req;}/*** 新增分片记录** @param param 文件对象*/private void saveFileShardingRecord(FileUploadRequest param) {//提前设置文件状态FileUploadUtil.beforeSetUploadProgressRedis(param);//保存数据库FileShardingRecord fileShardingRecord = new FileShardingRecord();fileShardingRecord.setMd5(param.getMd5());fileShardingRecordMapper.insert(fileShardingRecord);}/*** 修改分片记录状态** @param param 文件对象*/private void updateFileShardingRecord(FileUploadRequest param) {FileShardingRecord fileShardingRecord = new FileShardingRecord();fileShardingRecord.setMd5(param.getMd5());fileShardingRecord.setStatus(1);fileShardingRecordMapper.updateByMd5(fileShardingRecord);}
FileUploadUtil类
public class FileUploadUtil {/*** 创建临时文件** @param param 前端传递的对象* @return* @throws IOException*/public static File createTmpFile(FileUploadRequest param) throws IOException {String fileName = param.getFileName();String tempFile = param.getFileTepPath() + fileName + "_tmp." + param.getFileType();// 创建 Path 对象Path path = Paths.get(tempFile);// 检查文件是否存在boolean exists = Files.exists(path);if (!exists) {Files.createFile(path);}return path.toFile();}/*** 创建conf记录文件** @param param* @return* @throws IOException*/public static File createConfFile(FileUploadRequest param) throws IOException {String fileName = param.getFileName();String uploadDirPath = param.getFileTepPath();String tempConfFile = uploadDirPath + fileName + "_tmp.conf";param.setFileTepConfPath(tempConfFile);// 创建 Path 对象Path path = Paths.get(tempConfFile);// 检查文件是否存在boolean exists = Files.exists(path);if (!exists) {Files.createFile(path);}return path.toFile();}/*** 检查文件的md5值** @param md5 md5* @return 是否上传完成*/public static boolean checkFileMd5(String md5) {//和Redis存储得做比较看看有没有上传...RedisUtil redisUtil = (RedisUtil) GlobalCache.getStaticCache().getIfPresent("redisUtil");//获取redis记录Object hget = redisUtil.hget(RedisEnum.UploadFileStatus.getContent(), md5);if (Objects.nonNull(hget)) {if (hget.equals("true")) {log.info("文件已经上传");return true;}}return false;}/*** 检查并修改文件上传进度*/public static boolean checkAndSetUploadProgress(FileUploadRequest param) throws IOException {//创建conf记录文File confFile = createConfFile(param);//是否完成byte isComplete = Byte.MAX_VALUE;try (RandomAccessFile accessConfFile = new RandomAccessFile(confFile, "rw");) {//把该分段标记为 true 表示完成System.out.println("set part " + param.getChunk() + " complete");//创建conf文件文件长度为总分片数,每上传一个分块即向conf文件中写入一个127,那么没上传的位置就是默认0,已上传的就是Byte.MAX_VALUE 127accessConfFile.setLength(param.getTotalChunks());accessConfFile.seek(param.getChunk());accessConfFile.write(Byte.MAX_VALUE);//completeList 检查是否全部完成,如果数组里是否全部都是127(全部分片都成功上传)byte[] completeList = FileUtil.readAsByteArray(confFile);for (int i = 0; i < completeList.length && isComplete == Byte.MAX_VALUE; i++) {//与运算, 如果有部分没有完成则 isComplete 不是 Byte.MAX_VALUEisComplete = (byte) (isComplete & completeList[i]);System.out.println("check part " + i + " complete?:" + completeList[i]);}} catch (IOException e) {log.error(e.getMessage(), e);}//把上传进度信息存进redisboolean isOk = setUploadProgressRedis(param, confFile, isComplete);return isOk;}/*** 把上传进度信息存进redis*/private static boolean setUploadProgressRedis(FileUploadRequest param, File confFile, byte isComplete) {//获取redisRedisUtil redisUtil = (RedisUtil) GlobalCache.getStaticCache().getIfPresent("redisUtil");//127表示全部完成了if (isComplete == Byte.MAX_VALUE) {redisUtil.hset(RedisEnum.UploadFileStatus.getContent(), param.getMd5(), "true");//删除保存的配置文件confFile.delete();//这里表示当前文件全部完成return true;}//修改位tureredisUtil.hset(RedisEnum.UploadFileStatus.getContent(), param.getMd5(), "true");return false;}/*** 提前设置状态** @param param*/public static void beforeSetUploadProgressRedis(FileUploadRequest param) {//获取redisRedisUtil redisUtil = (RedisUtil) GlobalCache.getStaticCache().getIfPresent("redisUtil");redisUtil.hset(RedisEnum.UploadFileStatus.getContent(), param.getMd5(), "false");}}
fileUploadRequest类
@Data
public class FileUploadRequest {/*** 文件名称*/private String fileName;/*** 文件临时路径*/private String fileTepPath;/*** 文件临时配置路径*/private String fileTepConfPath;/*** 文件类型*/private String fileType;/*** 文件大小*/private String fileSize;/*** 文件md5*/private String md5;/*** 当前块数*/private Integer chunk;/*** 分片得大小*/private Long chunkSize;/*** 总块数*/private Integer totalChunks;}
总结
本文只提供分片上传思路,代码具体以项目逻辑为主.