最近领导让做个文件断点续传的功能,解决某些用户因网络问题导致文件上传失败的问题。
之前就了解过只是一直没有真正使用,正好借这个机会,学习记录一下。
断点续传是什么?老生常谈,不了解的去百度一下,这里不做赘述。
前端实现:
file.slice(start,end);
方法将文件分片,获取总分片数、当前分片序号、当前分片文件;后端实现:
RandomAccessFile
合并分片文件,并清除缓存。前端代码
前端是利用XMLHttpRequest()
进行文件传输,优点是可以异步传输文件。
var xhr = new XMLHttpRequest();
var defaultChunkSize = (1024 * 1024);//确定分片大小
var formData = new FormData(document.getElementById("id_uploadAttachmentFileForm")); //直接获取Form表单数据
var fileSizeTotal = 0; // 定义上传文件大小
var fileArr = []; // 定义文件集合数组
formData.forEach(item => {
if(item instanceof File){
fileArr.add(item);
fileSizeTotal += item.size;
}
});
// 分片上传
upload(xhr, defaultChunkSize, 0, fileArr, 0, fileSizeTotal, fileNumber, 1);
/**
* 分片上传
* @param xhr
* @param defaultChunkSize 默认分片大小
* @param index 分片序列号
* @param fileArr 上传文件集合
* @param curFileSize 当前已上传文件大小
* @param fileSizeTotal 上传文件总大小
* @param number 上传文件序号
*/
function upload(xhr, defaultChunkSize, index, fileArr, curFileSize, fileSizeTotal, number) {
let curFile = fileArr[number-1];
let fileName = curFile.name;
let fname = fileName.substring(0,fileName.lastIndexOf('.'));
let fext = fileName.substring(fileName.lastIndexOf('.')+1);
var indexSum = Math.ceil(curFile.size / defaultChunkSize); // 获取总切片数
var star = index * defaultChunkSize;//切片的起点
// 获取文件块的终止字节
let end = (star + defaultChunkSize > curFile.size) ? curFile.size : (star + defaultChunkSize);
//判断起点是否已经超过文件的长度,超过则返回
if (star > curFile.size) {
return;
}
var bool = curFile.slice(star, star + defaultChunkSize);//slice(分割起点,分割终点)是js切割文件的函数,
var boolname = fname + index + fext
var boolfile = new File([bool], boolname)//把分割后的快转成文件传输
var formData = new FormData();
// 判断传输的状态,传给后端
if(index === indexSum-1 && number === fileArr.length){
let fileNameList = [];
for(let i=0; i < fileArr.length; i++){
fileNameList.add(fileArr[i].name);
}
formData.append("fileNameList", fileNameList);
formData.append("state","over");
}else if(index > 0 && index < indexSum){
formData.append("state","run");
}else{
formData.append("state","start");
}
// 设置一个文件上传唯一标识,存入cookie
let cookie = getCookie("FILE_MARK_ID");
if('null' === cookie){
setCookie("FILE_MARK_ID",getUuid(32,16));
}
formData.append("FILE_MARK_ID",getCookie('FILE_MARK_ID')); //文件标识
formData.append("index", index); //分片序号
formData.append("defaultChunkSize", defaultChunkSize); //分片大小
formData.append("fileName", curFile.name); //文件名称
formData.append("file", boolfile); //分片文件
xhr.open("post", getRootPath() + '/xx/uploadFile', true); //发送请求
xhr.send(formData); //发送数据
xhr.onloadend = function() {
if (this.readyState === 4 && this.status === 200) {
// 计算上传进度
var loaded = Math.round((end + curFileSize) / fileSizeTotal * 100);
var piece = Math.round((end + curFileSize) / 1024 / 1024) + 'M';
var total = Math.round(fileSizeTotal / 1024 / 1024) + 'M';
var size = piece + '/' + total;
if(index < indexSum){
if(this.responseText === (indexSum-1).toString()){
if(number < fileArr.length){
curFileSize += fileArr[number-1].size;
number++;
upload(xhr, defaultChunkSize, 0, fileArr, curFileSize, fileSizeTotal, number);
}else{
setCookie("FILE_MARK_ID",null); // 上传完成设置文件标识为null
// 设置进度条数值
var fileTotal = Math.round(fileSizeTotal / 1024 / 1024) + 'M';
$('#id_uploadAttachmentProcessFilePercent').progressbar('setValue', 100);
$('#id_uploadAttachmentProcessFileSize').html(fileTotal + '/' + fileTotal);
mini.get("id_uploadAttachmentProcessFileWindow").hide();
mini.get(window.currentUploadAttachmentFileTableId).reload();
mini.alert("全部文件上传成功");
}
}else{
// 设置进度条数值
$('#id_uploadAttachmentProcessFilePercent').progressbar('setValue', loaded);
$('#id_uploadAttachmentProcessFileSize').html(size);
index = parseInt(this.responseText);
upload(xhr, defaultChunkSize, ++index, fileArr, curFileSize, fileSizeTotal, number);
}
}
}
};
// 网络异常回调
xhr.onerror = function () {
document.getElementById("id_confirmwindow").style.display = "inline";
}
xhr.timeout = 5000;
// 超时回调
xhr.ontimeout = function () {
document.getElementById("id_confirmwindow").style.display = "inline";
}
}
上述代码实现多文件按顺序一起上传,其中还有很多需要优化的点。比如当前实现是针对一次上传的多文件生成的唯一标识,而不是单独的一个文件一个标识;文件上传进度条需要优化(因为最后需要合并分片文件及业务处理导致最后加载时间略长、断点续传时没有记录当前上传文件大小,进度条还是从0开始)
后端代码
缓存推荐使用Redis,方便高效;因为项目太老,我这边利用ConcurrentMap实现的本地缓存;如何实现参考 java实现本地缓存方案
// 利用传入的文件标识作为外层文件夹
File file = new File("D:/temp/"+FILE_MARK_ID+File.separator+file.getName());
// 获取文件的MD5加密key
String md5FileId = MD5Util.getMD5EncodedPassword(FILE_MARK_ID + file.getName());
// 从缓存中读取文件
LinkedList<HashMap<String,Object>> fileData = FileDataCacheUtil.getFileData(md5FileId);
if(CollectionUtils.isNotEmpty(fileData)){
// 利用RandomAccessFile类进行分片文件合并
RandomAccessFile raf = new RandomAccessFile(file,"rw");
for (HashMap<String,Object> map : fileData) {
Set<Map.Entry<String, Object>> entries = map.entrySet();
Iterator<Map.Entry<String, Object>> iterator = entries.iterator();
String keyIndex = "";
MultipartFile fileValue = null;
while (iterator.hasNext()){
Map.Entry<String, Object> entry = iterator.next();
keyIndex = entry.getKey();
fileValue = (MultipartFile) entry.getValue();
}
raf.seek(Integer.parseInt(keyIndex) * fileDto.getDefaultChunkSize());//seek(int n)从n处处理
byte[] bytes = fileValue.getBytes();//提区文件的字节流
raf.write(bytes);//上面的seek方法已经把指针放在这里从这直接写入
}
// 关闭流
raf.close();
// 移除缓存
FileDataCacheUtil.removeFileData(md5FileNameId);
}
后端的逻辑比较简单,这里只贴出核心代码,这里要求分片文件要严格按顺序传输。
秒传功能的实现也很简单,根据上传的文件名称去数据库中查找,有则直接将文件地址返回前端。
网上也有很多实现,例如文件唯一性标识是在前端对整个二进制文件进行MD5加密,这种方式感觉更好,但是如果是大文件则有待商榷;
前端分片文件传输采用Web Worker多线程传输等等