文件太大怎么上传?【分组分片上传大文件】-实战记录

前言

很久以前就想尝试大文件上传了,一直没有什么机会,主要是业务上没有契合的场景。前段时间偶然的一个契机尝试了一下,今天准备用一个实际案例来记录一下分组分片上传大文件

场景和背景

我用的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代码,我们先分析逻辑:

  1. 定义好chunk大小: chunkSize,拿到原始文件后进行分片,得到最终会有多少chunk: totalChunks。还需一个变量finishedChunks 来表示已经完成了几个chunk,用于展示进度条
  2. 由于被分割成了多个chunk,如果我们串行一个一个上传,那么分割的意义就没有了。为了节省时间,所以我们使用Promise.all来并行发出所有chunk,所以需要组装每个chunk的请求方法
  3. 等待所有chunk上传完毕后需要调用一个完成上传的方法告诉后端已经上传完毕
  4. 处理上传过程中的错误,尤其注意并行的时候发生错误弹窗不能多次弹出

好了,直接上源码:

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个分片一组的,也是超时报错。最后受不了了,三管齐下,把之前分析的手段都用上了。既延长请求超时时间,也降低分组数量,还可以降低分片大小,慢慢传直到上传成功。

相关推荐
光影少年2 小时前
vite打包优化有哪些
前端·vite·掘金·金石计划
bug_kada2 小时前
前端性能优化之图片预加载
前端·性能优化
北漂大橙子2 小时前
运营妹子复制 200 个 URL 手酸到哭,我用 Puppeteer 写了个工具,1 小时搞定!
前端·puppeteer
小桥风满袖2 小时前
极简三分钟ES6 - ES9中Promise扩展
前端·javascript
Mintopia2 小时前
🧑‍💻 用 Next.js 打造全栈项目的 ESLint + Prettier 配置指南
前端·javascript·next.js
Mintopia2 小时前
🤖 微服务架构下 WebAI 服务的高可用技术设计
前端·javascript·aigc
江城开朗的豌豆2 小时前
React 跨级组件通信:避开 Context 的那些坑,我还有更好的选择!
前端·javascript·react.js
吃饺子不吃馅3 小时前
root.render(<App />)之后 React 干了哪些事?
前端·javascript·面试
SimonKing3 小时前
Archery:开源、一站式的数据库 SQL 审核与运维平台
java·后端·程序员