本文分享 RustFS 对象存储前段直传的完整实现方式,改变了传统的浏览器-> 后端服务器 -> 对象存储的上传方式,提高了效率和安全性。文章包括 5 个部分:
- 前言
- 核心概念
- 两种方案详解
- 技术实现
- 完整示例
- 最佳实践
什么是前端直传?
传统的文件上传流程:浏览器 → 后端服务器 → 对象存储这种方式存在以下问题:
- 占用后端服务器带宽和资源
- 上传速度受限于后端服务器
- 需要处理大文件的内存管理
- 服务器成本增加
前端直传则是:浏览器 → 对象存储
优势:
- ✅ 减轻后端服务器压力
- ✅ 上传速度更快(直连对象存储)
- ✅ 降低服务器成本
- ✅ 支持大文件上传
安全性问题
直传面临的核心问题:如何在不暴露永久密钥的情况下,让前端安全地上传文件?本教程介绍两种解决方案:
- 预签名 URL 方案(推荐)
- 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 凭证(减少请求)
- 文件验证
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
}
- 错误处理和重试
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)))
}
}
}
- 安全建议
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
# ...
- 性能优化
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)
}
总结
核心要点
- ✅ 使用 AWS SDK - boto3(后端)和 @aws-sdk/client-s3(前端)
- ✅ 预签名 URL - 简单场景首选,后端完全控制
- ✅ STS 凭证 - 批量上传,减少网络请求
- ✅ 永久密钥不暴露 - 只在后端使用
- ✅ 添加验证 - 文件类型、大小、用户权限
- ✅ 错误处理 - 重试机制,友好提示两种方案对比

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