背景
如题,最近遇到大文件上传慢的问题,用户需要经常上传一些超过一百多M的文件,系统由于历史原因上传功能并没有做分片上传的功能,是整个文件上传,并且服务器带宽限制和NGINX对文件大小的限制等问题,所以决定将文件上传功能改为分片上传。
决定将上传功能修改为分片上传后遂百度分片上传的相关开源项目,本项目使用的技术是Vue2+antd+SpringBoot,但是找到的开源项目基本不合适。
前端Vue代码
1、引入依赖
// 引入SparkMD5用于计算文件MD5值
npm install --save SparkMD5
2、编写UI及对应函数
3、设置分片大小
data() {
return {
CHUNK_SIZE: 20 * 1024 * 1024, // 分片上传大小20MB
}
}
4、
async customRequest(data) {
var that = this
// 1、设置文件状态为上传中
for (var ff of this.fileList) {
if (ff.uid === data.file.uid) {
ff.status = 'done'
break;
}
}
let file = data.file;
let time = new Date().getTime();
// 2、求出分片数量、计算文件MD5
let chunks = Math.ceil(file.size / that.CHUNK_SIZE);
let spark = new SparkMD5.ArrayBuffer();
spark.append(file);
let md5 = spark.end();
console.log(`MD5计算完毕:${file.name} \nMD5:${md5} \n分片:${chunks} 大小:${file.size} 用时:${new Date().getTime() - time} ms`);
spark.destroy(); //释放缓存
// 3、循环读取分片并上传
let currentChunk = 0;
// 3.1、读完第一个分片
let blob = loadNext(currentChunk, that.CHUNK_SIZE);
for (currentChunk = 1; currentChunk <= chunks; currentChunk++) {
// 上传分片
var params = {
chunkNumber: currentChunk,
totalChunks: chunks,
chunkSize: that.CHUNK_SIZE,
currentChunkSize: blob.size,
totalSize: file.size,
identifier: md5,
filename: file.name
}
// 3.2、上传文件分片,阻塞等待返回再继续执行
await this.uploadFileChunk(data, blob, params);
// 3.3、加载下一分片
if (currentChunk < chunks) {
blob = loadNext(currentChunk, that.CHUNK_SIZE);
}
}
// 4、发送合并文件请求
var mergeParams = {
totalChunks: chunks,
chunkSize: that.CHUNK_SIZE,
totalSize: file.size,
identifier: md5,
filename: file.name
}
await this.mergeFileChunk(data, mergeParams);
// 获取文件分片方法
function loadNext(currentChunk, CHUNK_SIZE) {
let start = currentChunk * CHUNK_SIZE;
let end = start + CHUNK_SIZE >= file.size ? file.size : start + CHUNK_SIZE;
return file.slice(start, end);
}
},
后端Spring Boot代码
后端代码这块是基于开源项目(金鳞岂是池中物灬 / simple-uploader)做了点小改动,具体代码如下:
文件分片上传类FileChunk
@Data
public class FileChunk {
/**
* 主键id
*/
private Long id;
/**
* 当前块的次序,第一个块是 1,注意不是从 0 开始的
*/
private Integer chunkNumber;
/**
* 文件被分成块的总数。
*/
private Integer totalChunks;
/**
* 分块大小,根据 totalSize 和这个值你就可以计算出总共的块数。注意最后一块的大小可能会比这个要大。
*/
private Integer chunkSize;
/**
* 当前块的大小,实际大小。
*/
private Integer currentChunkSize;
/**
* 文件总大小。
*/
private Long totalSize;
/**
* 这个就是每个文件的唯一标示。
*/
private String identifier;
/**
* 文件名。
*/
private String filename;
/**
* 文件夹上传的时候文件的相对路径属性。
*/
private String relativePath;
/**
* 创建时间
*/
private Date createTime;
/**
* Spring MultipartFile
*/
private MultipartFile file;
}
文件分片上传接口
/**
* Post方法:分片上传
*
* @param fileChunk 分片
* @return AjaxResult
*/
@PostMapping("/upload")
public BaseResponse uploadChunk(FileChunk fileChunk) {
logger.info("上传分片——开始:{}", fileChunk.toString());
if (fileChunk.getFile().isEmpty()) {
logger.error("上传文件不存在!");
throw new RuntimeException("上传文件不存在!");
}
File chunkPath = new File(uploadPath + File.separator + "temp" + File.separator + fileChunk.getIdentifier());
if (!chunkPath.exists()) {
final boolean flag = chunkPath.mkdirs();
if (!flag) {
logger.error("创建目录失败!");
return new BaseResponse().fail("上传失败");
}
}
RandomAccessFile raFile = null;
BufferedInputStream inputStream = null;
try {
File chuckFile = new File(chunkPath, String.valueOf(fileChunk.getChunkNumber()));
raFile = new RandomAccessFile(chuckFile, "rw");
raFile.seek(raFile.length());
inputStream = new BufferedInputStream(fileChunk.getFile().getInputStream());
byte[] buf = new byte[1024];
int length = 0;
while ((length = inputStream.read(buf)) != -1) {
raFile.write(buf, 0, length);
}
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
if (inputStream != null) {
try {
inputStream.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
if (raFile != null) {
try {
raFile.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
logger.info("上传分片——结束:{}", fileChunk.toString());
return new BaseResponse().success();
}
合并文件分片接口
/**
* 合并文件
*
* @param fileChunk 分片信息
* @return AjaxResult
*/
@PostMapping("/merge")
public BaseResponse merge(FileChunk fileChunk) {
logger.info("合并文件——开始:{}", fileChunk.toString());
//分片文件临时目录
File tempPath = new File(uploadPath + File.separator + "temp" + File.separator + fileChunk.getIdentifier());
// 上传的文件
File realFile = new File(uploadPath + File.separator + "temp" + File.separator + fileChunk.getFilename());
// 文件追加写入
FileOutputStream os;
try {
os = new FileOutputStream(realFile, true);
if (tempPath.exists()) {
//获取临时目录下的所有文件
File[] tempFiles = tempPath.listFiles();
//按名称排序
Arrays.sort(tempFiles, (o1, o2) -> {
if (Integer.parseInt(o1.getName()) < Integer.parseInt(o2.getName())) {
return -1;
}
if (Integer.parseInt(o1.getName()) == Integer.parseInt(o2.getName())) {
return 0;
}
return 1;
});
//每次读取10MB大小,字节读取
byte[] bytes = new byte[10 * 1024 * 1024];
int len;
for (int i = 0; i < tempFiles.length; i++) {
FileInputStream fis = new FileInputStream(tempFiles[i]);
while ((len = fis.read(bytes)) != -1) {
os.write(bytes, 0, len);
}
fis.close();
//删除分片
tempFiles[i].delete();
}
os.close();
// TODO:验证合并文件的MD5值是否与传输过来的文件MD5值一致
//删除临时目录
if (tempPath.isDirectory() && tempPath.exists()) {
System.gc(); // 回收资源
tempPath.delete();
}
}
} catch (Exception e) {
logger.error("文件合并——失败 " + e.getMessage());
return new BaseResponse().fail("文件合并失败");
}
logger.info("合并文件——结束:{}", fileChunk.toString());
// 文件合并成功,下一步上传至到阿里云
String ossUrl = uploadFileToOos(realFile, fileChunk.getFilename());
if(StrUtil.isEmpty(ossUrl)){
return new BaseResponse().fail("文件上传失败");
}
Map<String, Object> returnMap = new HashMap<>();
returnMap.put("oosUrl", ossUrl);
returnMap.put("attachmentName", fileChunk.getFilename());
return new BaseResponse().success(returnMap);
}
上述合并文件代码其实缺少了验证文件MD5值这一步,当时写代码时没发现。哈哈。
总结
至此文件分片上传的功能已经开发完毕,基于上述代码其实还可以实现文件秒传、断点续传和失败重试功能。
参考
CSDN博文 vue—大文件分片上传
金鳞岂是池中物灬 / simple-uploader