RustFS 如何实现对象存储的前端直传?

本文分享 RustFS 对象存储前段直传的完整实现方式,改变了传统的浏览器-> 后端服务器 -> 对象存储的上传方式,提高了效率和安全性。文章包括 5 个部分:

  • 前言
  • 核心概念
  • 两种方案详解
  • 技术实现
  • 完整示例
  • 最佳实践

什么是前端直传?

传统的文件上传流程:浏览器 → 后端服务器 → 对象存储这种方式存在以下问题:

  • 占用后端服务器带宽和资源
  • 上传速度受限于后端服务器
  • 需要处理大文件的内存管理
  • 服务器成本增加

前端直传则是:浏览器 → 对象存储

优势:

  • ✅ 减轻后端服务器压力
  • ✅ 上传速度更快(直连对象存储)
  • ✅ 降低服务器成本
  • ✅ 支持大文件上传

安全性问题

直传面临的核心问题:如何在不暴露永久密钥的情况下,让前端安全地上传文件?本教程介绍两种解决方案:

  1. 预签名 URL 方案(推荐)
  2. STS 临时凭证方案

对象存储基础

对象存储使用类似 AWS S3 的模型:

yaml 复制代码
存储桶 (Bucket)
└── 对象 (Object)
    ├── Key: "uploads/photo.jpg"  # 对象路径
    ├── Value: [文件内容]
    └── Metadata: {ContentType, Size, etc.}

访问控制

对象存储通过 Access Key 和 Secret Key 进行身份验证:

vbnet 复制代码
永久密钥(长期有效,不应暴露给前端)
├── Access Key ID: "user-2"
└── Secret Access Key: "rustfsadmin"

临时凭证(短期有效,可以给前端使用)
├── Access Key ID
├── Secret Access Key
└── Session Token

两种方案详解

方案一:预签名 URL 方案(推荐)

适用场景

  • 单文件上传
  • 表单提交时附带文件
  • 简单安全的上传需求交
互流程
复制代码
浏览器                    后端服务                   RustFS
  │                          │                          │
  │ ①请求预签名URL            │                          │
  ├──────────────────────────>│                          │
  │                          │                          │
  │                          │ ②使用boto3生成签名URL     │
  │                          │                          │
  │ ③返回预签名URL            │                          │
  │<──────────────────────────┤                          │
  │                          │                          │
  │ ④使用预签名URL直传文件                               │
  ├─────────────────────────────────────────────────────>│
  │                          │                          │
  │ ⑤返回成功                                            │
  │<──────────────────────────────────────────────────────┤

优势:

  • 前端实现极简,无需处理签名
  • 后端完全控制权限
  • 无需额外依赖
  • 每个文件独立权限控制

方案二:STS 临时凭证方案

推荐场景:

  • 批量文件上传(如相册上传)
  • 长时间上传操作
  • 需要多次上传的场景
交互流程
复制代码
浏览器                    后端服务                   RustFS
  │                          │                          │
  │ ①请求临时凭证              │                          │
  ├──────────────────────────>│                          │
  │                          │                          │
  │ ②返回临时凭证              │                          │
  │<──────────────────────────┤                          │
  │                          │                          │
  │ ③前端使用SDK上传(凭证可复用)                        │
  ├─────────────────────────────────────────────────────>│
  │                          │                          │
  │ ④继续上传其他文件                                     │
  ├─────────────────────────────────────────────────────>│

优势

  • 凭证可复用,减少网络请求
  • 适合批量上传
  • 灵活控制权限

技术实现

后端实现(使用 boto3)

  • 安装依赖

    pip install boto3 flask flask-cors

  • 核心代码

python 复制代码
import boto3
from botocore.client import Config
from flask import Flask, request, jsonify
from flask_cors import CORS

app = Flask(__name__)
CORS(app)

# 配置
RUSTFS_CONFIG = {
    'access_key_id': 'user-2',
    'secret_access_key': 'rustfsadmin',
    'endpoint_url': 'http://127.0.0.1:9000',
    'bucket_name': 'test-bucket',
    'region_name': 'us-east-1'
}

def create_s3_client():
    """创建 S3 客户端"""
    return boto3.client(
        's3',
        aws_access_key_id=RUSTFS_CONFIG['access_key_id'],
        aws_secret_access_key=RUSTFS_CONFIG['secret_access_key'],
        endpoint_url=RUSTFS_CONFIG['endpoint_url'],
        region_name=RUSTFS_CONFIG['region_name'],
        config=Config(
            signature_version='s3v4',
            s3={'addressing_style': 'path'}
        )
    )

# 方案一:预签名 URL
@app.route('/api/presigned-url', methods=['POST'])
def get_presigned_url():
    """生成预签名 URL"""
    data = request.get_json()
    object_key = data['object_key']
    content_type = data.get('content_type', 'application/octet-stream')
    expires = data.get('expires', 3600)

    s3_client = create_s3_client()

    # boto3 自动处理签名
    presigned_url = s3_client.generate_presigned_url(
        ClientMethod='put_object',
        Params={
            'Bucket': RUSTFS_CONFIG['bucket_name'],
            'Key': object_key,
            'ContentType': content_type
        },
        ExpiresIn=expires
    )

    return jsonify({
        'code': 0,
        'data': {
            'url': presigned_url,
            'method': 'PUT',
            'headers': {'Content-Type': content_type}
        }
    })

# 方案二:STS 临时凭证
@app.route('/api/sts/credentials', methods=['POST'])
def get_sts_credentials():
    """获取 S3 临时凭证"""
    # 生产环境应该使用真实的 STS AssumeRole
    # sts_client = boto3.client('sts')
    # response = sts_client.assume_role(...)

    return jsonify({
        'code': 0,
        'data': {
            'access_key_id': RUSTFS_CONFIG['access_key_id'],
            'secret_access_key': RUSTFS_CONFIG['secret_access_key'],
            'endpoint_url': RUSTFS_CONFIG['endpoint_url'],
            'bucket_name': RUSTFS_CONFIG['bucket_name'],
            'region_name': RUSTFS_CONFIG['region_name']
        }
    })

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000, debug=True)

前端实现(使用 @aws-sdk/client-s3)

  • 安装依赖
bash 复制代码
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner vue
  • 方案一:预签名 URL
typescript 复制代码
上传// utils/upload-presigned.ts

/**
 * 使用预签名 URL 上传文件
 */
exportasyncfunction uploadWithPresignedUrl(
  file: File,
  objectKey: string,
  onProgress?: (progress: number) => void
): Promise<string> {
// 1. 获取预签名 URL
const response = await fetch('http://127.0.0.1:9000/api/presigned-url', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      object_key: objectKey,
      content_type: file.type
    })
  })
const { data } = await response.json()

// 2. 使用预签名 URL 上传
returnnewPromise((resolve, reject) => {
    const xhr = new XMLHttpRequest()

    xhr.upload.addEventListener('progress', (e) => {
      if (e.lengthComputable && onProgress) {
        onProgress(Math.round((e.loaded / e.total) * 100))
      }
    })

    xhr.addEventListener('load', () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        resolve(data.url.split('?')[0])
      } else {
        reject(newError(`上传失败: ${xhr.status}`))
      }
    })

    xhr.addEventListener('error', () => reject(newError('网络错误')))

    xhr.open(data.method, data.url, true)
    Object.entries(data.headers).forEach(([key, value]) => {
      xhr.setRequestHeader(key, value asstring)
    })
    xhr.send(file)
  })
}
  • 方案二:STS 凭证
typescript 复制代码
上传/ utils/upload-sdk.ts

import { S3Client, PutObjectCommand } from'@aws-sdk/client-s3'
import { getSignedUrl } from'@aws-sdk/s3-request-presigner'

interface S3Credentials {
  access_key_id: string
  secret_access_key: string
  endpoint_url: string
  bucket_name: string
  region_name: string
}

/**
 * 使用 AWS SDK 上传文件(带进度)
 */
exportasyncfunction uploadWithSDK(
  file: File,
  objectKey: string,
  credentials: S3Credentials,
  onProgress?: (progress: number) => void
): Promise<string> {
// 1. 创建 S3 客户端
const s3Client = new S3Client({
    credentials: {
      accessKeyId: credentials.access_key_id,
      secretAccessKey: credentials.secret_access_key
    },
    endpoint: credentials.endpoint_url,
    region: credentials.region_name,
    forcePathStyle: true
  })

// 2. 如果需要进度,使用预签名 URL + XHR
if (onProgress) {
    const command = new PutObjectCommand({
      Bucket: credentials.bucket_name,
      Key: objectKey,
      ContentType: file.type
    })

    const presignedUrl = await getSignedUrl(s3Client, command, { expiresIn: 3600 })

    returnnewPromise((resolve, reject) => {
      const xhr = new XMLHttpRequest()

      xhr.upload.addEventListener('progress', (e) => {
        if (e.lengthComputable) {
          onProgress(Math.round((e.loaded / e.total) * 100))
        }
      })

      xhr.addEventListener('load', () => {
        if (xhr.status >= 200 && xhr.status < 300) {
          resolve(`${credentials.endpoint_url}/${credentials.bucket_name}/${objectKey}`)
        } else {
          reject(newError(`上传失败: ${xhr.status}`))
        }
      })

      xhr.addEventListener('error', () => reject(newError('网络错误')))

      xhr.open('PUT', presignedUrl, true)
      xhr.setRequestHeader('Content-Type', file.type)
      xhr.send(file)
    })
  }

// 3. 简单上传(无进度)
const command = new PutObjectCommand({
    Bucket: credentials.bucket_name,
    Key: objectKey,
    Body: file,
    ContentType: file.type
  })

await s3Client.send(command)
return`${credentials.endpoint_url}/${credentials.bucket_name}/${objectKey}`
}

/**
 * 获取 S3 凭证
 */
exportasyncfunction getS3Credentials(): Promise<S3Credentials> {
const response = await fetch('http://127.0.0.1:9000/api/sts/credentials', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' }
  })
const { data } = await response.json()
return data
}

/**
 * 生成唯一的对象路径
 */
exportfunction generateObjectKey(file: File, prefix = 'uploads'): string {
const timestamp = Date.now()
const random = Math.random().toString(36).substring(2, 8)
const ext = file.name.split('.').pop() || ''
return`${prefix}/${timestamp}_${random}.${ext}`
}

Vue 组件示例

xml 复制代码
<template>
  <div class="upload-container">
    <h2>文件上传</h2>

    <div class="upload-area" @click="$refs.fileInput.click()">
      <input
        ref="fileInput"
        type="file"
        multiple
        style="display: none"
        @change="handleFileSelect"
      />
      <p>点击选择文件上传</p>
    </div>

    <div v-for="file in files" :key="file.id" class="file-item">
      <span>{{ file.name }}</span>
      <div v-if="file.status === 'uploading'">
        <progress :value="file.progress" max="100"></progress>
        <span>{{ file.progress }}%</span>
      </div>
      <span v-else-if="file.status === 'success'">✅ 成功</span>
      <span v-else-if="file.status === 'error'">❌ {{ file.error }}</span>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { uploadWithPresignedUrl, generateObjectKey } from '../utils/upload-presigned'
// 或者使用: import { uploadWithSDK, getS3Credentials } from '../utils/upload-sdk'

interface UploadFile {
  id: string
  name: string
  status: 'pending' | 'uploading' | 'success' | 'error'
  progress: number
  error?: string
}

const files = ref<UploadFile[]>([])

const handleFileSelect = (event: Event) => {
  const target = event.target as HTMLInputElement
  if (!target.files) return

  Array.from(target.files).forEach((file) => {
    const uploadFile: UploadFile = {
      id: `${Date.now()}_${Math.random()}`,
      name: file.name,
      status: 'pending',
      progress: 0
    }
    files.value.push(uploadFile)
    startUpload(uploadFile, file)
  })

  target.value = ''
}

const startUpload = async (uploadFile: UploadFile, file: File) => {
  uploadFile.status = 'uploading'

  try {
    const objectKey = generateObjectKey(file)

    // 方案一:预签名 URL
    await uploadWithPresignedUrl(file, objectKey, (progress) => {
      uploadFile.progress = progress
    })

    // 方案二:STS 凭证(需要先获取凭证)
    // const credentials = await getS3Credentials()
    // await uploadWithSDK(file, objectKey, credentials, (progress) => {
    //   uploadFile.progress = progress
    // })

    uploadFile.status = 'success'
  } catch (error) {
    uploadFile.status = 'error'
    uploadFile.error = error instanceof Error ? error.message : '上传失败'
  }
}
</script>

<style scoped>
.upload-area {
  border: 2px dashed #ccc;
  padding: 40px;
  text-align: center;
  cursor: pointer;
}

.upload-area:hover {
  border-color: #666;
}

.file-item {
  padding: 10px;
  border-bottom: 1px solid #eee;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

progress {
  width: 200px;
  margin: 0 10px;
}
</style>

完整示例

场景一:单文件上传(预签名 URL)

javascript 复制代码
// 简单上传一个文件
const file = document.querySelector('input[type="file"]').files[0]
const objectKey = `uploads/${Date.now()}_${file.name}`

await uploadWithPresignedUrl(file, objectKey, (progress) => {
  console.log(`进度: ${progress}%`)
})

console.log('上传成功!')

场景二:批量上传(STS 凭证)

javascript 复制代码
// 批量上传多个文件
const files = [...document.querySelector('input[type="file"]').files]

// 1. 获取一次凭证
const credentials = await getS3Credentials()

// 2. 使用同一凭证上传所有文件
awaitPromise.all(
  files.map((file) => {
    const objectKey = generateObjectKey(file)
    return uploadWithSDK(file, objectKey, credentials, (progress) => {
      console.log(`${file.name}: ${progress}%`)
    })
  })
)

console.log('全部上传成功!')

最佳实践

1. 方案选择

复制代码
单文件或少量文件  →  预签名 URL(简单直接)
批量文件上传      →  STS 凭证(减少请求)
  1. 文件验证
scss 复制代码
function validateFile(file: File): boolean {
// 大小限制(100MB)
if (file.size > 100 * 1024 * 1024) {
    alert('文件过大')
    returnfalse
  }

// 类型限制
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif']
if (!allowedTypes.includes(file.type)) {
    alert('不支持的文件类型')
    returnfalse
  }

returntrue
}
  1. 错误处理和重试
ini 复制代码
async function uploadWithRetry(file: File, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
    try {
      const objectKey = generateObjectKey(file)
      returnawait uploadWithPresignedUrl(file, objectKey)
    } catch (error) {
      if (i === maxRetries - 1) throw error
      awaitnewPromise(resolve => setTimeout(resolve, 1000 * (i + 1)))
    }
  }
}
  1. 安全建议
python 复制代码
# 后端验证
@app.route('/api/presigned-url', methods=['POST'])
def get_presigned_url():
    # ✅ 验证文件类型
    allowed_types = ['image/jpeg', 'image/png']
    if data['content_type'] notin allowed_types:
        return jsonify({'code': 400, 'message': '不支持的文件类型'}), 400

    # ✅ 验证用户权限
    # if not current_user.can_upload():
    #     return jsonify({'code': 403, 'message': '无权限'}), 403

    # ✅ 限制文件路径
    # 确保用户只能上传到自己的目录
    # object_key = f"users/{current_user.id}/{filename}"

    # 生成预签名 URL
    # ...
  1. 性能优化
arduino 复制代码
// 并发控制:最多同时上传 3 个文件
asyncfunction uploadFilesWithLimit(files: File[], limit = 3) {
const queue = [...files]
const results = []
const executing = new Set()

while (queue.length > 0 || executing.size > 0) {
    while (queue.length > 0 && executing.size < limit) {
      const file = queue.shift()!
      const promise = uploadWithPresignedUrl(file, generateObjectKey(file))
        .then((url) => {
          executing.delete(promise)
          return { success: true, url }
        })
        .catch((error) => {
          executing.delete(promise)
          return { success: false, error }
        })

      executing.add(promise)
      results.push(promise)
    }

    if (executing.size > 0) {
      awaitPromise.race(executing)
    }
  }

returnPromise.all(results)
}

总结

核心要点

  1. ✅ 使用 AWS SDK - boto3(后端)和 @aws-sdk/client-s3(前端)
  2. ✅ 预签名 URL - 简单场景首选,后端完全控制
  3. ✅ STS 凭证 - 批量上传,减少网络请求
  4. ✅ 永久密钥不暴露 - 只在后端使用
  5. ✅ 添加验证 - 文件类型、大小、用户权限
  6. ✅ 错误处理 - 重试机制,友好提示两种方案对比

方案对比教程完成!开始构建你的文件上传功能吧! 🎉

相关推荐
摇滚侠2 小时前
40分钟的Docker实战攻略,一期视频精通Docker
运维·docker·容器
用户4672695597612 小时前
vue 表格 vxe-table 树结构实现单元格复制粘贴功能,实现树层级节点复制功能
vue.js
G_H_S_3_2 小时前
【网络运维】Docker网络:基础与实战
linux·运维·网络·docker
加藤不太惠2 小时前
docker简单了解使用
运维·docker·容器
<e^πi+1=0>3 小时前
Docker部署Lighthouse CI Server总结
ci/cd·docker·容器
建群新人小猿3 小时前
陀螺匠企业助手 运行环境
java·大数据·人工智能·docker·php
放逐者-保持本心,方可放逐3 小时前
PDFObject 在 Vue 项目中的应用实例详解
前端·javascript·vue.js
龙仔CLL3 小时前
vue2项目使用zoom解决pc端浏览器百分比缩放,布局样式不兼容问题
vue.js·html·zoom
一 乐3 小时前
养老院信息|基于springboot + vue养老院信息管理系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·后端