springboot断点上传、续传、秒传实现方式
作者: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; }
总结
1.检查文件块上传进度或秒传
- 根据文件md5查询附件信息表,如果存在,直接返回附件信息。
- 不存在查询分片信息表,查询当前文件分片上传进度,返回已经上传过的分片编号
2.上传分片
- 分片文件上传地址需要保证唯一性,可用文件MD5作为隔离
- 上传后保存分片上传信息
- minio对合并分片文件有大小限制,除最后一个分片外,其他分片文件大小不得小于5MB,所以minio分片上传需要分片大小最小为5MB,并且获取分片需要按照分片文件大小排序,将最后一个分片放到最后进行合并
3.分片合并
- 将分片文件合并为新文件到最终文件存放地址并删除分片文件
- 保存最终文件信息到附件信息表
- 删除对应分片信息表数据
总结
以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。