背景:
公司后台管理系统有个需求,需要上传体积比较大的文件:500M-1024M;此时普通的文件上传显然有些吃力了,加上我司服务器配置本就不高,带宽也不大,所以必须考虑多线程异步上传来提速;所以这里就要用到文件分片上传技术了。
技术选型:
直接问GPT实现大文件分片上传比较好的解决方案,它给的答案是webUploader(链接是官方文档);这是由 Baidu FEX 团队开发的一款以 HTML5 为主,FLASH 为辅的现代文件上传组件。在现代的浏览器里面能充分发挥 HTML5 的优势,同时又不摒弃主流IE浏览器,沿用原来的 FLASH 运行时,兼容 IE6+,iOS 6+, android 4+。采用大文件分片并发上传,极大的提高了文件上传效率;功能强大且齐全,支持对文件内容的Hash计算和分片上传,可实现上传进度条等功能。
实现原理:
文件分片上传比较简单,就不画图了,前端(webUploader)将用户选择的文件根据开发者配置的分片参数进行分片计算,将文件分成N个小文件多次调用后端提供的分片文件上传接口(webUploader插件有默认的一套参数规范,文件ID及分片相关字段,后端将对保存分片临时文件),后端记录并判断当前文件所有分片是否上传完毕,若已上传完则将所有分片合并成完整的文件,完成后建议删除分片临时文件(若考虑做分片下载可以保留)。
前端引入webUploader:
这里推荐去CDN下载静态资源:
记得要先引入JQuery,webUploader依赖JQuery;前端页面引入CSS和JS文件即可,Uploader.swf文件在创建webUploader对象时指定,貌似用来做兼容的。
前端(笔者前端用的layui)核心代码:
//百度文件上传插件 WebUploader
let uploader = WebUploader.create({
// 选完文件后,是否自动上传。
auto: true,
// swf文件路径
swf: contextPath + '/static/plugin/webuploader/Uploader.swf',
pick: {
id: '#webUploader',
multiple: false
},
// 文件接收服务端。
server: contextPath + '/common/file/shard/upload',
// 文件分片上传相关配置
chunked: true,
chunkSize: 5 * 1024 * 1024, // 分片大小为 5MB
chunkRetry: 3, // 上传失败最大重试次数
threads: 5, // 同时最大上传线程数
});
//文件上传临时对象
let fileUpload = {
idPrefix: '' //文件id前缀
, genIdPrefix: function () {
this.idPrefix = new Date().getTime() + '_';
}
, mergeLoading: null //合并文件加载层
, lastUploadResponse: null // 最后一次上传返回值
, chunks: 0 // 文件分片数
, uploadedChunks: 0 // 已上传文件分片数
, sumUploadChunk: function () {
if (this.chunks > 0) {
this.uploadedChunks++;
}
}
, checkResult: function () {
if (this.uploadedChunks < this.chunks) {
layer.open({
title: '系统提示'
, content: '文件上传失败,请重新上传!'
, btn: ['我知道了']
});
}
}
};
// 某个文件开始上传前触发,一个文件只会触发一次
uploader.on('uploadStart', function (file) {
$('#uploadProgressBar').show();
// 生成文件id前缀
fileUpload.genIdPrefix();
});
// 当某个文件的分块在发送前触发,主要用来询问是否要添加附带参数,大文件在开起分片上传的前提下此事件可能会触发多次
uploader.on('uploadBeforeSend', function (object, data, header) {
// 重写文件id生成规则
data.id = fileUpload.idPrefix + data.name;
fileUpload.chunks = data.chunks != null ? data.chunks : 0;
});
uploader.on('uploadProgress', function (file, percentage) {
// 更新进度条
let value = Math.round(percentage * 100);
element.progress('progressBar', value + '%');
if (value == 100) {
fileUpload.mergeLoading = layer.load();
}
});
// 获取最后上传成功的文件信息,每个分片文件上传都会回调
uploader.on('uploadAccept', function (file, response) {
if (response == null || response.code !== '0000') {
return;
}
fileUpload.sumUploadChunk();
if (response.data != null && response.data.fileAccessPath != null) {
fileUpload.lastUploadResponse = response.data;
}
});
// 文件上传成功时触发
uploader.on('uploadSuccess', function (file, response) {
console.log('File ' + file.name + ' uploaded successfully.');
layer.msg('文件上传成功!');
$('#fileName').val(fileUpload.lastUploadResponse.fileOriginalName);
$('#fileRelativePath').val(fileUpload.lastUploadResponse.fileRelativePath);
})
uploader.on('uploadComplete', function (file) {
console.log('File' + file.name + 'uploaded complete.');
console.log('总分片:' + fileUpload.chunks + ' 已上传:' + fileUpload.uploadedChunks);
fileUpload.checkResult();
$('#uploadProgressBar').hide();
layer.close(fileUpload.mergeLoading);
});
其中几个关键的节点的事件回调都提供了,使用起来很方便;其中“uploadProgress”事件实现了上传的实时进度条展示。
后端Controller代码:
/**
* 文件分片上传
*
*
* @param file
* @param fileUploadInfoDTO
* @return
*/
@PostMapping(value = "shard/upload")
public Layui<FileUploadService.FileBean> uploadFileByShard(@RequestParam("file") MultipartFile file,
FileUploadInfoDTO fileUploadInfoDTO) {
if (null == fileUploadInfoDTO) {
return Layui.error("文件信息为空");
}
if (null == file || file.getSize() <= 0) {
return Layui.error("文件内容为空");
}
log.info("fileName=[{}]", file.getName());
log.info("fileSize=[{}]", file.getSize());
log.info("fileShardUpload=[{}]", JSONUtil.toJsonStr(fileUploadInfoDTO));
FileUploadService.FileBean fileBean = fileShardUploadService.uploadFileByShard(fileUploadInfoDTO, file);
return Layui.success(fileBean);
}
/**
* @Author: XiangPeng
* @Date: 2023/12/22 12:01
*/
@Getter
@Setter
public class FileUploadInfoDTO implements Serializable {
private static final long serialVersionUID = -1L;
/**
* 文件 ID
*/
private String id;
/**
* 文件名
*/
private String name;
/**
* 文件类型
*/
private String type;
/**
* 文件最后修改日期
*/
private String lastModifiedDate;
/**
* 文件大小
*/
private Long size;
/**
* 分片总数
*/
private int chunks;
/**
* 当前分片序号
*/
private int chunk;
}
@Getter
@Setter
public class FileUploadCacheDTO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 文件 ID
*/
private String id;
/**
* 文件名
*/
private String name;
/**
* 分片总数
*/
private int chunks;
/**
* 当前已上传分片索引
*/
private List<Integer> uploadedChunkIndex;
public FileUploadCacheDTO(FileUploadInfoDTO fileUploadInfoDTO) {
this.id = fileUploadInfoDTO.getId();
this.name = fileUploadInfoDTO.getName();
this.chunks = fileUploadInfoDTO.getChunks();
this.uploadedChunkIndex = Lists.newArrayList();
}
public FileUploadCacheDTO() {
}
}
后端Service层代码:
/**
* 文件分片上传
*
* @param fileUploadInfoDTO
* @param file
* @return
*/
public FileBean uploadFileByShard(FileUploadInfoDTO fileUploadInfoDTO, MultipartFile file) {
if (fileUploadInfoDTO == null || file == null) {
throw new ServiceException("文件上传失败!");
}
// 无需分片的小文件直接上传
if (fileUploadInfoDTO.getChunks() <= 0) {
return super.commonUpload(file);
}
String fileId = fileUploadInfoDTO.getId();
// 生成分片临时文件,文件名格式:文件id_分片序号
FileBean fileBean = super.commonUpload(fileId + StrUtil.UNDERLINE + fileUploadInfoDTO.getChunk(), file);
// redis缓存数据
FileUploadCacheDTO fileUploadInfo = null;
synchronized (this) {
// 查询文件id是否存在,不存在则创建,存在则更新已上传分片数
fileUploadInfo = (FileUploadCacheDTO) redisService.get(genRedisKey(fileId));
// 第一个分片文件上传
if (fileUploadInfo == null) {
fileUploadInfo = new FileUploadCacheDTO(fileUploadInfoDTO);
}
fileUploadInfo.getUploadedChunkIndex().add(fileUploadInfoDTO.getChunk());
redisService.set(genRedisKey(fileId), fileUploadInfo);
// 判断所有分片文件是否上传完成
if ((fileUploadInfo.getUploadedChunkIndex().size()) < fileUploadInfo.getChunks()) {
return fileBean;
}
}
// 合并文件
return mergeChunks(fileUploadInfo);
}
/**
* 分片文件全部上传完成则合并文件,清除缓存并返回文件地址
*
* @param fileUploadCache
* @return
*/
private FileBean mergeChunks(FileUploadCacheDTO fileUploadCache) {
String mergeFileRelativePath = super.getCommonPath().getFileRelativePath() + fileUploadCache.getId();
String mergeFilePath = super.getCommonPath().getBasePath() + mergeFileRelativePath;
RandomAccessFile mergedFile = null;
File chunkTempFile = null;
RandomAccessFile chunkFile = null;
try {
mergedFile = new RandomAccessFile(mergeFilePath, "rw");
for (int i = 0; i < fileUploadCache.getChunks(); i++) {
// 读取分片文件
chunkTempFile = new File(
super.getCommonPath().getFileFullPath() + fileUploadCache.getId() + StrUtil.UNDERLINE + i);
byte[] buffer = new byte[1024 * 1024];
int bytesRead;
chunkFile = new RandomAccessFile(chunkTempFile, "r");
// 合并分片文件
while ((bytesRead = chunkFile.read(buffer)) != -1) {
mergedFile.write(buffer, 0, bytesRead);
}
chunkFile.close();
}
} catch (IOException e) {
log.error("merge file chunk error, fileId=[{}]", fileUploadCache.getId(), e);
} finally {
try {
if (mergedFile != null) {
mergedFile.close();
}
} catch (IOException e) {
}
redisService.remove(genRedisKey(fileUploadCache.getId()));
// 删除分片文件
removeChunkFiles(super.getCommonPath().getFileFullPath(), fileUploadCache);
}
return FileBean.builder().fileOriginalName(fileUploadCache.getName()).fileRelativePath(mergeFileRelativePath)
.fileAccessPath(super.getNginxPath() + mergeFileRelativePath).build();
}
private void removeChunkFiles(String fileFullPathPrefix, FileUploadCacheDTO fileUploadCache) {
taskExecutor.execute(() -> {
try {
// 延迟1秒删除
TimeUnit.SECONDS.sleep(1);
String fileFullPath;
for (int i = 0; i < fileUploadCache.getChunks(); i++) {
try {
fileFullPath = fileFullPathPrefix + fileUploadCache.getId() + StrUtil.UNDERLINE + i;
FileUtil.del(fileFullPath);
log.info("file[{}] delete success.", fileFullPath);
} catch (Exception e) {
log.error("delete temp file error.", e);
}
}
} catch (Exception e) {
log.error("delete temp chunk file error.", e);
}
});
}
private String genRedisKey(String id) {
return FILE_SHARD_UPLOAD_KEY + id;
}