本文用于整理记录大文件分片上传、断点续传、极速秒传的Java版简单实现。
关于上传的文章 FTP文件上传下载
分片上传的核心思路:
分片上传到意义:
该实例是一个串行上传分片数据的实例,一个文件仅在数据库中保存了一条记录,每次上传一个分片时更新一次该记录,直到该文件到所有分片上传完成。
1.2.3.1 template
模板部分包含一个“上传”Button
,和一个隐藏的 <input type="file">
。
点击 Button
触发 input
从而选择文件并上传。
<template>
<div>
<button type="button" v-on:click="selectFile()" class="btn btn-white btn-default btn-round">
<i class="ace-icon fa fa-upload"></i>{{text}}
</button>
<input class="hidden" type="file" ref="file" v-on:change="uploadFile()" v-bind:id="inputId+'-input'">
</div>
</template>
1.2.3.2 selectFile
点击 Button
【上传】,触发 隐藏 input 的点击事件,选择文件。
/**
* 点击【上传】
*/
selectFile () {
let _this = this;
$("#" + _this.inputId + "-input").trigger("click");
},
1.2.3.3 uploadFile
检测到选择好文件,input 执行该方法,完成文件上传。
/**
* 上传文件
*/
uploadFile() {
let _this = this;
// 1. 获取 input 中被选中的文件
let file = _this.$refs.file.files[0];
// 2. 生成文件标识,标识多次上传的是不是同一个文件
let key = hex_md5(file.name + file.size + file.type);
let key10 = parseInt(key, 16);
let key62 = Tool._10to62(key10);
// 判断文件格式 (非必选,根据实际情况选择是否需要限制文件上传类型)
let suffixs = _this.suffixs;
let fileName = file.name;
let suffix = fileName.substring(fileName.lastIndexOf(".")+1, fileName.length).toLowerCase();
if(!(!suffixs || JSON.stringify(suffixs) === "{}" || suffixs.length === 0)) {
let validateSuffix = false;
for(let s of suffixs) {
if(s.toLocaleLowerCase() === suffix) {
validateSuffix = true;
break;
}
}
if(!validateSuffix) {
Toast.warning("文件格式不正确!只支持上传:" + suffixs.join(","));
$("#" + _this0.inputId + "-input").val("");
return;
}
}
// 3. 文件分片开始
// 3.1 设置与计算分片必选参数
let shardSize = 20 * 1024 *1024; // 20M为一个分片
let shardIndex = 1; // 分片索引,1表示第1个分片
let size = file.size; // 文件的总大小
let shardTotal = Math.ceil(size / shardSize); // 总分片数
// 3.2 拼接将要传递到参数, use 非必选,这里用来标识文件用途。
let param = {
'shardIndex': shardIndex,
'shardSize': shardSize,
'shardTotal': shardTotal,
'use': _this.use,
'name': file.name,
'suffix': suffix,
'size': file.size,
'key': key62
};
// 3.3 传递分片参数,通过递归完成分片上传。
_this.upload(param);
},
1.2.3.4 upload
递归上传分片的过程
/**
* 递归上传分片
*/
upload(param) {
let _this = this;
let shardIndex = param.shardIndex;
let shardTotal = param.shardTotal;
let shardSize = param.shardSize;
// 3.3.1 根据参数,获取文件分片
let fileShard = _this.getFileShard(shardIndex,shardSize);
// 3.3.2 将文件分片转为base64进行传输
let fileReader = new FileReader();
// 读取并转化 fileShard 为 base64
fileReader.readAsDataURL(fileShard);
// readAsDataURL 读取后的回调,
// 将 经过 base64 编码的 分片 整合到 param ,发送给后端,从而上传分片。
fileReader.onload = function (e) {
let base64 = e.target.result;
param.shard = base64;
Loading.show();
_this.$ajax.post(process.env.VUE_APP_SERVER + "/file/admin/big-upload", param).then((res)=> {
Loading.hide();
let resp = res.data;
// 上传结果
// 当前分片索引小于 分片总数,继续执行分派,反之 则表示全部上传成功。
if(shardIndex < shardTotal) {
// 上传下一个分片
param.shardIndex = param.shardIndex + 1;
_this.upload(param);
} else {
// 文件上传成功后的回调
_this.afterUpload(resp);
}
$("#" + _this.inputId + "-input").val("");
});
};
},
1.2.3.5 getFileShard
1.2.3.4 upload 中根据传参,使用slice进行文件分片的函数。
/**
* 文件分片函数
*/
getFileShard(shardIndex, shardSize) {
let _this = this;
let file = _this.$refs.file.files[0];
let start = (shardIndex - 1) * shardSize; // 当前分片起始位置
let end = Math.min(file.size, start + shardSize); // 当前分片结束位置
let fileShard = file.slice(start, end); // 从文件中截取当前的分片数据
return fileShard;
},
该部分由上传api函数和合并函数组成。
1.2.4.1 uploadOfMerge
文件上传的api函数,前端将分片数据通过该api上传并保存到对应目录下,当全部分片上传成功,将所有分片合并成文件,同时将相关信息保存到数据库。
合并部分可以考虑通过定时任务、MQ等方式优化。
@PostMapping("/big-upload")
public ResponseDto uploadOfMerge(@RequestBody FileDto fileDto) throws IOException {
log.info("上传文件开始");
String use = fileDto.getUse();
String key = fileDto.getKey();
String suffix = fileDto.getSuffix();
String shardBase64 = fileDto.getShard();
// 1. 将分片转为 MultipartFile
MultipartFile shard = Base64ToMultipartFile.base64ToMultipart(fileDto.getShard());
// 获取分片要保存到的路径
// 根据use字段获取文件用途,从而上传到不同文件夹下(非必选)
FileUseEnum useEnum = FileUseEnum.getByCode(use);
// 若文件夹不存在则创建
String dir = useEnum.name().toLowerCase();
File fullDir = new File(FILE_PATH + dir);
if (!fullDir.exists()) {
fullDir.mkdir();
}
String path = new StringBuffer(dir)
.append(File.separator)
.append(key)
.append(".")
.append(suffix).toString(); // course\6sfSqfOwzmik4A4icMYuUe.mp4
String localPath = new StringBuffer(path)
.append(".")
.append(fileDto.getShardIndex()).toString(); // course\6sfSqfOwzmik4A4icMYuUe.mp4.1
String fullPath = FILE_PATH + localPath;
// 2. 通过 transferTo 保存文件到服务器磁盘
File dest = new File(fullPath);
shard.transferTo(dest);
log.info(dest.getAbsolutePath());
// 3. 将文件分片信息保存/更新到数据库
log.info("保存文件记录开始");
fileDto.setPath(path);
fileService.saveBigFile(fileDto);
ResponseDto responseDto = new ResponseDto();
responseDto.setContent(fileDto);
// 4. 合并
// 若分片均已上传,将所有分片合并成一个文件。
if (fileDto.getShardIndex().equals(fileDto.getShardTotal())) {
this.merge(fileDto);
}
// 5. 返回分片上传结果
return responseDto;
}
1.2.4.2 merge
文件所有分片上传完成后到合并操作,合并完成后删除文件的所有分片。
private void merge(FileDto fileDto) {
log.info("合并分片开始");
String path = fileDto.getPath();
Integer shardTotal = fileDto.getShardTotal();
File newFile = new File(FILE_PATH + path);
byte[] byt = new byte[10 * 1024 * 1024];
FileInputStream inputStream = null; // 分片文件
int len;
// 文件追加写入
try (FileOutputStream outputStream = new FileOutputStream(newFile, true);
) {
for (int i = 0; i < shardTotal; i++) {
// 读取第一个分片
inputStream = new FileInputStream(new File(FILE_PATH + path + "." + (i+1))); // course\6sfSqfOwzmik4A4icMYuUe.mp4.1
while ((len = inputStream.read(byt))!=-1) {
outputStream.write(byt, 0, len);
}
}
} catch (FileNotFoundException e) {
log.info("文件寻找异常", e);
} catch (IOException e) {
log.info("分片合并异常", e);
} finally {
try {
if(inputStream !=null) {
inputStream.close();
}
log.info("IO流关闭");
} catch (IOException e) {
log.error("IO流关闭", e);
}
}
log.error("合并分片结束");
System.gc();
// 删除分片
log.info("删除分片开始");
for (int i = 0; i < shardTotal; i++) {
String filePath = FILE_PATH + path + "." + (i + 1);
File file = new File(filePath);
boolean result = file.delete();
log.info("删除{},{}", filePath, result ? "成功" : "失败");
}
log.info("删除分片结束");
}
断点续传基于分片上传实现,使之前未上传完成到文件可以从上次上传完成的Part的位置继续上传。
断点续传实现了,也就间接实现了 极速秒传功能,通过 唯一key 检测文件上传进度,发现之前已经上传完成,便可返回给用户 “极速秒传” 成功的消息,而不需要将该文件再次上传一次。至于文件及其数据库信息是否需要内部拷贝,则看项目需求即可。
1.2.3.3 uploadFile 中的 _this.upload(param);
被 检测已上传分片的函数 _this.check(param);
取代。
upload
上传函数由 check
调用。
check(param) {
let _this = this;
_this.$ajax.get(process.env.VUE_APP_SERVER + "/file/admin/check/" + param.key).then((res)=> {
let resp = res.data;
if(resp.success) {
let obj = resp.content;
if(!obj) {
param.shardIndex = 1;
console.log("没有找到文件记录,从分片1开始上传");
_this.upload(param);
} else if (obj.shardIndex === obj.shardTotal) {
// 已上传分片 = 分片总数,说明已全部上传完,不需要再上传
Toast.success("文件极速秒传成功!");
_this.afterUpload(resp);
$("#" + _this.inputId + "-input").val("");
}else {
param.shardIndex = obj.shardIndex + 1;
console.log("没有找到文件记录,从分片1开始上传");
_this.upload(param);
}
} else {
console.log("文件上传失败");
$("#" + _this.inputId + "-input").val("");
}
});
},
Java 增加了一个检测文件分片上传情况到api。
@GetMapping("/check/{key}")
public ResponseDto check(@PathVariable String key) {
log.info("检测上传分片开始:{}}", key);
ResponseDto responseDto = new ResponseDto();
FileDto fileDto = fileService.findByKey(key);
responseDto.setContent(fileDto);
return responseDto;
}
readAsDataURL
方法会读取指定的 Blob 或 File 对象。读取操作完成的时候,readyState 会变成已完成DONE,并触发 loadend 事件,同时 result 属性将包含一个data:URL格式的字符串(base64编码)以表示所读取文件的内容。
本文主要参考课程 《Spring Cloud + Vue 前后端分离 开发企业级在线视频课程系统》 中相关章节整理实现,示例本身挺基础,可供优化点很多,这里暂且不做扩展,原理了解之后,大家可自行扩展到并行上传分片、消息队列合并文件/删除分片等,应该不会太难,另外分片上传和分片下载比较类似,也可自行考虑实现。