springboot 断点上传、续传、秒传实现

news2024/9/23 21:20:52

文章目录

  • 前言
  • 一、实现思路
  • 二、数据库表对象
  • 二、业务入参对象
  • 三、本地上传实现
  • 三、minio上传实现
  • 总结


前言

springboot 断点上传、续传、秒传实现。
保存方式提供本地上传(单机)和minio上传(可集群)
本文主要是后端实现方案,数据库持久化采用jpa

一、实现思路

  1. 前端生成文件md5,根据md5检查文件块上传进度或秒传

  2. 需要上传分片的文件上传分片文件

  3. 分片合并后上传服务器

二、数据库表对象

说明:

  1. AbstractDomainPd<String>为公共字段,如id,创建人,创建时间等,根据自己框架修改即可。
  2. clientId 应用id用于隔离不同应用附件,非必须
    附件表:上传成功的附件信息
@Entity
@Table(name = "gsdss_file", schema = "public")
@Data
public class AttachmentPO extends AbstractDomainPd<String> implements Serializable {
    /**
     * 相对路径
     */
    private String path;
    /**
     * 文件名
     */
    private String fileName;
    /**
     * 文件大小
     */
    private String size;
    /**
     * 文件MD5
     */
    private String fileIdentifier;
}

分片信息表:记录当前文件已上传的分片数据

@Entity
@Table(name = "gsdss_file_chunk", schema = "public")
@Data
public class ChunkPO extends AbstractDomainPd<String> implements Serializable {
    
    /**
     * 应用id
     */
    private String clientId;
    /**
     * 文件块编号,从1开始
     */
    private Integer chunkNumber;
    /**
     * 文件标识MD5
     */
    private String fileIdentifier;
    /**
     * 文件名
     */
    private String fileName;
    /**
     * 相对路径
     */
    private String path;
    
}

二、业务入参对象

检查文件块上传进度或秒传入参对象

package com.gsafety.bg.gsdss.file.manage.model.req;

import io.swagger.v3.oas.annotations.Hidden;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.web.multipart.MultipartFile;

import javax.validation.constraints.NotNull;

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ChunkReq {
    
    /**
     * 文件块编号,从1开始
     */
    @NotNull
    private Integer chunkNumber;
    /**
     * 文件标识MD5
     */
    @NotNull
    private String fileIdentifier;
    /**
     * 相对路径
     */
    @NotNull
    private String path;
    /**
     * 块内容
     */
    @Hidden
    private MultipartFile file;
    /**
     * 应用id
     */
    @NotNull
    private String clientId;
    /**
     * 文件名
     */
    @NotNull
    private String fileName;
}

上传分片入参

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class CheckChunkReq {
    
    /**
     * 应用id
     */
    @NotNull
    private String clientId;
    /**
     * 文件名
     */
    @NotNull
    private String fileName;
    
    /**
     * md5
     */
    @NotNull
    private String fileIdentifier;
}

分片合并入参

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class FileReq {
    
    @Hidden
    private MultipartFile file;
    /**
     * 文件名
     */
    @NotNull
    private String fileName;
    /**
     * 文件大小
     */
    @NotNull
    private Long fileSize;
    /**
     * eg:data/plan/
     */
    @NotNull
    private String path;
    /**
     * md5
     */
    @NotNull
    private String fileIdentifier;
    /**
     * 应用id
     */
    @NotNull
    private String clientId;
}

检查文件块上传进度或秒传返回结果

@Data
public class UploadResp implements Serializable {
    
    /**
     * 是否跳过上传(已上传的可以直接跳过,达到秒传的效果)
     */
    private boolean skipUpload = false;
    
    /**
     * 已经上传的文件块编号,可以跳过,断点续传
     */
    private List<Integer> uploadedChunks;
    
    /**
     * 文件信息
     */
    private AttachmentResp fileInfo;
    
}

三、本地上传实现

    @Resource
    private S3OssProperties properties;
    @Resource
    private AttachmentService attachmentService;
    @Resource
    private ChunkDao chunkDao;
    @Resource
    private ChunkMapping chunkMapping;
    
    /**
     * 上传分片文件
     *
     * @param req
     */
    @Override
    public boolean uploadChunk(ChunkReq req) {
        BizPreconditions.checkArgumentNoStack(!req.getFile().isEmpty(), "上传分片不能为空!");
        BizPreconditions.checkArgumentNoStack(req.getPath().endsWith("/"), "url参数必须是/结尾");
        //文件名-1
        String fileName = req.getFileName().concat("-").concat(req.getChunkNumber().toString());
        //分片文件上传服务器的目录地址 文件夹地址/chunks/文件md5
        String filePath = properties.getPath().concat(req.getClientId()).concat(File.separator).concat(req.getPath())
                .concat("chunks").concat(File.separator).concat(req.getFileIdentifier()).concat(File.separator);
        try {
            Path newPath = Paths.get(filePath);
            Files.createDirectories(newPath);
            //文件夹地址/md5/文件名-1
            newPath = Paths.get(filePath.concat(fileName));
            if (Files.notExists(newPath)) {
                Files.createFile(newPath);
            }
            Files.write(newPath, req.getFile().getBytes(), StandardOpenOption.CREATE);
        } catch (IOException e) {
            log.error(" 附件存储失败 ", e);
            throw new BusinessCheckException("附件存储失败");
        }
        // 存储分片信息
        chunkDao.save(chunkMapping.req2PO(req));
        return true;
    }
    
    /**
     * 检查文件块
     */
    @Override
    public UploadResp checkChunk(CheckChunkReq req) {
        UploadResp result = new UploadResp();
        //查询数据库记录
        //先判断整个文件是否已经上传过了,如果是,则告诉前端跳过上传,实现秒传
        AttachmentResp resp = attachmentService.findByFileIdentifierAndClientId(req.getFileIdentifier(), req.getClientId());
        if (resp != null) {
            //当前文件信息另存
            AttachmentResp newResp = attachmentService.save(AttachmentReq.builder()
                    .fileName(req.getFileName()).origin(AttachmentConstants.TYPE.LOCAL_TYPE)
                    .clientId(req.getClientId()).path(resp.getPath()).size(resp.getSize())
                    .fileIdentifier(req.getFileIdentifier()).build());
            result.setSkipUpload(true);
            result.setFileInfo(newResp);
            return result;
        }
        
        //如果完整文件不存在,则去数据库判断当前哪些文件块已经上传过了,把结果告诉前端,跳过这些文件块的上传,实现断点续传
        List<ChunkPO> chunkList = chunkDao.findByFileIdentifierAndClientId(req.getFileIdentifier(), req.getClientId());
        //将已存在的块的chunkNumber列表返回给前端,前端会规避掉这些块
        if (!CollectionUtils.isEmpty(chunkList)) {
            List<Integer> collect = chunkList.stream().map(ChunkPO::getChunkNumber).collect(Collectors.toList());
            result.setUploadedChunks(collect);
        }
        return result;
    }
    
    /**
     * 分片合并
     *
     * @param req
     */
    @Override
    public boolean mergeChunk(FileReq req) {
        String filename = req.getFileName();
        String date = DateUtil.localDateToString(LocalDate.now());
        //附件服务器存储合并后的文件存放地址
        String file = properties.getPath().concat(req.getClientId()).concat(File.separator).concat(req.getPath())
                .concat(date).concat(File.separator).concat(filename);
        //服务器分片文件存放地址
        String folder = properties.getPath().concat(req.getClientId()).concat(File.separator).concat(req.getPath())
                .concat("chunks").concat(File.separator).concat(req.getFileIdentifier());
        //合并文件到本地目录,并删除分片文件
        boolean flag = mergeFile(file, folder, filename);
        if (!flag) {
            return false;
        }
        
        //保存文件记录
        AttachmentResp resp = attachmentService.findByFileIdentifierAndClientId(req.getFileIdentifier(), req.getClientId());
        if (resp == null) {
            attachmentService.save(AttachmentReq.builder().fileName(filename).origin(AttachmentConstants.TYPE.LOCAL_TYPE)
                    .clientId(req.getClientId()).path(file).size(FileUtils.changeFileFormat(req.getFileSize()))
                    .fileIdentifier(req.getFileIdentifier()).build());
        }
        
        //插入文件记录成功后,删除chunk表中的对应记录,释放空间
        chunkDao.deleteAllByFileIdentifierAndClientId(req.getFileIdentifier(), req.getClientId());
        return true;
    }
    
    /**
     * 文件合并
     *
     * @param targetFile 要形成的文件地址
     * @param folder     分片文件存放地址
     * @param filename   文件的名称
     */
    private boolean mergeFile(String targetFile, String folder, String filename) {
        try {
            //先判断文件是否存在
            if (FileUtils.fileExists(targetFile)) {
                //文件已存在
                return true;
            }
            Path newPath = Paths.get(StringUtils.substringBeforeLast(targetFile, File.separator));
            Files.createDirectories(newPath);
            Files.createFile(Paths.get(targetFile));
            Files.list(Paths.get(folder))
                    .filter(path -> !path.getFileName().toString().equals(filename))
                    .sorted((o1, o2) -> {
                        String p1 = o1.getFileName().toString();
                        String p2 = o2.getFileName().toString();
                        int i1 = p1.lastIndexOf("-");
                        int i2 = p2.lastIndexOf("-");
                        return Integer.valueOf(p2.substring(i2)).compareTo(Integer.valueOf(p1.substring(i1)));
                    })
                    .forEach(path -> {
                        try {
                            //以追加的形式写入文件
                            Files.write(Paths.get(targetFile), Files.readAllBytes(path), StandardOpenOption.APPEND);
                            //合并后删除该块
                            Files.delete(path);
                        } catch (IOException e) {
                            log.error(e.getMessage(), e);
                            throw new BusinessException("文件合并失败");
                        }
                    });
            //删除空文件夹
            FileUtils.delDir(folder);
        } catch (IOException e) {
            log.error("文件合并失败: ", e);
            throw new BusinessException("文件合并失败");
        }
        return true;
    }

三、minio上传实现

    @Resource
    private MinioTemplate minioTemplate;
    @Resource
    private AttachmentService attachmentService;
 	@Resource
    private ChunkDao chunkDao;
    @Resource
    private ChunkMapping chunkMapping;
    
    /**
     * 上传分片文件
     */
    @Override
    public boolean uploadChunk(ChunkReq req) {
        String fileName = req.getFileName();
        BizPreconditions.checkArgumentNoStack(!req.getFile().isEmpty(), "上传分片不能为空!");
        BizPreconditions.checkArgumentNoStack(req.getPath().endsWith(separator), "url参数必须是/结尾");
        String newFileName = req.getPath().concat("chunks").concat(separator).concat(req.getFileIdentifier()).concat(separator)
                + fileName.concat("-").concat(req.getChunkNumber().toString());
        try {
            minioTemplate.putObject(req.getClientId(), newFileName, req.getFile());
        } catch (Exception e) {
            e.printStackTrace();
            throw new BusinessException("文件上传失败");
        }
        // 存储分片信息
        chunkDao.save(chunkMapping.req2PO(req));
        return true;
    }
    
    /**
     * 检查文件块
     */
    @Override
    public UploadResp checkChunk(CheckChunkReq req) {
        UploadResp result = new UploadResp();
        //查询数据库记录
        //先判断整个文件是否已经上传过了,如果是,则告诉前端跳过上传,实现秒传
        AttachmentResp resp = attachmentService.findByFileIdentifierAndClientId(req.getFileIdentifier(), req.getClientId());
        if (resp != null) {
            //当前文件信息另存
            AttachmentResp newResp = attachmentService.save(AttachmentReq.builder()
                    .fileName(req.getFileName()).origin(AttachmentConstants.TYPE.MINIO_TYPE)
                    .clientId(req.getClientId()).path(resp.getPath()).size(resp.getSize())
                    .fileIdentifier(req.getFileIdentifier()).build());
            result.setSkipUpload(true);
            result.setFileInfo(newResp);
            return result;
        }
        
        //如果完整文件不存在,则去数据库判断当前哪些文件块已经上传过了,把结果告诉前端,跳过这些文件块的上传,实现断点续传
        List<ChunkPO> chunkList = chunkDao.findByFileIdentifierAndClientId(req.getFileIdentifier(), req.getClientId());
        //将已存在的块的chunkNumber列表返回给前端,前端会规避掉这些块
        if (!CollectionUtils.isEmpty(chunkList)) {
            List<Integer> collect = chunkList.stream().map(ChunkPO::getChunkNumber).collect(Collectors.toList());
            result.setUploadedChunks(collect);
        }
        return result;
    }
    
    /**
     * 分片合并
     *
     * @param req
     */
    @Override
    public boolean mergeChunk(FileReq req) {
        String filename = req.getFileName();
        //合并文件到本地目录
        String chunkPath = req.getPath().concat("chunks").concat(separator).concat(req.getFileIdentifier()).concat(separator);
        List<Item> chunkList = minioTemplate.getAllObjectsByPrefix(req.getClientId(), chunkPath, false);
        String fileHz = filename.substring(filename.lastIndexOf("."));
        String newFileName = req.getPath() + UUIDUtil.uuid() + fileHz;
        try {
            List<ComposeSource> sourceObjectList = chunkList.stream()
                    .sorted(Comparator.comparing(Item::size).reversed())
                    .map(l -> ComposeSource.builder()
                            .bucket(req.getClientId())
                            .object(l.objectName())
                            .build())
                    .collect(Collectors.toList());
            ObjectWriteResponse response = minioTemplate.composeObject(req.getClientId(), newFileName, sourceObjectList);
            //删除分片bucket及文件
            minioTemplate.removeObjects(req.getClientId(), chunkPath);
        } catch (Exception e) {
            e.printStackTrace();
            throw new BusinessException("文件合并失败");
        }
        //保存文件记录
        AttachmentResp resp = attachmentService.findByFileIdentifierAndClientId(req.getFileIdentifier(), req.getClientId());
        if (resp == null) {
            attachmentService.save(AttachmentReq.builder().fileName(filename).origin(AttachmentConstants.TYPE.MINIO_TYPE)
                    .clientId(req.getClientId()).path(newFileName).size(FileUtils.changeFileFormat(req.getFileSize()))
                    .fileIdentifier(req.getFileIdentifier()).build());
        }
        
        //插入文件记录成功后,删除chunk表中的对应记录,释放空间
        chunkDao.deleteAllByFileIdentifierAndClientId(req.getFileIdentifier(), req.getClientId());
        return true;
    }

MinioTemplate 参考

总结

  1. 检查文件块上传进度或秒传

根据文件md5查询附件信息表,如果存在,直接返回附件信息。
不存在查询分片信息表,查询当前文件分片上传进度,返回已经上传过的分片编号

  1. 上传分片

分片文件上传地址需要保证唯一性,可用文件MD5作为隔离
上传后保存分片上传信息
minio对合并分片文件有大小限制,除最后一个分片外,其他分片文件大小不得小于5MB,所以minio分片上传需要分片大小最小为5MB,并且获取分片需要按照分片文件大小排序,将最后一个分片放到最后进行合并

  1. 分片合并

将分片文件合并为新文件到最终文件存放地址并删除分片文件
保存最终文件信息到附件信息表
删除对应分片信息表数据

在这里插入图片描述

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/510568.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

AI绘画:Lora模型训练完整流程!

关于AI绘画(基于Stable Diffusion Webui)&#xff0c;我之前已经写过三篇文章&#xff0c;分别是 软件安装&#xff0c;基本的使用方法&#xff0c;微调模型LoRA的使用。 整体来说还是比简单的&#xff0c;搞个别人的模型&#xff0c;搞个提示词就出图了。今天来一个有些难度…

推荐11个好用的prompt工具网站(附链接+论文)

同学们&#xff0c;你们prompt是自己苦哈哈码的吗&#xff1f;可别了&#xff0c;有现成的工具为啥不用&#xff1f; 今天我就和大家分享一些好用的prompt工具网站&#xff0c;用熟了ChatGPT、midjourney、stable diffusion能玩起来更爽&#xff01;搜罗了有十几个&#xff0c…

智能汽车实验二(视觉传感器标定)

实验二 视觉传感器标定&#xff08;实验报告&#xff09; 【实验目的】 1、了解开源图像处理库OpenCV的结构&#xff0c;掌握OpenCV的基本使用方法。 2、了解开源图像处理库OpenCV的基本模块功能&#xff0c;掌握常用图像处理方法。 3、掌握摄像机标定算法&#xff0c;学会使用…

Xilinx 7系列FPGA内置ADC

Xilinx 7系列FPGA全系内置了一个ADC&#xff0c;称之为XADC。这个XADC&#xff0c;内部是两个1mbps的ADC&#xff0c;可以采集模拟信号转为数字信号送给FPGA内部使用。 XADC内部可以直接获取芯片结温和FPGA的若干供电电压&#xff08;7系列不包括VCCO&#xff09;&#xff0c;用…

麒麟KylinV10SP1(2203)推荐安装一些硬件监控类软件与使用

目录 前言 1、tlp 电源管理 &#xff08;1&#xff09;查看电池容量、使用量、为Thinkpad设定电池充电开始结束阈值 &#xff08;2&#xff09;查看硬盘比如NVME SSD的型号种类、当前温度、读写量等信息&#xff1b; &#xff08;3&#xff09;查看CPU型号以及频率上下限、…

软件测试简单么,发展前景如何?

随着人工智能时代的到来&#xff0c;IT行业受到了越来越多人的重视。软件测试作为把控软件质量必不可少的环节&#xff0c;其重要性可见一斑。 软件测试可以说是算得上IT行业里相对简单的语言&#xff0c;但是也只是相对哈&#xff0c;如果想学习下去还是要看个人的学习能力的…

软件测试工作内容和职责有哪些

目前&#xff0c;在IT行业中测试的职位数量仅次于开发&#xff0c;可以说是第二大技术就业岗位。然而许多人对测试师工作的理解还停留在&#xff0c;只需要像用户一样使用产品&#xff0c;然后发现有问题提交报告就行了。其实这是极其不准确的&#xff0c;软件测试师在测试产品…

通过Dnspy调试解决powershell使用Install-module指定的转换无效的问题

之前运行Install-module -Name NtObjectManager出现以下错误&#xff1a; PackageManagement\Install-Package : Package NtObjectManager failed to be installed because: 指定的转换无效。 At C:\Program Files\WindowsPowerShell\Modules\PowerShellGet\1.0.0.1\PSModule.…

Shell编程之排序

目录 一、冒泡排序 二、选择排序 三、插入排序 基本思想&#xff1a; 四、反转排序 基本思想&#xff1a; 五、睡眠排序 六、希尔排序 基本思想&#xff1a; 举例 一、冒泡排序 冒泡排序&#xff0c;该排序的命名非常形象&#xff0c;即一个个将气泡冒出。冒泡排序一…

ChatDOC工具——使用ChatGPT高效阅读技术科研论文

ChatDOC是一款功能强大的人工智能阅读辅助工具&#xff0c;专为帮助用户快速理解论文内容而设计。使用ChatDOC&#xff0c;您可以通过上传PDF版论文文献&#xff0c;利用先进的ChatGPT技术&#xff0c;只需三个简单步骤&#xff0c;便可以高效地阅读论文&#xff0c;提高阅读效…

TypeScript初识

目录 介绍 定义 优点 类型声明 ts文件编译选项 自动编译 编译选项的各个属性 include compilerOptions 介绍 定义 TS&#xff08;TypeScript&#xff09;是一种由微软开发的编程语言&#xff0c;它是 JavaScript 的一个超集&#xff0c;提供了静态类型检查、类、接…

Linux系统编程——多线程[中]:互斥与同步

0.关注博主有更多知识 操作系统入门知识合集 目录 1.并发过程中的问题 2.互斥 2.1互斥锁 2.2如何看待互斥锁 2.3加锁和解锁的本质 2.4对锁做一个封装 2.5可重入函数与线程安全 2.6死锁 3.同步 3.1条件变量 1.并发过程中的问题 我们知道&#xff0c;同一个进程中的…

【SpringBoot】过滤器,监听器,拦截器介绍

文章目录 一、简介1、过滤器2、拦截器3、监听器 二、如何创建1、过滤器2、监听器3、拦截器 三、总结 一、简介 通过两幅图我们可以理解拦截器和过滤器的特点 1、过滤器 过滤器是在请求进入tomcat容器后&#xff0c;但请求进入servlet之前进行预处理的。请求结束返回也是&…

模拟IC与数字IC设计该怎么选?哪个岗位薪资高?

很多同学想要入行IC&#xff0c;但不知道数字和模拟方向怎么选&#xff1f; 如果没有亲身体会过模拟设计&#xff0c;并有发自内心的自信或者兴趣&#xff0c;一般不看好纯小白去学模拟电路设计。 模拟设计想做好&#xff0c;没有数学功底&#xff0c;没有电路分析的功底&…

面试题30天打卡-day24

1、Redis 为什么快&#xff1f; Redis 之所以快&#xff0c;主要是因为它具有以下特点&#xff1a; 纯内存操作&#xff1a;Redis 的数据存储在内存中&#xff0c;因此读写速度非常快&#xff0c;而无需像传统数据库一样从硬盘读取和写入数据。与此同时&#xff0c;Redis 支持…

【算法】动态规划算法求(编辑距离)

目录 编辑距离&#xff1a; 举例&#xff1a; 代码如下 调试&#xff1a; 核心代码&#xff1a; 画图演示上述代码&#xff1a; 编辑距离&#xff1a; 是一种计算两个自符串之间差异程度的方法&#xff0c;它通过比较两个字符串之间的插入&#xff0c;删除和 替换操作的数…

深度神经网络模型部署——Docker学习

容器技术中有三个核心概念&#xff1a;容器&#xff08;Container&#xff09;、镜像&#xff08;Image&#xff09;&#xff0c;以及镜像仓库&#xff08;Registry&#xff09; 从本质上来说&#xff0c;容器属于虚拟化技术的一种&#xff0c;和虚拟机&#xff08;Virtual Mac…

CentOS7安装vsftpd

CentOS7安装vsftpd 最近又用到ftp了&#xff0c;摸索了一下终于安装成功&#xff0c;记录下安装过程&#xff0c;本次使用的操作系统为 CentOS7&#xff0c;ftp使用vsftpd。 安装vsftpd yum install -y vsftpd配置vsftpd vsftpd的配置文件路径为&#xff1a;/etc/vsftpd/vs…

软件测试基础面试题大全(上)

1. 软件生命周期是什么&#xff1f; 软件生命周期&#xff1a;需求调研&#xff08;可行性研究&#xff09;、需求分析&#xff08;需求规格说明书&#xff09;、设计&#xff08;系统架构、模块设计、表结构设计、接口设计等&#xff0c;产出概要设计文档和详细设计文档&…

表达式求值问题-双栈模板化实现

好久不见&#xff0c;真的很久都没有更新博客了&#xff0c;最近很多事情&#xff0c;所以比较忙碌&#xff0c;没有时间每天都学算法&#xff0c;但是我会挤时间尽量做到&#xff0c;每两三天就更新博客&#xff0c;我会努力的&#xff0c;加油~ 前言&#xff1a;计算器都见过…