阅读视图

发现新文章,点击刷新页面。

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

前言

在移动应用开发中,文件上传和存储是一个常见需求。无论是用户头像、签名图片还是各类文档,都需要一个可靠的存储方案。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。

参考资源

❌