首页 > 其他分享 >springboot 断点上传、续传、秒传实现

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

时间:2023-10-29 12:13:55浏览次数:40  
标签:文件 续传 上传 req private String 分片 断点 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 参考

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

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

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

 

参考文章:http://blog.ncmem.com/wordpress/2023/10/29/springboot-%e6%96%ad%e7%82%b9%e4%b8%8a%e4%bc%a0%e3%80%81%e7%bb%ad%e4%bc%a0%e3%80%81%e7%a7%92%e4%bc%a0%e5%ae%9e%e7%8e%b0/

欢迎入群一起讨论

 

 

标签:文件,续传,上传,req,private,String,分片,断点,springboot
From: https://www.cnblogs.com/songsu/p/17795699.html

相关文章

  • SpringBoot使用Redis分布式缓存
    Redis是一个key-value存储系统。和Memcached类似,它支持存储的value类型相对更多,包括string(字符串)、list(链表)、set(集合)、zset(sortedset有序集合)和hash(哈希类型)。这些数据类型都支持push/pop、add/remove及取交集并集和差集及更丰富的操作,而且这些操作都是原子性的。在此基......
  • springboot项目实现断点续传功能
    这篇文章主要介绍了springboot项目实现断点续传,本文通过示例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下 java代码packagecom.ruoyi.web.upload.controller;importcom.ruoyi.web.upload.dto.FileChunkDTO;importcom.ruoyi.......
  • 一些研发工程师在Springboot注意点
    一些研发工程师在Springboot注意点1.正确设计代码目录结构虽然您有很大的自由度,但有一些基本规则值得遵循来设计您的源代码结构。避免使用默认包。确保所有内容(包括入口点)都在命名良好的包中,这样您就可以避免与组装和组件扫描相关的意外情况;将Application.java(应用程序的入口类)......
  • SpringBoot 自定义注解实现过程
    1、新建SpringBoot-Test 其中pom.xml文件如下:<?xmlversion="1.0"encoding="UTF-8"?><projectxmlns="http://maven.apache.org/POM/4.0.0"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schem......
  • 10G 大文件、秒传、断点续传、分片上传
    超大文件上传是一个老生常谈的话题了,在文件相对比较小的情况下,可以直接把文件转化为字节流上传到服务器,但在文件比较大的情况下,用普通的方式进行上传,这可不是一个好的办法,毕竟很少有人会忍受,当文件上传到一半中断后,继续上传却只能重头开始上传,这种让人不爽的体验。那有没有比较好......
  • 断点下载帮助方法
    核心代码publicstaticclassDownloadHelper{///<summary>///断点下载///</summary>///<paramname="controller"></param>///<paramname="fullpath"></param>///<returns......
  • 前端大文件断点续传
    昨天整理了前端实现大文件上传通过文件切片进行处理,今天继续拓展进行断点续传原理断点续传的原理在于前端/服务端需要记住已上传的切片,这样下次上传就可以跳过之前已上传的部分,有两种方案实现记忆的功能前端使用localStorage记录已上传的切片hash服务端保存已上传的切片hash......
  • 详解SpringBoot @Conditional相关条件注解
    Springboot条件注解是@ContionalXXX相关的注解,表示当特定条件有效时,被修饰的配置类或配置方法才会生效。条件注解可以用来修饰@Configuration类或@Bean方法等。主要有以下行为:当SpringBoot检测到类加载路径包含某个框架时,会自动配置该框架的基础Bean.只有当开发者没配置某......
  • Springboot+Mybatis+Mybatisplus 框架中增加自定义分页插件和sql 占位符修改插件
    一、Springboot简介springboot是当下最流行的web框架,SpringBoot是由Pivotal团队提供的全新框架,其设计目的是用来简化新Spring应用的初始搭建以及开发过程。该框架使用了特定的方式来进行配置,从而使开发人员不再需要定义样板化的配置,让这些框架集成在一起变得更加简单,简化了我......
  • 用springBoot、netty写TCP客户端/服务端,并用TCP工具测试
    1.启动客户端和连接服务端packagecom.pkx.cloud.test.netty;importio.netty.bootstrap.Bootstrap;importio.netty.channel.*;importio.netty.channel.nio.NioEventLoopGroup;importio.netty.channel.socket.SocketChannel;importio.netty.channel.socket.nio.NioSock......