H5大视频上传治理

一、背景

H5分配内存很少,在业务中需上传大视频,用户低端机经常出现crash、请求超时现象,基于已有的视频上传方案,应整体进行优化。

二、整体方案

sequenceDiagram 用户->>+前端:用户选择视频 前端->>+前端:解析视频信息 前端->>+前端:预校验视频时长、大小 前端->>+后端:请求STS上传凭证信息 后端->>+前端:返回凭证信息 前端->>+前端:初始化OSS实例 前端->>+webworker:开启getMd5异步线程,解析视频MD5 前端->>+OSS:初始化分片上传,与OSS建立首片连接 OSS->>+前端:返回首片请求结果 前端->>+OSS:多轮分片上传(单片3次重试机制) OSS->>+前端:分片上传结束 前端->>+OSS:调用completeMultipartUpload完成上传,文件资源落到OSS目录 OSS->>+前端:视频上传完毕 webworker->>+前端:md5解析完成 前端->>+后端:根据ossKey获取视频临时链接 后端->>+前端:返回视频临时链接 前端->>+用户:回显展示用户

三、Action

3.1、分片上传(支持单片重试)

3.1.1、优化前

现状是分片上传走oss定制的SDK,并且SDK内置并未做part块级别的重试,业务也无法准确识别到哪个part上传失败了,无法做到指定某个part的重试。

阿里云oss相关案例代码:help.aliyun.com/zh/oss/deve...

javascript 复制代码
const OSS = require('ali-oss');
const path = require("path");

const client = new OSS({
  // yourregion填写Bucket所在地域。以华东1(杭州)为例,Region填写为oss-cn-hangzhou。
  region: 'yourregion',
  // 从环境变量中获取访问凭证。运行本代码示例之前,请确保已设置环境变量OSS_ACCESS_KEY_ID和OSS_ACCESS_KEY_SECRET。
  accessKeyId: process.env.OSS_ACCESS_KEY_ID,
  accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET,
  authorizationV4: true,
  // 填写存储空间名称。
  bucket: 'yourbucketname',
});


const progress = (p, _checkpoint) => {
  // Object的上传进度。
  console.log(p); 
  // 分片上传的断点信息。
  console.log(_checkpoint); 
};

const headers = {  
  // 指定Object的存储类型。
  'x-oss-storage-class': 'Standard', 
  // 指定Object标签,可同时设置多个标签。
  'x-oss-tagging': 'Tag1=1&Tag2=2', 
  // 指定初始化分片上传时是否覆盖同名Object。此处设置为true,表示禁止覆盖同名Object。
  'x-oss-forbid-overwrite': 'true'
}

// 开始分片上传。
async function multipartUpload() {
  try {
    // 依次填写Object完整路径(例如exampledir/exampleobject.txt)和本地文件的完整路径(例如D:\localpath\examplefile.txt)。Object完整路径中不能包含Bucket名称。
    // 如果本地文件的完整路径中未指定本地路径(例如examplefile.txt),则默认从示例程序所属项目对应本地路径中上传文件。
    const result = await client.multipartUpload('exampledir/exampleobject.txt', path.normalize('D:\localpath\examplefile.txt'), {
      progress,
      // headers,
      // 指定meta参数,自定义Object的元数据。通过head接口可以获取到Object的meta数据。
      meta: {
        year: 2020,
        people: 'test',
      },
    });
    console.log(result);
    // 填写Object完整路径,例如exampledir/exampleobject.txt。Object完整路径中不能包含Bucket名称。
    const head = await client.head('exampledir/exampleobject.txt');
    console.log(head);
  } catch (e) {
    // 捕获超时异常。
    if (e.code === 'ConnectionTimeoutError') {
      console.log('TimeoutError');
      // do ConnectionTimeoutError operation
    }
    console.log(e);
  }
}

multipartUpload();

3.1.2、优化后

查阅官方标准使用姿势,可将"分片上传大文件"拆解成标准的三步:

三、Action

3.1、分片上传(支持单片重试)

3.1.1、优化前

现状是分片上传走oss定制的SDK,并且SDK内置并未做part块级别的重试,业务也无法准确识别到哪个part上传失败了,无法做到指定某个part的重试。

相关案例代码:help.aliyun.com/zh/oss/deve...

javascript 复制代码
    const OSS = require('ali-oss');
    const path = require("path");

    const client = new OSS({
      // yourregion填写Bucket所在地域。以华东1(杭州)为例,Region填写为oss-cn-hangzhou。
      region: 'yourregion',
      // 从环境变量中获取访问凭证。运行本代码示例之前,请确保已设置环境变量OSS_ACCESS_KEY_ID和OSS_ACCESS_KEY_SECRET。
      accessKeyId: process.env.OSS_ACCESS_KEY_ID,
      accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET,
      authorizationV4: true,
      // 填写存储空间名称。
      bucket: 'yourbucketname',
    });


    const progress = (p, _checkpoint) => {
      // Object的上传进度。
      console.log(p); 
      // 分片上传的断点信息。
      console.log(_checkpoint); 
    };

    const headers = {  
      // 指定Object的存储类型。
      'x-oss-storage-class': 'Standard', 
      // 指定Object标签,可同时设置多个标签。
      'x-oss-tagging': 'Tag1=1&Tag2=2', 
      // 指定初始化分片上传时是否覆盖同名Object。此处设置为true,表示禁止覆盖同名Object。
      'x-oss-forbid-overwrite': 'true'
    }

    // 开始分片上传。
    async function multipartUpload() {
      try {
        // 依次填写Object完整路径(例如exampledir/exampleobject.txt)和本地文件的完整路径(例如D:\localpath\examplefile.txt)。Object完整路径中不能包含Bucket名称。
        // 如果本地文件的完整路径中未指定本地路径(例如examplefile.txt),则默认从示例程序所属项目对应本地路径中上传文件。
        const result = await client.multipartUpload('exampledir/exampleobject.txt', path.normalize('D:\localpath\examplefile.txt'), {
          progress,
          // headers,
          // 指定meta参数,自定义Object的元数据。通过head接口可以获取到Object的meta数据。
          meta: {
            year: 2020,
            people: 'test',
          },
        });
        console.log(result);
        // 填写Object完整路径,例如exampledir/exampleobject.txt。Object完整路径中不能包含Bucket名称。
        const head = await client.head('exampledir/exampleobject.txt');
        console.log(head);
      } catch (e) {
        // 捕获超时异常。
        if (e.code === 'ConnectionTimeoutError') {
          console.log('TimeoutError');
          // do ConnectionTimeoutError operation
        }
        console.log(e);
      }
    }

    multipartUpload();

3.1.2、优化后

查阅官方标准使用姿势,可将"分片上传大文件"拆解成标准的三步:

基于优化前的上传代码作为提示词给到AI,尝试快速生成代码

提示词:

javascript 复制代码
帮我将multipartUpload分片上传的方案重构成.initMultipartUpload、.uploadPart以及.completeMultipartUpload方法实现单片重试,保留上传的业务逻辑,如上传进度回调,相关代码:

public async uploadVideo(file: File, onProgress?: (progress: number) => void): Promise<OssFile> {
    const createTime = file.lastModified;
    const result: OssFile = {
      url: '',
      key: '',
      thumbnailUrl: 'https://gw.alicdn.com/imgextra/i3/O1CN01SXhFxS1pW8bYuTeYn_!!6000000005367-0-tps-296-296.jpg',
      extra: {
        createTime,
        fileMd5: '',
        status: 'success',
        failReason: '',
        fileName: file.name,
        fileSize: file.size,
        ossKey: file.name,
      },
    };

    const fileName = `${formateDateStr()}/${uuidv4()}.${file.name.split('.').pop()}`; // Using UUID for unique file name
    let progress = 0;
    const options = {
      parallel: 4,
      partSize: this.determinePartSize(file.size),
      timeout: 8000,
      retryMax: 3,
      requestErrorShouldRetry: (err, params) => {
        return true;
      },
      progress: (p: number, cpt, res) => {
        console.log('Progress:', p, cpt, res);
        if (onProgress && p > progress) {
          // 如果上传进度大于UI所展示的进度(超时重新上传)
          progress = p;
          onProgress(p * 100);
        }
      },
    };


    try {
      const ossResult = await this.client.multipartUpload(fileName, file, options);
      result.url = URL.createObjectURL(file);
      result.key = ossResult.name;
      result.extra.ossKey = ossResult.name;

      return result;
    } catch (error) {
    }

    return result;
  }

AI所生成的内容和说明:

typescript 复制代码
    根据你的需求,重构你的 `uploadVideo` 方法使用阿里云 OSS SDK 的 `initMultipartUpload`、`uploadPart` 和 `completeMultipartUpload` 方法来进行分片上传,并在单个分片失败时进行重试。以下是详细实现:

    ### 分片上传代码实现


    public async uploadVideo(file: File, onProgress?: (progress: number) => void): Promise<OssFile> {
      const createTime = file.lastModified;
      const result: OssFile = {
        url: '',
        key: '',
        thumbnailUrl: 'https://gw.alicdn.com/imgextra/i3/O1CN01SXhFxS1pW8bYuTeYn_!!6000000005367-0-tps-296-296.jpg',
        extra: {
          createTime,
          fileMd5: '',
          status: 'success',
          failReason: '',
          fileName: file.name,
          fileSize: file.size,
          ossKey: file.name,
        },
      };

      const fileName = `${formateDateStr()}/${uuidv4()}.${file.name.split('.').pop()}`; // 使用 UUID 生成文件名
      const partSize = this.determinePartSize(file.size); // 确定分片大小
      const partsCount = Math.ceil(file.size / partSize); // 计算分片数量
      const progressState = Array(partsCount).fill(0); // 用于跟踪每个分片的进度

      try {
        // 1. 初始化分片上传
        const { uploadId } = await this.client.initMultipartUpload(fileName);
        console.log('uploadId:', uploadId);

        const uploadParts: Array<{ number: number; etag: string }> = []; // 用于存储所有分片的编号和 eTag

        // 2. 逐个上传分片
        for (let partNumber = 0; partNumber < partsCount; partNumber++) {
          const start = partNumber * partSize; // 分片起始字节
          const end = Math.min(file.size, start + partSize); // 分片结束字节
          const partFile = file.slice(start, end); // 切割出当前分片数据

          let retries = 0;
          const maxRetries = 3;

          while (retries < maxRetries) {
            try {
              // 调用 uploadPart 上传当前分片
              const { etag } = await this.client.uploadPart(fileName, uploadId, partNumber + 1, partFile);
              uploadParts.push({ number: partNumber + 1, etag });

              // 更新进度
              progressState[partNumber] = (end - start) / file.size;
              const overallProgress = progressState.reduce((a, b) => a + b, 0);

              if (onProgress) {
                onProgress(overallProgress * 100); // 返回整体上传进度
              }
              break; // 当前分片上传成功, 跳出重试循环
            } catch (error) {
              retries++;
              console.error(`分片 ${partNumber + 1} 上传失败,第 ${retries}/${maxRetries} 次重试`, error);

              if (retries === maxRetries) {
                throw new Error(`分片 ${partNumber + 1} 上传失败,超出最大重试次数`);
              }
            }
          }
        }

        // 3. 完成分片上传
        const completeResult = await this.client.completeMultipartUpload(fileName, uploadId, uploadParts);
        console.log('completeMultipartUpload result:', completeResult);

        result.url = completeResult.location; // 上传后文件的完整路径
        result.key = completeResult.name; // OSS 对象的存储路径
        result.extra.ossKey = completeResult.name;
        return result;
      } catch (err) {
        console.error('视频上传失败:', err);
        result.extra.status = 'fail';
        result.extra.failReason = '上传失败';
        throw err; // 抛出错误以便调用方处理
      }
    }
    ```

    ---

    ### 代码解析

    1. **初始化分片上传 (`initMultipartUpload`)**:
       - 调用 `initMultipartUpload` 方法返回 `uploadId`,这是标识这次分片上传的唯一值。

    2. **切割文件并上传分片 (`uploadPart`)**:
       - 通过 `File.slice` 方法将文件按 `partSize` 切分成多个块。
       - 对每一块文件,调用 `uploadPart` 方法上传。
       - 使用一个重试循环 (`while`) 手动实现分片上传的重试机制。

    3. **跟踪分片进度**:
       - 使用 `progressState` 数组存储每个分片的上传进度比例,上传完成后总体计算进度更新。

    4. **完成上传 (`completeMultipartUpload`)**:
       - 分片上传完成后将 `uploadParts` 提交给 `completeMultipartUpload` 方法,通知 OSS 服务端合并分片为最终文件。

    5. **错误处理**:
       - 在单个分片上传失败时,进行最多 `maxRetries` 次重试,失败则抛出异常并标记为上传失败。

    ---

    ### 示例输出日志

    假设文件大小为 10MB,分片大小为 2MB,总共 5 个分片:

    ```
    uploadId: xxxx-xxxxx-xxxx
    分片 1 上传成功
    Progress: 20%
    分片 2 上传成功
    Progress: 40%
    分片 3 上传成功
    Progress: 60%
    分片 4 上传成功
    Progress: 80%
    分片 5 上传成功
    Progress: 100%
    completeMultipartUpload result: {
      name: '<uploaded file name>',
      location: '<uploaded file URL>',
    }
    ```

    ---

    ### 需要注意的点

    1. **分片大小限制**:
       根据 OSS 服务端限制,每个分片大小需大于等于 100KB,最后一个分片可以小于 100KB。对于较大的文件,推荐 1~10MB 分片大小。

    2. **分片数量限制**:
       分片数最多为 `10,000`,如果文件超大(例如 TB 级文件),需要根据 OSS 文档调整分片策略。

    3. **进度更新频率**:
       `onProgress` 回调的触发频率取决于分片上传的时间。如果分片大小较大,用户可能会感到进度更新缓慢。

    ---

    ### 如何测试

    1. 准备一个需要上传的视频文件(文件大小控制在 5MB、50MB、500MB 测试分片逻辑)。
    2. 调用此方法,传入文件对象以及 `onProgress` 回调函数,用于在页面实时显示上传进度:
       ```javascript
       const uploader = Upload.getInstance();
       const file = document.getElementById('video-input').files[0]; // 获取上传的文件
       uploader.uploadVideo(file, (progress) => {
         console.log('当前进度:', progress);
       });


    这样实现会更加灵活,也更符合需求!

实跑了除了上传完毕的url赋值没有使用临时链接,整个上传功能直接是可用的,如图:

这里mock了第三个片异常,也进行了对应单片的重试机制,非常完美的进行了重构,整个重构过程90%由AI完成,用时30分钟。

3.2、MD5计算优化

现状(未放webworker),在钉容器中直接崩溃。

优化后,解析MD5走webworker线程。

3.3、内存管理

背景:APP作为H5乞丐户,单H5分配的内存极少,在APP中上传大文件极容易出现crash。

使用低端机(可用内存较少的机型)测试,发现在连续上传第二个大视频时,非常容易出现H5 crash,经过研究发现核心原因是:大视频上传过程中产生的临时对象没有被及时清除占用了大量的内存导致。

经过统计后发现以下case会导致:

  1. URL.createObjectURL生成临时blob链接;
  2. getMd5 webWorker的临时blob链接;

治理方案:

  1. 走OSS生成临时链接,前端不生成;
  2. 每次调用结束后getMd5后清除blob链接(URL.revokeObjectURL),每次调用前URL.createObjectURL生成新的链接;

3.4、STS动态获取

STS配置化数据走内部刷新机制,通过refreshSTSToken、refreshSTSTokenInterval实现。

3.5、交互优化

3.5.1、重渲染治理

保证视频上传组件除首渲染以外无重排、重绘、重渲染(memo、useMemo、useCallback);

3.5.2、渲染卡顿优化

分片进度页面反馈走requestAnimationFrame,在空闲帧响应

3.5.3、校验前置

分析antd源码,放在beforeUpload,不放在upload中,可以节省很多file处理

4、结果

4.1、成功率

视频上传成功率接近100%,从前端监控观察,没有出现过用户单片三次重试都失败的case,偶发会出现一次的超时,基于单片重传快速弥补让用户未感知到体验异常。

4.2、稳定性

经过内存管理等一系列治理优化手段,在手机冷却->冷启动APP的环境下,钉容器中crash发生的情况从 上传2次视频必现crash 优化至 上传10次视频开始偶现crash,并且在这此环境下在手机中进行正常操作也已经开始出现卡顿掉帧,故归于符合预期。

相关推荐
缉毒英雄祁同伟4 分钟前
企业级WEB应用服务器TOMCAT
java·前端·tomcat
har01d4 分钟前
在 uniapp 里使用 unocss,vue3 + vite 项目
前端·uni-app·vue·uniapp·unocss
OLong18 分钟前
React Update Queue 源码全链路解析:从 setState 到 DOM 更新
前端·react.js
知识浅谈21 分钟前
OpenLayers与Vue.js结合实现前端地图应用
前端
掘金0128 分钟前
Vue3 项目中实现特定页面离开提示保存功能方案
javascript·vue.js
答案answer41 分钟前
three.js 实现几个好看的文本内容效果
前端·webgl·three.js
余_弦1 小时前
区块链钱包开发(十九)—— 构建账户控制器(AccountsController)
javascript·区块链·以太坊
Running_C1 小时前
一文读懂跨域
前端·http·面试
前端Hardy1 小时前
HTML&CSS:有趣的小铃铛
javascript·css·html
南囝coding1 小时前
这个Web新API让任何内容都能画中画!
前端·后端