前言
很久以前就想尝试大文件上传了,一直没有什么机会,主要是业务上没有契合的场景。前段时间偶然的一个契机尝试了一下,今天准备用一个实际案例来记录一下分组分片上传大文件。
场景和背景
我用的vue3+element-plus,是的还是熟悉的配方,还是熟悉的味道,上传组件使用el-upload。需求是上传安装包,其实不算大,但是也会有几百兆。所以这里考虑使用分组分片上传。
分组上传
我这边直接使用http-request来自定义上传行为:
ruby
<el-upload
v-model:file-list="form.fileExeList"
accept=".exe"
:limit="1"
:disabled="isEdit"
:on-preview="handlePreview"
:on-remove="handleRemove"
:before-remove="beforeRemove"
:on-exceed="handleExceed"
:http-request="httpRequest"
>
<el-button type="primary" :disabled="isEdit">
{{ t("versionManage.publish.dialog.uploadTips") }}
{{ t("versionManage.publish.dialog.exe") }}
</el-button>
</el-upload>
JS代码,我们先分析逻辑:
- 定义好chunk大小: chunkSize,拿到原始文件后进行分片,得到最终会有多少chunk: totalChunks。还需一个变量finishedChunks 来表示已经完成了几个chunk,用于展示进度条
- 由于被分割成了多个chunk,如果我们串行一个一个上传,那么分割的意义就没有了。为了节省时间,所以我们使用Promise.all来并行发出所有chunk,所以需要组装每个chunk的请求方法
- 等待所有chunk上传完毕后需要调用一个完成上传的方法告诉后端已经上传完毕
- 处理上传过程中的错误,尤其注意并行的时候发生错误弹窗不能多次弹出
好了,直接上源码:
ini
const httpRequest = async (options) => {
const { file, onSuccess, onError, onProgress } = options;
const initRes = await initMultipartUpload({
fileName: file.name,
isPublic: true
});
if ((initRes.data as any)?.code) {
ElMessage.error(t("buttons.upLoadFail"));
onError && onError((initRes.data as any)?.msg);
return;
}
const chunkSize = 5 * 1024 * 1024; // 5MB
const rawFile = file.raw || file;
const totalChunks = Math.ceil(rawFile.size / chunkSize);
let finishedChunks = 0;
let hasError = false;
resendFormRef.value?.clearValidate();
// 并行上传所有分片
const uploadPromises = Array.from({ length: totalChunks }).map(
async (_, currentChunk) => {
const start = currentChunk * chunkSize;
const end = Math.min(start + chunkSize, rawFile.size);
const chunk = rawFile.slice(start, end);
const fD = new FormData();
fD.append("partData", chunk);
try {
const res = await uploadMultipartPart(
{
uploadId: initRes.data?.uploadId,
partNumber: currentChunk + 1
},
fD
);
// throw new Error("error");
if ((res.data as any)?.code) {
hasError = true;
onError && onError((res.data as any)?.msg);
return Promise.reject((res.data as any)?.msg);
}
finishedChunks++;
if (onProgress) {
onProgress({
percent: Math.round((finishedChunks / totalChunks) * 100)
});
}
} catch (err) {
!hasError && ElMessage.error(t("buttons.upLoadFail")); // 避免多次弹窗
hasError = true;
onError && onError(err);
}
}
);
await Promise.all(uploadPromises);
if (!hasError) {
const res = await completeMultipartUpload({
uploadId: initRes.data?.uploadId
});
if ((res.data as any)?.code) {
ElMessage.error(t("buttons.upLoadFail"));
onError && onError((res.data as any)?.msg);
return;
}
onSuccess({ response: { fileUrl: res.data?.key }, status: "success" });
ElMessage.success(t("buttons.upLoadSuccess"));
}
};
代码解析:
- 首先我们需要线条用一个初始化的接口,这个接口告诉服务器我要开始上传一个大文件了,并提交大文件的一些基本信息,服务器返回一个uploadId,后续上传使用,这个uploadId相当于一个任务id,开起了一个上传任务。
- 将文件分好片,然后调用分段上传即可。我们使用Promise.all来做并行这个操作,为此需要封装uploadPromise这个方法,创建一个数组,然后往里面塞我们的请求方法将文件按照chunkSize一点点进行上传,并告诉服务器当前chunk的顺序partNumber(服务器需要按照顺序拼接回去)
- 分片上传了,就更容易展示进度条了。Promise.all里面的的请求每成功一次finishedChunks就加1,以此来展示当前上传的进度,这可不是假进度条了,货真价实的进度。
- 所有分片上传完毕后,再调用一个completeMultipartUpload的方法告诉服务器当前大文件已上传完毕,服务器合并文件后就可以提供link了
问题拓展
代码写完了,上传了一个几十MB的文件测试没有问题,满心欢喜提测。
然鹅,测试甩了一个截图,所有part分片上传失败了,即uploadMultipartPart的上传失败了。原因是测试用另一个几百MB的.exe文件上传,我们的分片有好几十个,但是服务器来不及处理完所有就超时了,http请求超时自动cancel了。
怎么办呢?
1. 延长http请求的超时时间
治标不治本,超大文件、低网速环境下依然没有用
2. 将分片大小减少
一样治不了根,原因同上
3. 分组分片上传
将分好的分片放入一个组,比如五个为一组,然后一组一组的请求,每一组请求完毕后继续下一组,直到最后一组请求完毕可以解决这个问题。
4. 请求管道
思想同第三点类似,也是将分片的请求方法放入一个组中,只是这里是一个管道。比如这个管道能装5个请求,然后发起请求,管道里每请求完毕一个就加入一个在管道里,直到请求完毕
解决方案
我综合了一下,决定还是用分组分片来实现。没用管道,主要不想太麻烦。
思路:
- 设定每组并发上传的分片数为 5(groupSize = 5)。
- 外层循环以每组 5 个分片为单位遍历所有分片。
- 每组内用一个数组 groupPromises 收集 5 个分片的上传 Promise。
- 内层循环遍历当前组的每个分片,切割文件,放入 FormData,调用 uploadMultipartPart 上传。
- 每个分片上传成功后,finishedChunks++,并通过 onProgress 回调实时更新进度百分比。
- 如果某个分片上传失败,设置 hasError = true,并通过 onError 回调通知外部。
- await Promise.all(groupPromises) 等待当前组的所有分片上传完成后再进入下一组。
- 如果有分片上传失败(hasError),中断后续上传。
根据以上思路我们重写代码为:
ini
const httpRequest = async (options) => {
const { file, onSuccess, onError, onProgress } = options;
const initRes = await initMultipartUpload({
fileName: file.name,
isPublic: true
});
if ((initRes.data as any)?.code) {
ElMessage.error(t("buttons.upLoadFail"));
onError && onError((initRes.data as any)?.msg);
return;
}
const chunkSize = 5 * 1024 * 1024; // 5MB
const rawFile = file.raw || file;
const totalChunks = Math.ceil(rawFile.size / chunkSize);
let finishedChunks = 0;
let hasError = false;
resendFormRef.value?.clearValidate();
// 分组并发上传,每组5个分片,组间串行
const groupSize = 5;
for (let groupStart = 0; groupStart < totalChunks; groupStart += groupSize) {
const groupEnd = Math.min(groupStart + groupSize, totalChunks);
const groupPromises = [];
for (
let currentChunk = groupStart;
currentChunk < groupEnd;
currentChunk++
) {
const start = currentChunk * chunkSize;
const end = Math.min(start + chunkSize, rawFile.size);
const chunk = rawFile.slice(start, end);
const fD = new FormData();
fD.append("partData", chunk);
const promise = (async () => {
try {
const res = await uploadMultipartPart(
{
uploadId: initRes.data?.uploadId,
partNumber: currentChunk + 1
},
fD
);
if ((res.data as any)?.code) {
hasError = true;
onError && onError((res.data as any)?.msg);
return Promise.reject((res.data as any)?.msg);
}
finishedChunks++;
if (onProgress) {
onProgress({
percent: Math.round((finishedChunks / totalChunks) * 100)
});
}
} catch (err) {
!hasError && ElMessage.error(t("buttons.upLoadFail"));
hasError = true;
onError && onError(err);
}
})();
groupPromises.push(promise);
}
await Promise.all(groupPromises);
if (hasError) break;
}
if (!hasError) {
const res = await completeMultipartUpload({
uploadId: initRes.data?.uploadId
});
if ((res.data as any)?.code) {
ElMessage.error(t("buttons.upLoadFail"));
onError && onError((res.data as any)?.msg);
return;
}
onSuccess({ response: { fileUrl: res.data?.key }, status: "success" });
ElMessage.success(t("buttons.upLoadSuccess"));
}
};
总结
实际上,我还是把http的超时时间扩展了,主要是测试环境速度太拉跨了,分组本来也想用10个分片一组的,也是超时报错。最后受不了了,三管齐下,把之前分析的手段都用上了。既延长请求超时时间,也降低分组数量,还可以降低分片大小,慢慢传直到上传成功。