0.需求背景
- 遇到大文件上传时,会存在文件过大,后端无法一次性接受
- 上传过程中,异常失败后,需要重新上传,耗时
- 单次请求时间过长,请求受限
分片上传,相比于普通的单线程上传,速度更快,更灵活。
1.大文件上传的解决思路
- 前端文件切片:把一个大文件转换成二进制内容,然后按照一个固定的大小对二进制内容进行切割,得到多个小文件,然后循环上传所有的小文件。在js中,文件File对象是Blob对象的子类,可以使用slice()方法完成对文件的切割;
- 后端文件合并:当所有小文件上传完成,调用接口通知后端把所有的文件按编号进行合并,组成大文件;
- 并发控制:结合Promise.race和异步函数实现,限制多个请求同时并发的数量,防止浏览器内存溢出;
- 断点续传:把所有上传失败的小文件加入一个数组里面,在所有小文件都上传结束(成功和失败都算结束)之后再上传一次上传失败了的小文件,反复执行这一步,直到所有小文件都上传成功,可以通过递归实现。
思考的点
- 上传多次失败的分片,如何处理?
- 如上思路,会自动重新上传,但是不可以无限制的一直重复上传。大概率是接口并发问题,二次上传或者调整并发量,就可以上传上去了。
- 提供重新上传机会,但是原则上,必须所有的分片都上传成功后,才能合并出最终的大文件。
- 断网重连如何处理?待实现
- 出现请求失败的请求,则循环停止,暂停后面的请求了,直到网络连接后再继续上传!定时器,判断网络是否可行。
- 暴力的方案:界面检测到没有网络了,直接提示让客户重新上传,原来上传的作废。
- 上传一半,浏览器关闭,已上传的切片垃圾数据,如何处理?
- 方案1,界面销毁,触发销毁事件,告知后台,删除掉剩下的文件
- 方案2,后台定时检查分片数据。如果文件夹的创建日期是隔天的,则删除。
- 客户要二次上传,就重新进界面了
- 进度显示,切片的上传返回结果
3.核心代码参考
前端大文件切片
// 文件分片
let hash = 0 // 切片序号
let size = 1024 * 50 // 切片大小
let fileArr = []
total.value = Math.ceil(file.size / size)
console.log('total: ', total)
for (let i = 0; i < file.size; i = i + size) {
fileArr.push({
hash: hash++,
chunk: file.slice(i, i + size),
uploadTimes: 0
})
progress.value = Math.round(hash / total.value * 10000) / 100
console.log('拆分', hash)
console.log('拆分进度', progress.value + '%')
}
前端切片上传
// 遍历文件列表,上传文件
for (let i = 0; i < list.length; i++) {
let item = list[i]
if (item.uploadTimes > 5) {
errorList.push(item)
console.log('item.uploadTimes: ', item.uploadTimes)
continue
}
item.uploadTimes = item.uploadTimes + 1//记录
let formData = new FormData()
formData.append('filename', file.name)
formData.append('hash', item.hash)
formData.append('dir', uuid)
formData.append('chunk', item.chunk)
console.log('上传', item.hash)
// 上传
let res = axios({
method: 'post',
url: 'bigfile/upload',
data: formData
})
// 把上传文件的异步操作放入并发池里
pool.push(res)
if (pool.length === max) {
// 每当并发池跑完一个任务,就再塞入一个任务
await Promise.race(pool)
}
res.then((response) => {
let status = response.data.status
if (status == '201') {
// 请求成功,从并发池里移除
const index = pool.findIndex(it => it === res)
pool.splice(index, 1)
finalFine.value++
progress.value = Math.round(finalFine.value / total.value * 10000) / 100
console.log('response: ', response)
console.log('上传进度 ----- ', finalFine.value, total.value, progress.value + '%')
} else {
//上传有问题
const index = pool.findIndex(it => it === res)
pool.splice(index, 1)
failList.push(item)
}
}).catch((response) => {
console.log('response-error: ', response)
// 请求失败,从并发池里移除,添加到失败的文件列表
const index = pool.findIndex(it => it === res)
pool.splice(index, 1)
failList.push(item)
}).finally(() => {
finish++
// 如果请求都完成了,递归调用自己,把上传失败的文件列表再上传一次
if (finish === list.length) {
uploadFileChunks(failList, uuid)
}
})
}
后端切片合并
// 创建目标文件
RandomAccessFile writeFile = new RandomAccessFile(targetFile, "rw");
// 位置起始点
long position = 0L;
for (String tempFilename : fileNames) {
// System.out.println(tempFilename);
File sourceFile = new File(parentDir, tempFilename);// 切片文件
RandomAccessFile readFile = new RandomAccessFile(sourceFile, "rw");
int chunksize = 1024 * 3;
byte[] buf = new byte[chunksize];
writeFile.seek(position);
int byteCount = 0;
while ((byteCount = readFile.read(buf)) != -1) {
if (byteCount != chunksize) {
byte[] tempBytes = new byte[byteCount];
System.arraycopy(buf, 0, tempBytes, 0, byteCount);
buf = tempBytes;
}
writeFile.write(buf);// 写入
position = position + byteCount;
}
readFile.close();
}
writeFile.close();
4.案例源码
百度网盘 链接:https://pan.baidu.com/s/1wMJoE0ETiSPHniJLV5IPZA
码微信小程序。获取 提取码