本指南详细说明如何将 ObjectQL 文件附件存储到 AWS S3,包括完整的实现代码和最佳实践。
使用 AWS S3 存储文件附件有以下优势:
✅ 可扩展性 - 无限存储容量,无需管理磁盘空间
✅ 高可用性 - 99.99% 可用性 SLA,自动多区域备份
✅ 高性能 - 与 CloudFront CDN 集成,全球加速访问
✅ 成本优化 - 按需付费,支持生命周期管理和存储分层
✅ 安全性 - 支持加密、访问控制、签名 URL 等安全特性
✅ 易于维护 - 无需管理服务器,AWS 负责基础设施
ObjectQL 通过 IFileStorage 接口实现存储抽象:
interface IFileStorage {
save(file: Buffer, filename: string, mimeType: string, options?: FileStorageOptions): Promise<AttachmentData>;
get(fileId: string): Promise<Buffer | null>;
delete(fileId: string): Promise<boolean>;
getPublicUrl(fileId: string): string;
}┌─────────────┐
│ 客户端 │
│ (Browser) │
└──────┬──────┘
│ ① 上传文件
▼
┌─────────────┐
│ ObjectQL │
│ Server │──┐
└──────┬──────┘ │ ② 调用 S3FileStorage
│ │
│ ▼
│ ┌─────────────┐
│ │ S3FileStorage│
│ │ implements │
│ │ IFileStorage │
│ └──────┬───────┘
│ │ ③ 上传到 S3
│ ▼
│ ┌─────────────┐
│ │ AWS S3 │
│ │ Bucket │
│ └──────┬──────┘
│ │
│ │ ④ 返回 URL
▼ ▼
┌─────────────────────┐
│ Database │
│ (存储文件元数据) │
└─────────────────────┘
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner或使用 pnpm:
pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner完整实现代码请参考:s3-storage-implementation.ts
核心特性:
- ✅ 实现
IFileStorage接口 - ✅ 支持公开和私有访问模式
- ✅ 集成 CloudFront CDN
- ✅ 生成签名 URL(临时访问)
- ✅ 支持客户端直传 S3
- ✅ 自动组织文件夹结构
async save(file: Buffer, filename: string, mimeType: string, options?: FileStorageOptions): Promise<AttachmentData> {
// 生成唯一 ID
const id = crypto.randomBytes(16).toString('hex');
const ext = filename.substring(filename.lastIndexOf('.'));
// 构建 S3 key(文件路径)
const folder = options?.folder || 'uploads';
const objectPath = options?.object ? `${options.object}/` : '';
const key = `${this.basePrefix}/${folder}/${objectPath}${id}${ext}`;
// 上传到 S3
const command = new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: file,
ContentType: mimeType,
ACL: this.publicRead ? 'public-read' : 'private',
Metadata: {
'original-filename': filename,
'uploaded-by': options?.userId || 'unknown'
}
});
await this.s3Client.send(command);
return {
id: key,
name: `${id}${ext}`,
url: this.getPublicUrl(key),
size: file.length,
type: mimeType,
original_name: filename,
uploaded_at: new Date().toISOString()
};
}async get(fileId: string): Promise<Buffer | null> {
const command = new GetObjectCommand({
Bucket: this.bucket,
Key: fileId
});
const response = await this.s3Client.send(command);
// 将流转换为 Buffer
const chunks: Uint8Array[] = [];
for await (const chunk of response.Body) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}创建 .env 文件:
# AWS 凭证
AWS_ACCESS_KEY_ID=your_access_key_id
AWS_SECRET_ACCESS_KEY=your_secret_access_key
AWS_REGION=us-east-1
# S3 配置
S3_BUCKET=my-objectql-uploads
S3_BASE_PREFIX=objectql-uploads
# CloudFront(可选)
CLOUDFRONT_DOMAIN=https://d123456.cloudfront.net
# 访问控制
S3_PUBLIC_READ=false
S3_SIGNED_URL_EXPIRY=3600interface S3StorageConfig {
bucket: string; // S3 存储桶名称
region: string; // AWS 区域
accessKeyId?: string; // 访问密钥 ID(可选,使用 IAM 角色时)
secretAccessKey?: string; // 访问密钥(可选)
basePrefix?: string; // 文件路径前缀
cloudFrontDomain?: string; // CloudFront 域名
publicRead?: boolean; // 是否公开读取
signedUrlExpiry?: number; // 签名 URL 过期时间(秒)
}import { ObjectQL } from '@objectql/core';
import { createNodeHandler } from '@objectql/server';
import { S3FileStorage } from './s3-storage-implementation';
const app = new ObjectQL({ /* ... */ });
// 配置 S3 存储
const storage = new S3FileStorage({
bucket: process.env.S3_BUCKET!,
region: process.env.AWS_REGION!,
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
basePrefix: process.env.S3_BASE_PREFIX,
cloudFrontDomain: process.env.CLOUDFRONT_DOMAIN,
publicRead: process.env.S3_PUBLIC_READ === 'true'
});
// 创建 HTTP 处理器
const handler = createNodeHandler(app, { fileStorage: storage });
// 启动服务器
const server = http.createServer(handler);
server.listen(3000);在 EC2、ECS 或 Lambda 上运行时,推荐使用 IAM 角色而不是硬编码凭证:
const storage = new S3FileStorage({
bucket: 'my-objectql-uploads',
region: 'us-east-1',
// 不提供 accessKeyId 和 secretAccessKey
// SDK 会自动使用 IAM 角色
publicRead: false
});需要的 IAM 权限:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::my-objectql-uploads/*",
"arn:aws:s3:::my-objectql-uploads"
]
}
]
}使用 CloudFront 加速全球访问:
const storage = new S3FileStorage({
bucket: 'my-objectql-uploads',
region: 'us-east-1',
cloudFrontDomain: 'https://d123456.cloudfront.net',
publicRead: true // CloudFront 需要
});文件 URL 将是:
https://d123456.cloudfront.net/objectql-uploads/expense/abc123.pdf
而不是:
https://my-objectql-uploads.s3.us-east-1.amazonaws.com/objectql-uploads/expense/abc123.pdf
对于私有文件,生成临时访问链接:
const storage = new S3FileStorage({
bucket: 'my-objectql-uploads',
region: 'us-east-1',
publicRead: false, // 私有访问
signedUrlExpiry: 3600 // 1小时过期
});
// 获取签名 URL
const fileId = 'objectql-uploads/expense/abc123.pdf';
const signedUrl = await storage.getSignedUrl(fileId, 7200); // 2小时有效
// 返回给客户端
res.json({ downloadUrl: signedUrl });避免文件经过服务器,直接上传到 S3:
// 服务器端:生成上传凭证
const uploadCredentials = await storage.getSignedUploadUrl(
'receipt.pdf',
'application/pdf',
{ object: 'expense', userId: 'user_123' }
);
// 返回给客户端
res.json(uploadCredentials);
// {
// url: "https://...",
// key: "objectql-uploads/expense/abc123.pdf",
// fields: { "Content-Type": "application/pdf" }
// }// 客户端:直接上传到 S3
const formData = new FormData();
formData.append('key', uploadCredentials.key);
Object.entries(uploadCredentials.fields).forEach(([k, v]) => {
formData.append(k, v);
});
formData.append('file', fileInput.files[0]);
await fetch(uploadCredentials.url, {
method: 'PUT',
body: formData
});
// 然后创建 ObjectQL 记录
await fetch('/api/objectql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
op: 'create',
object: 'expense',
args: {
receipt: {
id: uploadCredentials.key,
url: `https://d123456.cloudfront.net/${uploadCredentials.key}`,
// ... 其他元数据
}
}
})
});S3FileStorage 自动组织文件夹结构:
my-objectql-uploads/
├── objectql-uploads/ # basePrefix
│ ├── uploads/ # 默认文件夹
│ │ ├── expense/ # 按对象类型
│ │ │ ├── abc123.pdf
│ │ │ └── def456.jpg
│ │ └── product/
│ │ ├── img001.jpg
│ │ └── img002.jpg
│ └── avatars/ # 自定义文件夹
│ └── user_123.jpg
配置 S3 生命周期策略自动管理文件:
{
"Rules": [
{
"Id": "ArchiveOldFiles",
"Status": "Enabled",
"Transitions": [
{
"Days": 90,
"StorageClass": "STANDARD_IA"
},
{
"Days": 180,
"StorageClass": "GLACIER"
}
]
},
{
"Id": "DeleteTempFiles",
"Status": "Enabled",
"Filter": {
"Prefix": "objectql-uploads/temp/"
},
"Expiration": {
"Days": 7
}
}
]
}✅ 推荐做法:
// 使用 IAM 角色
const storage = new S3FileStorage({
bucket: 'my-objectql-uploads',
region: 'us-east-1',
publicRead: false // 默认私有
});
// 对需要公开的文件,使用签名 URL
const url = await storage.getSignedUrl(fileId);❌ 避免:
// 不要在代码中硬编码凭证
const storage = new S3FileStorage({
accessKeyId: 'AKIAIOSFODNN7EXAMPLE', // ❌ 危险
secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY' // ❌ 危险
});// 使用 CloudFront CDN
const storage = new S3FileStorage({
bucket: 'my-objectql-uploads',
region: 'us-east-1',
cloudFrontDomain: 'https://d123456.cloudfront.net',
publicRead: true
});
// 配置 CloudFront 缓存策略
// - Cache-Control: max-age=31536000 (1年)
// - 启用 Gzip 压缩
// - 使用边缘位置// 1. 使用智能分层存储
// 在 S3 控制台启用 Intelligent-Tiering
// 2. 定期清理未使用的文件
async function cleanupOrphanedFiles() {
// 查询数据库中的所有文件引用
const usedFiles = await db.query('SELECT file_url FROM attachments');
// 列出 S3 中的所有文件
// 删除未引用的文件
}
// 3. 压缩图片
// 在上传前使用 sharp 库压缩
import sharp from 'sharp';
const compressedBuffer = await sharp(originalBuffer)
.resize(1920, 1080, { fit: 'inside' })
.jpeg({ quality: 85 })
.toBuffer();import { S3Client } from '@aws-sdk/client-s3';
const s3Client = new S3Client({
region: 'us-east-1',
logger: console, // 启用日志
});
// CloudWatch 监控指标
// - NumberOfObjects
// - BucketSizeBytes
// - AllRequests
// - 4xxErrors, 5xxErrors为灾难恢复配置跨区域复制:
{
"Role": "arn:aws:iam::123456789:role/s3-replication-role",
"Rules": [
{
"Status": "Enabled",
"Priority": 1,
"DeleteMarkerReplication": { "Status": "Enabled" },
"Destination": {
"Bucket": "arn:aws:s3:::my-objectql-uploads-backup",
"ReplicationTime": {
"Status": "Enabled",
"Time": { "Minutes": 15 }
}
}
}
]
}1. AccessDenied 错误
Error: Access Denied
解决方案:
- 检查 IAM 权限是否正确
- 确认 S3 bucket 策略允许访问
- 验证 AWS 凭证是否有效
2. NoSuchBucket 错误
Error: The specified bucket does not exist
解决方案:
- 确认 bucket 名称正确
- 检查 region 配置是否匹配
- 在 AWS 控制台创建 bucket
3. 上传缓慢
解决方案:
- 使用客户端直传 S3
- 启用 S3 Transfer Acceleration
- 选择地理位置更近的 region
4. 文件无法访问
解决方案:
- 检查 ACL 设置(public-read vs private)
- 对私有文件使用签名 URL
- 验证 CloudFront 分发配置
通过实现 IFileStorage 接口,ObjectQL 可以无缝集成 AWS S3 存储。关键要点:
✅ 简单集成 - 只需实现 4 个方法
✅ 灵活配置 - 支持公开/私有访问、CDN、签名 URL
✅ 生产就绪 - 内置错误处理、元数据管理
✅ 可扩展 - 支持客户端直传、生命周期管理
完整代码示例:s3-storage-implementation.ts
相关文档: