基于华为云 OBS 的分片上传功能

此文章部分使用AI生成的内容。
技术栈

  • **对象存储**:华为云 OBS(兼容 AWS S3 协议)

  • **MD5 计算**:spark-md5

大概步骤

1、安装依赖spark-md5

2、计算文件 MD5,使用 `spark-md5` 进行分片计算,避免大文件一次性加载到内存

3、获取预上传信息,调用后端接口获取分片上传所需的签名 URL

**重要参数说明:**

  • `fileName`:原始文件名

  • `md5Value`:文件 MD5 值,用于秒传判断

  • `chunkNum`:分片数量,后端根据此生成对应数量的签名 URL

  • `expiresSeconds`:签名 URL 有效期,默认 1 小时

4、上传单个分片,使用 XMLHttpRequest 上传分片并获取 ETag

**⚠️ 关键注意点:CORS 配置**

这是最容易踩坑的地方!必须在华为云 OBS 控制台配置 CORS 规则:

```xml

<CORSConfiguration>

<CORSRule>

<AllowedOrigin>*</AllowedOrigin>

<AllowedMethod>PUT</AllowedMethod>

<AllowedMethod>POST</AllowedMethod>

<AllowedMethod>GET</AllowedMethod>

<AllowedHeader>*</AllowedHeader>

<!-- 必须暴露 ETag 响应头,否则前端取不到 -->

<ExposeHeader>ETag</ExposeHeader>

<ExposeHeader>x-amz-request-id</ExposeHeader>

<MaxAgeSeconds>3000</MaxAgeSeconds>

</CORSRule>

</CORSConfiguration>

```

**如果没有配置 `<ExposeHeader>ETag</ExposeHeader>`,浏览器将无法获取 ETag,导致合并分片失败!**

bash 复制代码
pnpm add spark-md5

工具函数文件

javascript 复制代码
import SparkMD5 from 'spark-md5';
import { getPreUploadUrl, finishFile } from '@/api/global';
import { message } from 'ant-design-vue';

interface PreUploadData {
  fileId: string;
  filePath: string;
  fileUrl: string;
  uploadUrl: string;
  storageType: string;
  objectKey: string;
  uploadId: string;
  partSignList: {
    partNumber: number;
    signedUrl: string;
  }[];
  exists: boolean;
}

interface UploadOptions {
  file: File;
  chunkSize?: number; // 分片大小(MB),默认 5MB
  bizId?: string; // 业务ID
  bizType?: string; // 业务类型
  onProgress?: (progress: number) => void; // 进度回调
}

interface UploadResult {
  exists: boolean; // 文件是否存在
  fileUrl: string;
  fileId?: string;
  fileName?: string;
  fileSize?: number;
  filePath?: string;
  fileMd5?: string;
}

/**
 * 计算文件 MD5 值(使用 spark-md5 分片计算)
 */
const calculateFileMD5 = (file: File): Promise<string> => {
  return new Promise((resolve, reject) => {
    try {
      const spark = new SparkMD5.ArrayBuffer();
      const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB 分片
      let offset = 0;

      async function readNextChunk() {
        const chunkEnd = Math.min(offset + CHUNK_SIZE, file.size);
        if (offset >= file.size) {
          // 读取完成
          resolve(spark.end());
          return;
        }

        try {
          // 使用 FileReader 读取分片数据
          const slice = file.slice(offset, chunkEnd);
          const reader = new FileReader();

          reader.onload = e => {
            const arrayBuffer = e.target?.result as ArrayBuffer;
            if (arrayBuffer) {
              spark.append(arrayBuffer);
              offset += arrayBuffer.byteLength;
              readNextChunk();
            }
          };

          reader.onerror = err => {
            console.error('MD5 分片计算失败:', err);
            reject(new Error('MD5 计算失败'));
          };

          reader.readAsArrayBuffer(slice);
        } catch (e) {
          console.error('MD5 分片计算失败:', e);
          reject(new Error('MD5 计算失败'));
        }
      }

      readNextChunk();
    } catch (e) {
      console.error('MD5 计算初始化失败:', e);
      reject(new Error('MD5 计算失败'));
    }
  });
};

/**
 * 获取预上传链接
 */
const handlePreUploadUrl = async (
  fileName: string,
  md5Value: string,
  chunkNum: number,
  expiresSeconds = 3600
): Promise<any> => {
  try {
    const response = await getPreUploadUrl({
      fileName,
      md5Value,
      chunkNum,
      expiresSeconds
    });

    if (response && +response.code === 0 && response.data) {
      return response.data;
    } else {
      throw new Error(response?.message || '获取预上传链接失败');
    }
  } catch (error) {
    console.error('获取预上传链接失败:', error);
    throw new Error('网络请求失败');
  }
};

/**
 * 上传单个分片到 OBS,并返回 ETag
 */
const uploadChunk = (chunk: Blob, signedUrl: string): Promise<string> => {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('PUT', signedUrl);

    // 上传完成
    xhr.onload = () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        // 从响应头中获取 ETag
        // 注意:OBS 返回的 ETag 可能包含双引号,需要去除
        let etag = xhr.getResponseHeader('ETag') || xhr.getResponseHeader('Etag');

        // 调试信息:打印所有响应头
        // console.log('分片上传响应头:', xhr.getAllResponseHeaders());
        // console.log('获取到的 ETag:', etag);

        if (etag) {
          // 去除 ETag 两端的引号(如果有的话)
          etag = etag.replace(/^"|"$/g, '');
          resolve(etag);
        } else {
          console.error('未获取到 ETag');
          console.error('请检查 OBS CORS 配置,确保 Access-Control-Expose-Headers 包含 ETag');
          reject(new Error('分片上传成功但未获取到 ETag,请检查 OBS CORS 配置'));
        }
      } else {
        console.error('OBS 分片上传失败:', xhr.status, xhr.responseText);
        reject(new Error(`分片上传失败: ${xhr.status}`));
      }
    };

    // 上传错误
    xhr.onerror = err => {
      console.error('OBS 分片上传错误:', err);
      console.error('可能的原因:');
      console.error('1. OBS CORS 配置未设置或配置不正确');
      console.error('2. 预签名 URL 已过期');
      console.error('3. 网络问题');
      console.error('请在华为云 OBS 控制台配置 CORS 规则,允许来自 localhost 的 PUT 请求');
      reject(new Error('分片上传失败,请检查 OBS CORS 配置'));
    };

    // 发送分片
    xhr.send(chunk);
  });
};

/**
 * 分片上传文件
 */
export const chunkedUpload = async (options: UploadOptions): Promise<UploadResult> => {
  const { file, chunkSize = 5, onProgress } = options;

  if (!file) {
    throw new Error('文件不能为空');
  }

  // 1. 计算文件 MD5
  // console.log('开始计算文件 MD5...');
  const md5Value = await calculateFileMD5(file);
  // console.log('文件 MD5:', md5Value);

  // 2. 计算分片数量
  const totalChunks = Math.ceil(file.size / (chunkSize * 1024 * 1024));
  console.log('文件分片数:', totalChunks);

  // 3. 获取预上传链接
  console.log('获取预上传链接...');
  const preUploadData: PreUploadData = await handlePreUploadUrl(file.name, md5Value, totalChunks, 3600);

  const { exists, fileUrl, fileId, uploadId, objectKey, partSignList } = preUploadData;
  if (exists) {
    message.success('文件上传成功');
    console.log('文件已存在', fileUrl);

    return {
      exists: true,
      fileId,
      fileName: file.name || preUploadData.filePath,
      filePath: preUploadData.filePath || fileUrl,
      fileUrl: fileUrl
    };
  }

  console.log('预上传链接获取成功:', { uploadId, objectKey, partCount: partSignList.length });

  // 4. 分片上传
  console.log('开始分片上传...');
  const parts: { etag: string; partNumber: number }[] = [];

  for (let i = 0; i < partSignList.length; i++) {
    const partSign = partSignList[i];
    const start = i * chunkSize * 1024 * 1024;
    const end = Math.min(start + chunkSize * 1024 * 1024, file.size);
    const chunk = file.slice(start, end);

    // 更新进度
    const progress = Math.round(((i + 1) / partSignList.length) * 100);
    if (onProgress) {
      onProgress(progress);
    }
    // console.log(`上传进度: ${progress}% (${i + 1}/${partSignList.length})`);

    // 上传分片并获取 ETag
    const etag = await uploadChunk(chunk, partSign.signedUrl);
    parts.push({
      etag,
      partNumber: partSign.partNumber
    });
  }

  // 5. 上传完成后更新文件信息
  console.log('所有分片上传完成,更新文件信息...');
  const finishRes = await finishFile({
    id: fileId,
    uploadId,
    objectKey,
    bizId: options.bizId || '',
    bizType: options.bizType || '',
    parts
  });

  console.log('文件上传成功finishRes', finishRes);

  return {
    exists: false,
    fileId,
    fileName: file.name || finishRes.fileName,
    fileSize: file.size,
    filePath: fileUrl || finishRes.filePath,
    fileUrl: fileUrl || finishRes.fileUrl,
    fileMd5: md5Value
  };
};

在组件中使用

javascript 复制代码
<a-upload-dragger
  :show-upload-list="false"
  :before-upload="() => false"
  name="file"
  @change="(info: any) => handleFileChange(info, 'left')"
>
  <a-spin :spinning="fileUploading.left" :tip="`文件上传中...${fileUploadProgress.left}%`">
    <div class="upload-box">
      <video
        v-if="modalForm.leftVideoPath"
        class=""
        controls="true"
        :src="modalForm.leftVideoPath"
        initial-time="0"
        :autoplay="false"
        loop="false"
        muted="false"
        width="100%"
        height="100%"
      ></video>
      <div class="upload-content" v-else>
        <span class="upload-icon">+</span>
        <span class="upload-text">上传视频</span>
      </div>
    </div>
  </a-spin>
</a-upload-dragger>
javascript 复制代码
const handleFileChange = async (info: any) => {
  const file = info.file.originFileObj || info.file;

  if (!file) {
    message.error('文件选择失败');
    return;
  }

  // 检查文件类型
  if (!file.type.startsWith('video/')) {
    message.error('请选择视频文件');
    return;
  }

  // 检查文件大小(最大 500MB)
  const maxSize = 500 * 1024 * 1024;
  if (file.size > maxSize) {
    message.error(`文件大小不能超过 500MB`);
    return;
  }

  try {

    // 执行分片上传
    const result = await chunkedUpload({
      file,
      chunkSize: 5, // 5MB 分片
      bizId: modalForm.value.id || `temp${new Date().getTime()}`, // 使用活动ID作为业务ID
      bizType: 'video_analysis', // 业务类型:视频分析
      onProgress: progress => {
        console.log(`${side} 侧上传进度: ${progress}%`);
        fileUploadProgress[side] = progress;
      }
    });
    console.log('分片上传result', result);
    message.success(`${result.fileName} 上传成功`);
  } catch (error: any) {
    console.error('上传失败:', error);
    message.error(error.message || '上传失败');
  }
};

常见问题与解决方案

1. 获取不到 ETag

**问题现象:** 分片上传成功,但 `xhr.getResponseHeader('ETag')` 返回 null

**解决方案:**

  • 检查 OBS CORS 配置,确保包含 `<ExposeHeader>ETag</ExposeHeader>`

  • 尝试同时获取 `ETag` 和 `Etag`(大小写兼容)

  • 使用 `xhr.getAllResponseHeaders()` 调试查看所有响应头

2. ETag 格式问题

**问题现象:** 获取到的 ETag 包含双引号,如 `"abc123"`

**解决方案:**

```typescript

etag = etag.replace(/^"|"$/g, '');

```

3. 跨域错误

**问题现象:** 浏览器控制台报 CORS 错误

**解决方案:**

  • 在 OBS 控制台配置 CORS 规则

  • 确保 `AllowedOrigin` 包含你的域名(开发环境可以是 `*` 或 `http://localhost:*`)

  • 确保 `AllowedMethod` 包含 `PUT`

4. 签名 URL 过期

**问题现象:** 上传过程中提示签名过期

**解决方案:**

  • 增加 `expiresSeconds` 参数(最大 7200 秒)

  • 对于超大文件,考虑减小分片大小或优化上传速度

  • 实现重试机制,检测到过期后重新获取签名

5. 内存溢出

**问题现象:** 计算大文件 MD5 时浏览器卡死或崩溃

**解决方案:**

  • 使用分片计算 MD5(如示例中的 2MB 分片)

  • 避免一次性将整个文件读入内存

  • 使用 `FileReader` 异步读取

参考资料

  • 华为云 OBS 官方文档\](https://support.huaweicloud.com/obs/index.html)

  • spark-md5 GitHub\](https://github.com/satazor/js-spark-md5)

相关推荐
largecode8 小时前
怎么让手机显示公司名?来电显示公司名称认证实现品牌外显
linux·ubuntu·华为od·华为·智能手机·华为云·harmonyos
shizhan_cloud1 天前
华为云核心服务运维知识点与高频实操问题总结
运维·华为云
号码认证服务2 天前
如何让经销商接电话时看到“XX集团”?申请号码认证统一上线
服务器·经验分享·sql·华为·智能手机·华为云·云计算
容器魔方2 天前
华为云 AgentArts 智能体评估, 驱动智能体自优化
云原生·容器·开源·华为云·云计算
童先生2 天前
华为云、阿里云、AWS签名机制详解! AK/SK + HMAC-SHA256 签名鉴权!
算法·阿里云·华为云·云计算
大雷神4 天前
HarmonyOS APP<<古今职鉴定>>开源教程第2篇:开发环境搭建:DevEco Studio 全攻略
华为·华为云·harmonyos
容器魔方4 天前
Karmada 用户组再迎新成员 | GMI Cloud 正式加入!
大数据·云原生·容器·华为云·云计算
Elaine3365 天前
机器学习概述
人工智能·机器学习·华为云
FairGuard手游加固17 天前
双云权威认证|FairGuard游戏加固上架华为云、阿里云商店
游戏·阿里云·华为云