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

 更新时间:2024年07月17日 08:56:59   作者:Mr-Wanter  
这篇文章主要介绍了springboot断点上传、续传、秒传实现方式,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教

前言

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

一、实现思路

  • 前端生成文件md5,根据md5检查文件块上传进度或秒传
  • 需要上传分片的文件上传分片文件
  • 分片合并后上传服务器

二、数据库表对象

说明:

  • AbstractDomainPd<String>为公共字段,如id,创建人,创建时间等,根据自己框架修改即可。
  • 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查询附件信息表,如果存在,直接返回附件信息。
  • 不存在查询分片信息表,查询当前文件分片上传进度,返回已经上传过的分片编号

2.上传分片

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

3.分片合并

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

总结

以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。

相关文章

  • 详解JAVA 七种创建单例的方法

    详解JAVA 七种创建单例的方法

    这篇文章主要介绍了详解JAVA 七种创建单例的方法,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2020-01-01
  • 解决springMVC 跳转js css图片等静态资源无法加载的问题

    解决springMVC 跳转js css图片等静态资源无法加载的问题

    下面小编就为大家带来一篇解决springMVC 跳转js css图片等静态资源无法加载的问题。小编觉得挺不错的,现在就分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2017-10-10
  • Spring整合redis的操作代码

    Spring整合redis的操作代码

    这篇文章主要介绍了Spring整合redis的操作代码,本文通过实例代码给大家介绍的非常详细,对大家的学习或工作具有一定参考借鉴价值,需要的朋友可以参考下
    2022-02-02
  • jar包中替换指定的class文件方法详解

    jar包中替换指定的class文件方法详解

    这篇文章主要为大家介绍了jar包中替换指定的class文件方法详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2023-11-11
  • Java反射之通过反射获取一个对象的方法信息(实例代码)

    Java反射之通过反射获取一个对象的方法信息(实例代码)

    下面小编就为大家带来一篇Java反射之通过反射获取一个对象的方法信息(实例代码)。小编觉得挺不错的,现在就分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2016-10-10
  • Java使用DateUtils对日期进行数学运算经典应用示例【附DateUtils相关包文件下载】

    Java使用DateUtils对日期进行数学运算经典应用示例【附DateUtils相关包文件下载】

    这篇文章主要介绍了Java使用DateUtils对日期进行数学运算的方法,可实现针对日期时间的各种常见运算功能,并附带DateUtils的相关包文件供读者下载使用,需要的朋友可以参考下
    2017-11-11
  • Mybatis-Plus条件构造器的具体使用方法

    Mybatis-Plus条件构造器的具体使用方法

    这篇文章主要介绍了Mybatis-Plus条件构造器的具体使用方法,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2020-08-08
  • JAVA线程sleep()和wait()详解及实例

    JAVA线程sleep()和wait()详解及实例

    这篇文章主要介绍了JAVA线程sleep()和wait()详解及实例的相关资料,探讨一下sleep()和wait()方法的区别和实现机制,需要的朋友可以参考下
    2017-05-05
  • Java Fluent Mybatis实战之构建项目与代码生成篇上

    Java Fluent Mybatis实战之构建项目与代码生成篇上

    Java中常用的ORM框架主要是mybatis, hibernate, JPA等框架。国内又以Mybatis用的多,基于mybatis上的增强框架,又有mybatis plus和TK mybatis等。今天我们介绍一个新的mybatis增强框架 fluent mybatis
    2021-10-10
  • Spring解决循环依赖问题及三级缓存的作用

    Spring解决循环依赖问题及三级缓存的作用

    这篇文章主要介绍了Spring解决循环依赖问题及三级缓存的作用,所谓的三级缓存只是三个可以当作是全局变量的Map,Spring的源码中大量使用了这种先将数据放入容器中等使用结束再销毁的代码风格
    2022-07-07

最新评论