在 React Native 中集成 MinIO 对象存储(图片/文件上传服务)

10 阅读6分钟

前言

在移动应用开发中,文件上传和存储是一个常见需求。无论是用户头像、签名图片还是各类文档,都需要一个可靠的存储方案。MinIO 作为一个高性能的对象存储服务,完全兼容 AWS S3 API,成为了许多开发者的首选。

本文将详细介绍如何在 React Native 项目中集成 MinIO,包括环境配置、SDK 集成、实际代码示例以及最佳实践。

为什么选择 MinIO?

MinIO 的优势

  1. 完全兼容 S3 API - 可以直接使用 AWS SDK,无需学习新的 API
  2. 高性能 - 基于 Go 语言开发,性能优异
  3. 自托管 - 可以部署在自己的服务器上,数据完全可控
  4. 开源免费 - 基于 Apache License 2.0 开源
  5. 简单易用 - 配置简单,上手快速

与其他方案对比

方案优势劣势
MinIO自托管、高性能、免费需要自己维护服务器
AWS S3无需维护、全球分发需要付费、数据在云端
阿里云 OSS国内访问快、功能丰富需要付费、厂商锁定
本地存储无需网络、速度快存储空间有限、无法跨设备

技术方案

使用 AWS S3 SDK

由于 MinIO 完全兼容 S3 API,我们可以直接使用 AWS 官方的 JavaScript SDK:

npm install @aws-sdk/client-s3
# 或
yarn add @aws-sdk/client-s3

同时需要安装 react-native-config 来管理环境变量:

npm install react-native-config
# 或
yarn add react-native-config

环境配置

1. 配置环境变量

在项目根目录创建 .env 文件:

# MinIO 配置
MINIO_ENDPOINT='http://xxx:xxx'
MINIO_ACCESS_KEY='your_access_key'
MINIO_SECRET_KEY='your_secret_key'
MINIO_BUCKET='your_bucket_name'
MINIO_USE_SSL=false

2. 初始化 S3 客户端

创建一个自定义 Hook 来封装 MinIO 操作:

// src/hooks/useMinio.js
import {useState, useEffect, useCallback, useRef} from 'react';
import {S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand} from '@aws-sdk/client-s3';
import Config from 'react-native-config';

const useMinio = () => {
  const [client, setClient] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const bucketName = Config.MINIO_BUCKET || 'default-bucket';
  const clientRef = useRef(null);

  // 初始化 S3 客户端
  useEffect(() => {
    if (!clientRef.current) {
      try {
        const endpoint = Config.MINIO_ENDPOINT || 'http://localhost:9000';
        
        const s3Client = new S3Client({
          endpoint: endpoint,
          forcePathStyle: true, // MinIO 需要路径风格
          region: 'us-east-1',
          credentials: {
            accessKeyId: Config.MINIO_ACCESS_KEY || '',
            secretAccessKey: Config.MINIO_SECRET_KEY || '',
          },
        });
        
        clientRef.current = s3Client;
        setClient(s3Client);
      } catch (err) {
        setError(err);
        console.error('Error initializing S3 client:', err);
      }
    }
  }, []);

  return {
    loading,
    error,
    bucketName,
    client,
  };
};

export default useMinio;

关键配置说明

  • forcePathStyle: true - MinIO 必须使用路径风格(/bucket/object),而不是虚拟主机风格
  • region - MinIO 默认使用 us-east-1,可以自定义
  • endpoint - MinIO 服务器地址,包含端口

核心功能实现

1. 上传文件

上传文件是最常用的功能。在 React Native 中,我们通常处理的是 Buffer 或 Base64 格式的数据。

const uploadImageFromBuffer = useCallback(async (buffer, objectName, contentType = 'image/jpeg') => {
  if (!client) {
    throw new Error('S3 client not initialized');
  }

  setLoading(true);
  setError(null);

  try {
    const command = new PutObjectCommand({
      Bucket: bucketName,
      Key: objectName,
      Body: buffer,
      ContentType: contentType,
    });

    await client.send(command);
    console.log(`File uploaded successfully as ${objectName}`);
    
    return objectName;
  } catch (err) {
    setError(err);
    console.error('Error uploading file:', err);
    throw err;
  } finally {
    setLoading(false);
  }
}, [client, bucketName]);

2. 获取文件 URL

获取已上传文件的访问 URL:

const getImageUrl = useCallback(async (objectName) => {
  try {
    const endpoint = Config.MINIO_ENDPOINT || 'http://localhost:9000';
    
    // 构建简单 URL 格式:endpoint/bucket/objectName
    const url = `${endpoint}/${bucketName}/${objectName}`;
    
    console.log('Generated image URL:', url);
    return url;
  } catch (err) {
    setError(err);
    console.error('Error getting image URL:', err);
    throw err;
  }
}, [bucketName]);

3. 删除文件

const deleteImage = useCallback(async (objectName) => {
  if (!client) {
    throw new Error('S3 client not initialized');
  }

  try {
    const command = new DeleteObjectCommand({
      Bucket: bucketName,
      Key: objectName,
    });

    await client.send(command);
    console.log(`File ${objectName} deleted successfully`);
  } catch (err) {
    setError(err);
    console.error('Error deleting file:', err);
    throw err;
  }
}, [client, bucketName]);

4. 检查文件是否存在

const objectExists = useCallback(async (objectName) => {
  if (!client) {
    throw new Error('S3 client not initialized');
  }

  try {
    const command = new HeadObjectCommand({
      Bucket: bucketName,
      Key: objectName,
    });

    await client.send(command);
    return true;
  } catch (err) {
    if (err.name === 'NotFound' || err.$metadata?.httpStatusCode === 404) {
      return false;
    }
    throw err;
  }
}, [client, bucketName]);

实际应用示例

场景:电子签名上传

以下是一个完整的电子签名上传示例,包括 Base64 转换、上传和 URL 获取:

import React, {useState} from 'react';
import {View, TouchableOpacity, Text, ActivityIndicator} from 'react-native';
import useMinio from '../../hooks/useMinio';

const SignatureUpload = () => {
  const {uploadImageFromBuffer, getImageUrl, loading} = useMinio();
  const [signatureUrl, setSignatureUrl] = useState(null);

  const handleSignatureUpload = async (base64Signature) => {
    try {
      // 1. 提取 base64 数据
      let base64Data = base64Signature;
      if (base64Data.includes('base64,')) {
        base64Data = base64Data.split('base64,')[1];
      }

      // 2. 将 base64 转换为 Uint8Array(React Native 兼容方式)
      const base64Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
      const decodeLength = (base64Data.length * 3) / 4;
      const bytes = new Uint8Array(decodeLength);
      let bufferIndex = 0;
      
      for (let i = 0; i < base64Data.length; i += 4) {
        const enc1 = base64Chars.indexOf(base64Data[i]);
        const enc2 = base64Chars.indexOf(base64Data[i + 1]);
        const enc3 = base64Chars.indexOf(base64Data[i + 2] || '=');
        const enc4 = base64Chars.indexOf(base64Data[i + 3] || '=');
        
        bytes[bufferIndex++] = (enc1 << 2) | (enc2 >> 4);
        if (enc3 !== 64) {
          bytes[bufferIndex++] = ((enc2 & 15) << 4) | (enc3 >> 2);
        }
        if (enc4 !== 64) {
          bytes[bufferIndex++] = ((enc3 & 3) << 6) | enc4;
        }
      }

      const actualBytes = bytes.slice(0, bufferIndex);

      // 3. 生成唯一的对象名称
      const timestamp = Date.now();
      const userId = 'user123'; // 实际项目中从用户信息获取
      const objectName = `${userId}/${timestamp}.png`;

      // 4. 上传到 MinIO
      await uploadImageFromBuffer(actualBytes, objectName, 'image/png');

      // 5. 获取在线 URL
      const imageUrl = await getImageUrl(objectName);
      
      setSignatureUrl(imageUrl);
      console.log('Signature uploaded successfully:', imageUrl);
      
      return imageUrl;
    } catch (error) {
      console.error('Error uploading signature:', error);
      throw error;
    }
  };

  return (
    <View>
      <TouchableOpacity onPress={() => handleSignatureUpload('your_base64_data')}>
        <Text>上传签名</Text>
      </TouchableOpacity>
      
      {loading && <ActivityIndicator />}
      
      {signatureUrl && (
        <Image source={{uri: signatureUrl}} style={{width: 200, height: 100}} />
      )}
    </View>
  );
};

最佳实践

1. 对象命名规范

建议使用有层次结构的命名方式:

{userId}/{type}/{timestamp}.{extension}

示例:

  • user123/avatar/1713456789000.jpg
  • user123/signature/1713456789001.png
  • user456/document/1713456789002.pdf

2. 文件大小限制

在上传前检查文件大小,避免上传过大的文件:

const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB

const uploadWithSizeCheck = async (buffer, objectName) => {
  if (buffer.length > MAX_FILE_SIZE) {
    throw new Error('File size exceeds 5MB limit');
  }
  return uploadImageFromBuffer(buffer, objectName);
};

3. 错误处理

完善的错误处理机制:

const handleUpload = async () => {
  try {
    setLoading(true);
    const url = await uploadImageFromBuffer(buffer, objectName);
    Toast.success('上传成功');
    return url;
  } catch (error) {
    if (error.name === 'NetworkError') {
      Toast.error('网络错误,请检查网络连接');
    } else if (error.name === 'AccessDenied') {
      Toast.error('权限不足,请联系管理员');
    } else {
      Toast.error('上传失败,请重试');
    }
    console.error('Upload error:', error);
  } finally {
    setLoading(false);
  }
};

4. 进度显示

对于大文件上传,可以添加进度显示(需要使用分片上传):

// 使用 @aws-sdk/lib-storage 支持进度显示
import {Upload} from '@aws-sdk/lib-storage';

const uploadWithProgress = async (buffer, objectName, onProgress) => {
  const upload = new Upload({
    client,
    params: {
      Bucket: bucketName,
      Key: objectName,
      Body: buffer,
    },
  });

  upload.on('httpUploadProgress', (progress) => {
    const percentage = Math.round((progress.loaded / progress.total) * 100);
    onProgress(percentage);
  });

  await upload.done();
};

5. 缓存策略

对于频繁访问的图片,可以实现本地缓存:

import {AsyncStorage} from 'react-native';

const getCachedOrUpload = async (localPath, objectName) => {
  const cacheKey = `cached_${objectName}`;
  const cachedUrl = await AsyncStorage.getItem(cacheKey);
  
  if (cachedUrl) {
    return cachedUrl;
  }
  
  const url = await uploadImageFromBuffer(buffer, objectName);
  await AsyncStorage.setItem(cacheKey, url);
  return url;
};

常见问题

Q1: 为什么需要 forcePathStyle: true

MinIO 使用路径风格的 URL(/bucket/object),而 AWS S3 默认使用虚拟主机风格(bucket.s3.amazonaws.com/object)。设置 forcePathStyle: true 可以确保 SDK 使用正确的 URL 格式。

Q2: 如何处理网络中断?

实现重试机制:

const uploadWithRetry = async (buffer, objectName, maxRetries = 3) => {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await uploadImageFromBuffer(buffer, objectName);
    } catch (error) {
      if (i === maxRetries - 1) throw error;
      await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
    }
  }
};

Q3: 如何实现文件预签名 URL?

对于需要临时访问的文件,可以使用预签名 URL:

import {getSignedUrl} from '@aws-sdk/s3-request-presigner';

const getPresignedUrl = async (objectName, expiresIn = 3600) => {
  const command = new GetObjectCommand({
    Bucket: bucketName,
    Key: objectName,
  });
  
  return await getSignedUrl(client, command, {expiresIn});
};

Q4: React Native 中如何处理文件选择?

可以使用 react-native-document-pickerreact-native-image-picker

npm install react-native-image-picker
import {launchImageLibrary} from 'react-native-image-picker';

const pickAndUpload = async () => {
  const result = await launchImageLibrary({mediaType: 'photo'});
  
  if (result.assets && result.assets[0]) {
    const asset = result.assets[0];
    // asset.uri 是本地文件路径
    // 需要转换为 Buffer 后再上传
  }
};

性能优化

1. 并发上传

对于多个文件,使用并发上传:

const uploadMultiple = async (files) => {
  const uploadPromises = files.map(file => 
    uploadImageFromBuffer(file.buffer, file.objectName)
  );
  
  return Promise.all(uploadPromises);
};

2. 压缩图片

上传前压缩图片以减少带宽:

npm install react-native-image-resizer
import ImageResizer from 'react-native-image-resizer';

const compressAndUpload = async (imagePath, objectName) => {
  const compressed = await ImageResizer.createResizedImage(
    imagePath,
    800, // 宽度
    600, // 高度
    'JPEG',
    80 // 质量
  );
  
  // 读取压缩后的文件并上传
  const buffer = await readFile(compressed.uri);
  return uploadImageFromBuffer(buffer, objectName, 'image/jpeg');
};

3. CDN 加速

如果 MinIO 服务器在国内,可以考虑配置 CDN 加速:

const getImageUrl = useCallback(async (objectName) => {
  const cdnEndpoint = Config.MINIO_CDN_ENDPOINT || Config.MINIO_ENDPOINT;
  const url = `${cdnEndpoint}/${bucketName}/${objectName}`;
  return url;
}, [bucketName]);

安全建议

1. 环境变量管理

  • 不要将敏感信息提交到代码仓库
  • 使用 .env.local 存储本地开发配置
  • 生产环境使用安全的密钥管理方案

2. 访问控制

  • 为不同用户创建不同的 Access Key
  • 设置合理的 Bucket 策略
  • 定期轮换密钥

3. 数据加密

  • 敏感数据上传前加密
  • 使用 HTTPS 传输
  • MinIO 支持服务器端加密

总结

MinIO 是一个优秀的对象存储解决方案,在 React Native 中集成也非常简单。通过使用 AWS S3 SDK,我们可以快速实现文件上传、下载、删除等功能。

本文介绍了从环境配置到实际应用的完整流程,包括核心功能实现、最佳实践和常见问题解决方案。希望这些内容能帮助你在 React Native 项目中更好地使用 MinIO。

参考资源