Java大文件上传方案(vue+饿了么):秒传、断点续传、分片上传!

前言

本篇文章是基于其他文章的基础上结合自己的理解写出来的,如果哪里有问题请指出!

详细教程

秒传

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值传递查询服务器是否有相同文件->否?->进入上传逻辑->是?->什么也不干,进入下个分片检测

前端代码片段(代码垃圾,大佬勿怪,有哪里需要改的帮忙指出下谢谢)

html 复制代码
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);

			//计算文件md5
			var md5 = await computeFileSliceMD5(partFile);

			//服务器验证md5是否相同
			var verify = true;
			//验证MD5
			await 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和数据库状态为为上传状态->通过偏移量计算出文件位置,将当前数据写入到服务器->检测文件是否完成

后端代码片段(代码垃圾,大佬勿怪,有哪里需要改的帮忙指出下谢谢)

java 复制代码
    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类
java 复制代码
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 127
            accessConfFile.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_VALUE
                isComplete = (byte) (isComplete & completeList[i]);
                System.out.println("check part " + i + " complete?:" + completeList[i]);
            }

        } catch (IOException e) {
            log.error(e.getMessage(), e);
        }
        //把上传进度信息存进redis
        boolean isOk = setUploadProgressRedis(param, confFile, isComplete);
        return isOk;
    }


    /**
     * 把上传进度信息存进redis
     */
    private static boolean setUploadProgressRedis(FileUploadRequest param, File confFile, byte isComplete) {
        //获取redis
        RedisUtil redisUtil = (RedisUtil) GlobalCache.getStaticCache().getIfPresent("redisUtil");
        //127表示全部完成了
        if (isComplete == Byte.MAX_VALUE) {
            redisUtil.hset(RedisEnum.UploadFileStatus.getContent(), param.getMd5(), "true");
            //删除保存的配置文件
            confFile.delete();
            //这里表示当前文件全部完成
            return true;
        }
        //修改位ture
        redisUtil.hset(RedisEnum.UploadFileStatus.getContent(), param.getMd5(), "true");
        return false;
    }

    /**
     * 提前设置状态
     *
     * @param param
     */
    public static void beforeSetUploadProgressRedis(FileUploadRequest param) {
        //获取redis
        RedisUtil redisUtil = (RedisUtil) GlobalCache.getStaticCache().getIfPresent("redisUtil");
        redisUtil.hset(RedisEnum.UploadFileStatus.getContent(), param.getMd5(), "false");
    }

}

fileUploadRequest类

java 复制代码
@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;

}

总结

本文只提供分片上传思路,代码具体以项目逻辑为主.

相关推荐
ForgeAI码匠3 小时前
ForgeAdmin|Spring Boot 3 后台框架的自动配置设计:少写配置,多做组合
java·spring boot·后端
tongluowan0073 小时前
Redisson的参数及工作原理
java·redis·lua·分布式锁
小娄~~3 小时前
C语言卷子错题集
c语言·开发语言·数据结构
仙俊红4 小时前
Integer\int对比,equals()\hashcode面试
java·面试·职场和发展
WiChP4 小时前
【V0.1B10】从零开始的2D游戏引擎开发之路
java·数据库·游戏引擎
一然明月4 小时前
qt基本用法
开发语言·qt
云烟成雨TD4 小时前
Spring AI Alibaba 1.x 系列【60】检查点机制原理与全流程剖析
java·人工智能·spring
ForgeAI码匠4 小时前
Maven 多模块项目如何避免越写越乱?Forge Admin 的模块边界实践
java·人工智能·开源·maven
hef2884 小时前
Prism图形设计从入门到精通:外观设置、图层顺序与微调技巧
开发语言
z落落4 小时前
C# 数组 最终完整版全套笔记(一维+多维+交错+引用类型+对象数组)
java·笔记·c#