Java 大文件分片上传
原理:前端通过js读取文件,并将大文件按照指定大小拆分成多个分片,并且计算每个分片的MD5值。前端将每个分片分别上传到后端,后端在接收到文件之后验证当前分片的MD5值是否与上传的MD5一致,待所有分片上传完成之后后端将多个分片合并成一个大文件,并校验该文件的MD5值是否与上传时传入的MD5值一致;
首先是交互的控制器
支持文件分片上传,查询当前已经上传的分片信息,取消文件上传
package com.aimilin.component.system.service.modular.file.controller;
import com.aimilin.common.core.pojo.base.param.BaseParam;
import com.aimilin.common.core.pojo.response.ResponseData;
import com.aimilin.common.log.annotation.BusinessLog;
import com.aimilin.common.log.enums.LogOpTypeEnum;
import com.aimilin.common.security.annotation.Permission;
import com.aimilin.component.system.service.modular.file.param.SysPartFileParam;
import com.aimilin.component.system.service.modular.file.result.SysPartFileResult;
import com.aimilin.component.system.service.modular.file.service.SysPartFileService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
/**
* 系统大文件上传
*
* @version V1.0
* @date 2022/5/24 11:22
*/
@Slf4j
@RestController
public class SysPartFileController {
@Resource
private SysPartFileService sysPartFileService;
/**
* 上传大文件
*
*/
@Permission
@PostMapping("/sysFileInfo/partUpload")
public ResponseData<SysPartFileResult> partUpload(@Validated(BaseParam.add.class) SysPartFileParam partFile) {
return ResponseData.success(sysPartFileService.partUpload(partFile));
}
/**
* 获取文件上传状态
*
*/
@Permission
@GetMapping("/sysFileInfo/partUpload/status")
public ResponseData<SysPartFileResult> getPartUploadStatus(@Validated(BaseParam.detail.class) SysPartFileParam partFile) {
return ResponseData.success(sysPartFileService.getPartUploadStatus(partFile));
}
/**
* 获取文件上传状态
*
*/
@Permission
@GetMapping("/sysFileInfo/partUpload/cancel")
@BusinessLog(title = "文件_上传大文件_取消", opType = LogOpTypeEnum.OTHER)
public ResponseData<SysPartFileResult> cancelUpload(@Validated(BaseParam.detail.class) SysPartFileParam partFile) {
return ResponseData.success(sysPartFileService.cancelUpload(partFile));
}
}
上传文件分片参数接收
如果按照分片方式上传文件需要指定当前大文件的MD5、分片MD5、分片内容、分片大小、当前文件名称、文件总大小等信息;另外对于每个文件前端都需要生成一个唯一编码用于确定当前上传的分片属于统一文件。
package com.aimilin.component.system.service.modular.file.param;
import java.io.Serializable;
import java.util.Objects;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.aimilin.common.core.pojo.base.param.BaseParam;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.springframework.web.multipart.MultipartFile;
import javax.validation.constraints.NotNull;
/**
* 大文件断点续传
*
* @version V1.0
* @date 2022/5/24 10:52
*/
@Getter
@Setter
@ToString
public class SysPartFileParam extends BaseParam implements Serializable {
/**
* 文件上传Id, 前端传入的值
*/
@NotNull(message = "uid不能为空", groups = {BaseParam.detail.class, BaseParam.add.class})
private String uid;
/**
* 上传文件名称
*/
private String filename;
/**
* 当前文件块,从1开始
*/
@NotNull(message = "partNumber不能为空", groups = {BaseParam.add.class})
private Integer partNumber;
/**
* 当前分块Md5
*/
@NotNull(message = "partMd5不能为空", groups = {BaseParam.add.class})
private String partMd5;
/**
* 分块大小,根据 totalSize 和这个值你就可以计算出总共的块数。注意最后一块的大小可能会比这个要大。
*/
@NotNull(message = "partSize不能为空", groups = {BaseParam.add.class})
private Long partSize;
/**
* 总大小
*/
@NotNull(message = "totalSize不能为空", groups = {BaseParam.add.class})
private Long totalSize;
/**
* 文件标识,MD5指纹
*/
@NotNull(message = "fileMd5不能为空", groups = {BaseParam.add.class})
private String fileMd5;
/**
* 二进制文件
*/
@NotNull(message = "file不能为空", groups = {BaseParam.add.class})
private MultipartFile file;
/**
* 总块数, (int)totalSize / partSize 最后一个模块要大一点;
*
* @return 结果
*/
public Integer getTotalParts() {
if (Objects.isNull(totalSize) || Objects.isNull(partSize)) {
return 0;
}
return new Double(Math.ceil(totalSize * 1.0 / partSize)).intValue();
}
public String getFilename() {
if (StringUtils.isBlank(this.filename) && Objects.isNull(this.file)) {
return null;
}
return StringUtils.isBlank(this.filename) ? this.file.getOriginalFilename() : this.filename;
}
}
至于代码中的 BaseParam 类,只是定义了一些验证的分组,类似以下代码:
/**
* 参数校验分组:分页
*/
public @interface page {
}
/**
* 参数校验分组:列表
*/
public @interface list {
}
/**
* 参数校验分组:下拉
*/
public @interface dropDown {
}
/**
* 参数校验分组:增加
*/
public @interface add {
}
大文件分片上传服务类实现
也是定义了三个接口,分片上传、查询当前已上传的分片、取消文件上传
package com.aimilin.component.system.service.modular.file.service;
import com.aimilin.component.system.service.modular.file.param.SysPartFileParam;
import com.aimilin.component.system.service.modular.file.result.SysPartFileResult;
/**
* 块文件上传
*
* @version V1.0
* @date 2022/5/24 10:59
*/
public interface SysPartFileService {
/**
* 文件块上传公共前缀
*/
public static final String PART_FILE_KEY = "PART_FILE";
/**
* 文件块上传
* 1. 将上传文件按照partSize拆分成多个文件块
* 2. 判断当前文件块是否已经上传
* 3. 未上传,则上传当前文本块
* 4. 已上传则不处理
* 5. 统计当前文本块上传进度信息
* 6. 判断所有文本块是否已经上传完成,如果上传完成则触发文件合并
*/
public SysPartFileResult partUpload(SysPartFileParam partFile);
/**
* 获取文件上传状态
*
* @param partFile 上传文件信息
* @return 文件上传状态结果
*/
public SysPartFileResult getPartUploadStatus(SysPartFileParam partFile);
/**
* 取消文件上传
*
* @param partFile 上传文件信息
* @return 文件上传状态结果
*/
public SysPartFileResult cancelUpload(SysPartFileParam partFile);
}
服务实现类:
package com.aimilin.component.system.service.modular.file.service.impl;
import cn.hutool.core.io.FileUtil;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.aimilin.common.base.file.FilePartOperator;
import com.aimilin.common.base.file.param.AbortMultipartUploadResult;
import com.aimilin.common.base.file.param.CompleteFileUploadPart;
import com.aimilin.common.base.file.param.FileUploadPart;
import com.aimilin.common.base.file.param.FileUploadPartResult;
import com.aimilin.common.cache.RedisService;
import com.aimilin.common.core.consts.CommonConstant;
import com.aimilin.common.core.context.login.LoginContextHolder;
import com.aimilin.common.core.exception.ServiceException;
import com.aimilin.component.system.service.modular.file.convert.SysPartFileConvert;
import com.aimilin.component.system.service.modular.file.entity.SysFileInfo;
import com.aimilin.component.system.service.modular.file.enums.SysFileInfoExceptionEnum;
import com.aimilin.component.system.service.modular.file.enums.SysPartFileEnum;
import com.aimilin.component.system.service.modular.file.param.SysPartFileParam;
import com.aimilin.component.system.service.modular.file.result.SysPartFileCache;
import com.aimilin.component.system.service.modular.file.result.SysPartFileCache.FileInfo;
import com.aimilin.component.system.service.modular.file.result.SysPartFileCache.SysFilePart;
import com.aimilin.component.system.service.modular.file.result.SysPartFileResult;
import com.aimilin.component.system.service.modular.file.service.SysFileInfoService;
import com.aimilin.component.system.service.modular.file.service.SysPartFileService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.TimeUnit;
import static com.aimilin.component.system.service.config.FileConfig.DEFAULT_BUCKET;
/**
* 大文件上传功能服务实现
*
* @version V1.0
* @date 2022/5/24 11:53
*/
@Slf4j
@Service
public class SysPartFileServiceImpl implements SysPartFileService {
@Resource
private FilePartOperator fileOperator;
@Resource
private RedisService redisService;
@Resource
private SysFileInfoService sysFileInfoService;
@Resource
private RedissonClient redisson;
/**
* 文件块上传
* 1. 将上传文件按照partSize拆分成多个文件块
* 2. 判断当前文件块是否已经上传
* 3. 未上传,则上传当前文本块
* 4. 已上传则不处理
* 5. 统计当前文本块上传进度信息
* 6. 判断所有文本块是否已经上传完成,如果上传完成则触发文件合并
*
* @param partFile 上传文件
* @return SysPartFileResult 文件上传结果
*/
@Override
public SysPartFileResult partUpload(SysPartFileParam partFile) {
MultipartFile file = partFile.getFile();
log.info("分块上传文件:{}, partNumber:{}/{}, partSize:{}/{}",
partFile.getFilename(), partFile.getPartNumber(), partFile.getTotalParts(), file.getSize(), partFile.getPartSize());
SysPartFileResult partUploadStatus = this.getPartUploadStatus(partFile);
// 已经上传该部分则直接返回当前文件状态
if (SysPartFileEnum.SUCCESS.getCode().equals(partUploadStatus.getPartState())) {
return partUploadStatus;
}
// 上传分片文件
FileUploadPart fileUploadPart = this.getFileUploadPart(partFile);
try {
FileUploadPartResult uploadPartResult = fileOperator.uploadPart(fileUploadPart);
this.setPartUploadStatus(partFile, uploadPartResult);
} catch (Exception e) {
log.error("文件分片上传失败,请求:{}:{}", partFile, e.getMessage(), e);
throw new ServiceException(SysFileInfoExceptionEnum.FILE_OSS_ERROR);
}
return this.getPartUploadStatus(partFile);
}
/**
* 获取文件上传状态
*
* @param partFile 上传文件信息
* @return 文件上传状态结果
*/
@Override
public SysPartFileResult getPartUploadStatus(SysPartFileParam partFile) {
SysPartFileCache fileCache = redisService.getCacheObject(getPartFileKey(partFile.getUid()));
SysPartFileResult result;
// 如果没有上传过则返回默认值
if (Objects.isNull(fileCache)) {
result = SysPartFileConvert.INSTANCE.toSysPartFileResult(partFile);
result.setFileState(SysPartFileEnum.NOT_EXISTS.getCode());
result.setPartState(SysPartFileEnum.NOT_EXISTS.getCode());
} else {
result = SysPartFileConvert.INSTANCE.toSysPartFileResult(fileCache, fileCache.getFilePart(partFile.getPartNumber()));
}
return result;
}
/**
* 取消文件上传
*
* @param partFile 上传文件信息
* @return 文件上传状态结果
*/
@Override
public SysPartFileResult cancelUpload(SysPartFileParam partFile) {
String cacheKey = getPartFileKey(partFile.getUid());
SysPartFileCache fileCache = redisService.getCacheObject(cacheKey);
if (Objects.isNull(fileCache)) {
throw new ServiceException(SysFileInfoExceptionEnum.NOT_EXISTED_FILE);
}
SysPartFileCache.FileInfo fileInfo = fileCache.getFileInfo();
fileOperator.abortMultipartUpload(fileInfo.getBucketName(), fileInfo.getObjectName(), fileInfo.getUploadId());
log.info("取消文件上传:{}", partFile.getUid());
SysPartFileResult sysPartFileResult = SysPartFileConvert.INSTANCE.toSysPartFileResult(partFile);
sysPartFileResult.setFileState(SysPartFileEnum.CANCELED.getCode());
redisService.deleteObject(cacheKey);
return sysPartFileResult;
}
/**
* 文件分片上传,设置文件分片信息
*
* @param partFile 分片文件参数
* @param uploadPartResult 文件上传结果信息
*/
private void setPartUploadStatus(SysPartFileParam partFile, FileUploadPartResult uploadPartResult) {
String redisKey = getPartFileKey(partFile.getUid());
if (!redisService.hasKey(redisKey)) {
throw new ServiceException(SysFileInfoExceptionEnum.FILE_CACHE_ERROR);
}
RLock lock = redisson.getLock(CommonConstant.getLockKey(redisKey));
try {
lock.lock();
SysPartFileCache fileCache = redisService.getCacheObject(redisKey);
Set<SysFilePart> filePartList = fileCache.getFilePartList();
if (Objects.isNull(filePartList)) {
filePartList = new HashSet<>();
fileCache.setFilePartList(filePartList);
}
SysFilePart sysFilePart = new SysFilePart();
sysFilePart.setPartNumber(partFile.getPartNumber());
sysFilePart.setPartState(SysPartFileEnum.SUCCESS.getCode());
sysFilePart.setPartMd5(partFile.getPartMd5());
sysFilePart.setPartSize(partFile.getFile().getSize());
sysFilePart.setFileUploadPartResult(uploadPartResult);
filePartList.add(sysFilePart);
fileCache.setFileState(SysPartFileEnum.UPLOADING.getCode());
// 所有文本块都已经上传完成
if (new HashSet<>(fileCache.getUploadedParts()).size() == fileCache.getTotalParts()) {
CompleteFileUploadPart completeFileUploadPart = SysPartFileConvert.INSTANCE.toCompleteFileUploadPart(fileCache);
fileOperator.completeMultipartUpload(completeFileUploadPart);
log.info("文件合并完成:{},part: {}/{}", partFile.getFilename(), partFile.getPartNumber(), partFile.getTotalParts());
this.saveFileInfo(partFile, fileCache);
fileCache.setFileState(SysPartFileEnum.SUCCESS.getCode());
redisService.setCacheObject(redisKey, fileCache, 1L, TimeUnit.DAYS);
} else {
redisService.setCacheObject(redisKey, fileCache);
}
} catch (Exception e) {
log.error("设置文件分片上传状态异常,{},上传结果:{}", partFile, uploadPartResult, e);
throw new ServiceException(SysFileInfoExceptionEnum.PART_FILE_SET_STATE_ERROR);
}finally {
lock.unlock();
}
}
/**
* 保存文件信息到 数据库
*
* @param partFile 分片文件
* @param fileCache 文件缓存对象
*/
private void saveFileInfo(SysPartFileParam partFile, SysPartFileCache fileCache) {
SysFileInfo sysFileInfo = new SysFileInfo();
sysFileInfo.setId(Objects.isNull(fileCache.getFileId()) ? IdWorker.getId() : fileCache.getFileId());
sysFileInfo.setFileLocat