目录
一、前言
原先接口支持1G文件上传,但是最近客户需求变了,需要支持2G文件上传。本着能调参数就不改代码的原则,尝试通过调整JVM来实现大文件上传,最后发现不可行,因为虽然上传的是2G文件,但是实际占用的内存却远远大于2G。后来想着有没有web端的流式文件上传,一边上传的同时可以一边写入文件,也不会占用多大的内存。于是花了不少时间找资料,也没找到我觉得可行或可用的方案(WebSocket可能可以,还没尝试,试过的小伙伴可以回来告诉我一下)。
迫于无奈,只能对文件上传接口进行改造,使用较为通用的文件分片上传。
通过查找了不少文章,发现都很复杂。复杂不说,要不就是代码不完整,要不就是结构不清晰,难以明白其真意。
于是,我按照自己的想法,写了一个简单版本的文件分片上传,简单主要是简单在后台处理上面进行了各种简化,使得文件分片上传更加简单明了。至于需要更加完美的版本,也在此基础上进行完善。或者说,明白了简单版本的文件分片上传,也可以根据自己的想法,做出一版新的,以适应不同的业务需求。
以下将展开对前端和后端的具体操作
二、前端WebUploader
前端的文件分片上传组件,我这里使用的是WebUploader,使用成熟组件的好处是:不用自己写文件切割的相关逻辑,所有分片文件请求发送完毕之后,会触发一个最终确认的请求。
前端相关的代码我是参考以下这篇文章的:
WebUploader - 分片上传_风神修罗使的博客-CSDN博客_webuploader分片上传
WebUpoader具体详细可以参考以下网址:
资源的引入这里我就不说了,想要了解可以参考其他地方
<link th:href="@{/webuploader/webuploader.css}" rel="stylesheet" />
<script th:src="@{/webuploader/webuploader.js}"></script>
以下是我的前端相关代码(非前端工程师,代码看看就好哈),具体含义可以参考注释:
// 文件分片上传
function sliceUpload(addFormData, options) {
// default options
options = $.extend({
folder: '',
success: $.noop,
error: $.noop,
progress: $.noop
}, options);
var errorHandler = options.error;
var successHandler = options.success;
var progressHanlder = options.progress;
//var fileMd5 = uuidv4();
//var runtimeForRuid = new WebUploader.Runtime.Runtime();
var file = addFormData.get("file")
var wuFile = new WebUploader.File(new WebUploader.Lib.File(WebUploader.guid('rt_'), file)); // 待上传文件
var chunkSize = 50 * 1024 * 1024; // 分片大小
var chunked = file.size > chunkSize; // 文件是否需要分片
var chunks = Math.ceil(file.size / chunkSize); // 文件分片块数
//
var events = $.extend({
uploadBeforeSend: function(_, data) {
// 当是分片上传时
if (chunked) {
// 上传前的准备,todo
}
},
uploadProgress: progressHanlder
}, file.type.indexOf('image') >= 0 || !chunked ? {
uploadSuccess: function(_, data) {
// 非分片上传成功回调,todo
},
uploadError: function() {
// 非分片上传失败回调,todo
}
} : {
uploadFinished: function() {
// 所有分片都上传完毕,最终执行逻辑,后台确认文件并且进行合并
var file = uploader.getFiles()[0];
if (!file || file.type.indexOf('image') >= 0) {
return;
}
$.ajax({
method: "POST",
url: "/checkUpload",
contentType: 'application/json; charset=UTF-8',
data: JSON.stringify({
fileName: addFormData.get("fileName"), // 文件名称
size: addFormData.get("size"), // 文件大小
chunks: chunks // 总分片数
}),
success: function (data) {
// 文件确认上传调用成功
},
error: function (){
// 文件确认上传调用失败
}
})
}
});
// 配置webuploader,指定每个分片文件的上传接口等
var uploader = WebUploader.create({
fileNumLimit: 1,
chunked: chunked,
chunkSize: chunkSize,
prepareNextFile: true,
server: '/sliceUpload',
fileVal: 'file',
formData: {
md5: addFormData.get("md5"), // 此处是计算好的文件的md5,作为文件唯一的标识
fileName: addFormData.get("fileName"),
stationId: addFormData.get("stationId")
},
threads: 3, // 开启3个线程并发上传分片,根据自己系统能力调整
});
// 给Uploader绑定上传事件
$.each(events, function(name, handler) {
uploader.on(name, handler);
});
// 开始上传
uploader.addFiles(wuFile);
uploader.upload();
return uploader;
}
三、后端
1、每一个分块文件调用的接口
创建一个文件对象接收类:
/**
* 分片文件接收类
* @date 2022/8/25
*/
@ToString
@Data
public class FileUploadVO {
// 文件唯一标识,并不是分片文件的,而是大文件的
private String md5;
// 文件名称
private String fileName;
// 当前分片序号
private Integer chunk;
// 分片对象,文件对象
private MultipartFile file;
}
创建一个controller,加一个sliceUpload接口,用于分片文件的上传:
@RestCOntroller
public class UploadConroller
{
@Reource
private UploadService uploadService;
/**
* 文件分片上传
* @date 2022/8/29
* @param fileUploadVO 文件分片对象
* @return 返回分片上传结果
*/
@PostMapping("/sliceUpload")
public R<Boolean> sliceUpload(FileUploadVO fileUploadVO){
try{
return uploadService.saveSliceFile(fileUploadVO);
}catch (Exception e){
return new R<>(false, "分片文件保存失败", R.FAIL);
}
}
}
创建一个service类:
public interface UploadService {
/**
* 分片文件上传
* @date 2022/8/29
* @param fileUploadVO 分片文件对象
* @return 返回上传结果
*/
R<Boolean> saveSliceFile(FileUploadVO fileUploadVO) throws Exception;
}
创建上传实现类:
@Slf4j
@Service
public class UploadServiceImpl implements UploadService{
@Value("${save-location}")
private String saveDir;
@Resource
private RedisTemplate<String, String> redisTemplate;
private final static String KEY_PREFIX = "uploader-server";
/**
* 分片文件上传
* @date 2022/8/29
* @param fileUploadVO 分片文件对象
* @return 返回上传结果
*/
@Override
public R<Boolean> saveSliceFile(FileUploadVO fileUploadVO) throws Exception{
// 获取分片文件临时存放位置
File dir = Paths.get(saveDir, "temp").toFile();
// 创建文件夹,同步,避免并发创建
synchronized (this) {
if (!dir.exists() && !dir.isDirectory()) {
boolean mkdirs = dir.mkdirs();
if (!mkdirs) {
log.error("文件保存目錄創建失敗");
String key = KEY_PREFIX + ":slice-file-upload:" + fileUploadVO.getMd5() + ":" + "fail";
// 将失败的文件数进行记录
redisTemplate.boundValueOps(key).increment();
return new R<>(false, "分片文件保存失败", R.FAIL);
}
}
}
InputStream inputStream = null;
FileOutputStream fos = null;
String tempFileName = fileUploadVO.getFileName();
try{
log.info("进行分片文件保存,fileUploadVO = " + fileUploadVO);
inputStream = fileUploadVO.getFile().getInputStream();
if(fileUploadVO.getChunk() != null){
// 存入到临时文件中的文件改为 当前块_文件名
tempFileName = fileUploadVO.getChunk() + "_" + tempFileName;
}
fos = new FileOutputStream(Paths.get(dir.getPath(), tempFileName).toFile());
byte[] bytes = new byte[1024];
int len;
while((len = inputStream.read(bytes)) != -1){
fos.write(bytes, 0, len);
}
// 无异常,向缓存中记录文件成功块数
String key = KEY_PREFIX + ":slice-file-upload:" + fileUploadVO.getMd5() + ":" + "success";
redisTemplate.boundValueOps(key).increment();
log.info("文件分片保存成功,fileUploadVO = " + fileUploadVO);
return new R<>(true, "分片文件保存成功", R.SUCCESS);
}catch (Exception e){
// 出现异常,向缓存中记录文件失败情况
log.error("文件分片保存失败,fileUploadVO = " + fileUploadVO);
log.error("异常堆栈信息:", e);
String key = KEY_PREFIX + ":slice-file-upload:" + fileUploadVO.getMd5() + ":" + "fail";
redisTemplate.boundValueOps(key).increment();
return new R<>(false, "分片文件保存失败", R.FAIL);
}finally {
if(inputStream != null){
inputStream.close();
}
if(fos != null){
fos.close();
}
}
}
将一个文件分片上传之后,你可以在temp文件夹夹中看到所有的分片文件:
0_xxx.filetype
1_xxx.filetype
2_xxx.filetype
...
2、所有分块文件调用完毕之后,将触发该接口,用于合并所有文件与确认文件最终上传情况
创建一个文件确认类:
@Data
@ToString
public class CheckFileUploadVO {
// 文件唯一标识
private String md5;
// 文件名称
private String fileName;
// 文件大小
private String size;
// 总分片数
private Integer chunks;
}
在controller中添加如下接口:
/**
* 检查文件上传情况
* @date 2022/8/29
* @param checkFileUploadVO 文件信息
* @return 返回文件检查情况
*/
@PostMapping("/checkUpload")
public R<Boolean> checkUpload(@RequestBody CheckFileUploadVO checkFileUploadVO){
try{
return uploadService.checkUpload(checkFileUploadVO);
}catch (Exception e){
log.error("文件合并异常,checkFileUploadVO = " + checkFileUploadVO);
log.error("异常堆栈: ", e);
return new R<>(false, "文件上传失败", R.FAIL);
}
}
在service中添加方法声明:
/**
* 检查分片文件上传情况
* @date 2022/8/29
* @param checkFileUploadVO 分片文件检查对象
* @return 返回上传检查结果
*/
R<Boolean> checkUpload(CheckFileUploadVO checkFileUploadVO) throws Exception;
在实现类中添加如下方法:
/**
* 检查分片文件上传情况(检查并且合并文件)
* @date 2022/8/29
* @param checkFileUploadVO 分片文件对象
* @return 返回上传检查结果
*/
public R<Boolean> checkUpload(CheckFileUploadVO checkFileUploadVO) throws Exception{
// 记录分片文件上传成功个数的key
String successKey = KEY_PREFIX + ":slice-file-upload:" + checkFileUploadVO.getMd5() + ":" + "success";
// 记录分片文件上传失败个数的key
String failKey = KEY_PREFIX + ":slice-file-upload:" + checkFileUploadVO.getMd5() + ":" + "fail";
// 缓存中记录的成功分片数
String successNum = redisTemplate.boundValueOps(successKey).get();
// 缓存中记录的失败分片数
String failNum = redisTemplate.boundValueOps(failKey).get();
// 分片上传成功数等于总分片数,进行文件合并,合并之后删除文件所有分片文件,并且进行记录
File[] files = new File[checkFileUploadVO.getChunks()];
for (int i = 0; i < checkFileUploadVO.getChunks(); i++) {
String tempFileName = i + "_" + checkFileUploadVO.getFileName();
// 临时分片文件
files[i] = Paths.get(saveDir, "temp", tempFileName).toFile();
}
// 最终分片文件
File resultFile = Paths.get(saveDir, checkFileUploadVO.getFileName()).toFile();
// 成功个数为空或者失败个数不为空,则文件上传失败
if (StringUtils.isEmpty(successNum) || Integer.parseInt(successNum) < checkFileUploadVO.getChunks() || !StringUtils.isEmpty(failNum)) {
log.error("部分分片文件上传失败,结束文件合并, checkFileUploadVO = " + checkFileUploadVO);
return new R<>(false, "文件上传失败", R.FAIL);
}
try {
log.info("开始进行分片文件合并, checkFileUploadVO = " + checkFileUploadVO);
FileChannel resultFileChannel = new FileOutputStream(resultFile, true).getChannel();
for (int i = 0; i < checkFileUploadVO.getChunks(); i++) {
FileChannel sliceFileChannel = new FileInputStream(files[i]).getChannel();
// 将分片文件写入到最终文件
resultFileChannel.transferFrom(sliceFileChannel, resultFileChannel.size(), sliceFileChannel.size());
sliceFileChannel.close();
}
resultFileChannel.close();
//记录文件上传情况,todo
boolean insert = true;
return new R<>(false, "文件上传成功", R.FAIL);
} catch (Exception e){
log.error("分片文件合并失败, checkFileUploadVO = " + checkFileUploadVO);
log.error("异常堆栈: ", e);
insert = false;
return new R<>(false, "文件上传失败", R.FAIL);
} finally {
// 删除所有分片文件
for (int i = 0; i < checkFileUploadVO.getChunks(); i++) {
if(files[i].exists() && files[i].isFile()){
files[i].delete();
}
}
// 如果记录失败,删除最终文件
if(!insert){
// 如果合并文件存在。也删除
if(resultFile.exists() && resultFile.isFile()){
resultFile.delete();
}
}
// 删除key
redisTemplate.delete(successKey);
redisTemplate.delete(failKey);
}
}
最终将
0_xxx.filetype
1_xxx.filetype
2_xxx.filetype
...
合并为xxx.filetype,并且返回合并结果
3、如果文件不是一个分块文件,是一个没有达到分块条件的文件,就按正常文件上传,此处省略,拿1修改完善下即可。
四、总结
以上只是将大文件分片上传进行了简单化,目的是为了快速实现大文件分片上传,以及充分了解其过程。当然,其中肯定存在不少问题的,需要根据实现业务场景进行优化。