基于华为云 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` 异步读取

参考资料

相关推荐
晨间晓7 小时前
图片里的高频和低频是什么?用华为云码道开发 OpenCV 傅里叶频谱分析工具
opencv·计算机视觉·华为云
晨间晓8 小时前
图片里的阴影能不能减弱?用华为云码道开发 OpenCV 光照校正工具
人工智能·opencv·华为云
熊猫钓鱼>_>8 小时前
Kotlin Multiplatform for OpenHarmony 实战:kotlinx-serialization JSON 适配
开发语言·华为·kotlin·华为云·json·harmonyos·code
晨间晓1 天前
图片里的细小噪点怎么平滑?用华为云码道开发 OpenCV 图像滤波对比工具
人工智能·opencv·华为云
偷吃米饭1 天前
图片特征怎么描述?用华为云码道开发 OpenCV ORB 特征提取工具
人工智能·opencv·华为云
晨间晓1 天前
图片里的关键点怎么找?用华为云码道开发 OpenCV FAST 特征点检测工具
人工智能·opencv·华为云
晨间晓1 天前
二值图里的噪点怎么去掉?用华为云码道开发 OpenCV 形态学操作工具
人工智能·opencv·华为云
晨间晓1 天前
图片里的角点在哪里?用华为云码道开发 OpenCV Harris 角点检测工具
人工智能·opencv·华为云
晨间晓2 天前
图片细节丰富不丰富?用华为云码道开发 OpenCV 纹理复杂度分析工具
人工智能·opencv·华为云
晨间晓2 天前
图片边缘到底怎么提取?用华为云码道开发 OpenCV Canny 参数可视化工具
华为云