690 lines
27 KiB
Markdown
690 lines
27 KiB
Markdown
---
|
||
tags: [后端, Go, 对象存储, S3, 文件上传]
|
||
create time: 2026-05-07 19:12
|
||
---
|
||
|
||
# OSS 对象存储
|
||
|
||
## 概述
|
||
|
||
OSS(Object Storage Service)即**对象存储**,是一种将非结构化数据作为对象存储在云端的方案。阿里云的 OSS、AWS 的 S3 以及兼容 S3 协议的 MinIO 本质上是同一种技术——通过 HTTP API 对海量二进制数据进行增删改查。理解其核心概念和工作模式,是构建现代 Web 应用基础设施的关键一环。
|
||
|
||
思考题:和传统的关系型数据库相比,为什么图片、视频、日志文件这类数据不适合存在 MySQL 里?
|
||
|
||
## 正文
|
||
|
||
### 1. 核心概念
|
||
|
||
对象存储有三个基本概念:
|
||
|
||
| 概念 | 说明 | 类比关系型数据库 |
|
||
|------|------|------------------|
|
||
| **Bucket(存储桶)** | 对象的容器,类似"数据库" | Database / Table |
|
||
| **Object(对象)** | 实际存储的数据,包含 Key + Body + Metadata,类似"行记录" | Row |
|
||
| **Key(键)** | 对象的全局唯一标识,类似文件系统路径 | File Path |
|
||
|
||
> [!key] 关键点:扁平结构
|
||
> 对象存储是一个**扁平结构**——没有真正的目录层级,所谓的 `folder/` 只是 Key 中的一部分字符(如 `photos/2026/vacation.jpg`)。管理面板按 `/` 做可视化分组,但这仅是展示层面的伪层级。
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
subgraph OSS_BUCKET["Bucket: my-app-data"]
|
||
direction TB
|
||
O1["Key: uploads/avatar/001.png Body: PNG binary data"]
|
||
O2["Key: docs/report.pdf Body: PDF binary data"]
|
||
O3["Key: backups/db-dump.sql.gz Body: SQL compressed"]
|
||
end
|
||
|
||
classDef bucketStyle fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
|
||
class OSS_BUCKET bucketStyle
|
||
```
|
||
|
||
**Region 和 Endpoint:**
|
||
|
||
每个 Bucket 必须属于一个地域(Region),Region 决定了数据的物理存储位置。访问时需要使用对应的 Endpoint:
|
||
|
||
| 地域 | 内网 Endpoint | 外网 Endpoint |
|
||
|------|---------------|---------------|
|
||
| 杭州 (oss-cn-hangzhou) | `oss-cn-hangzhou-internal.aliyuncs.com` | `oss-cn-hangzhou.aliyuncs.com` |
|
||
| 上海 (oss-cn-shanghai) | `oss-cn-shanghai-internal.aliyuncs.com` | `oss-cn-shanghai.aliyuncs.com` |
|
||
| 北京 (oss-cn-beijing) | `oss-cn-beijing-internal.aliyuncs.com` | `oss-cn-beijing.aliyuncs.com` |
|
||
|
||
> [!tip] 省钱提速第一步
|
||
> 当你的 Go 服务和 OSS Bucket **在同一 Region** 时,务必使用**内网 Endpoint**。内网流量免费且速度比外网快 5~10 倍。这是最容易被忽略的基础优化。
|
||
|
||
### 2. Go SDK 选型
|
||
|
||
目前 Go 生态中有三套主流 OSS/S3 SDK,选择哪一套取决于你的目标服务:
|
||
|
||
```mermaid
|
||
graph TD
|
||
A["Go 对象存储 SDK"] --> B["Aliyun OSS SDK alibabacloud-go/oss-go-sdk-v2"]
|
||
A --> C["Minio Client SDK minio/minio-go"]
|
||
A --> D["AWS SDK for Go v2 aws/aws-sdk-go-v2/s3"]
|
||
|
||
B --> B1["适用于阿里云 OSS"]
|
||
B --> B2["API 完全覆盖 OSS 功能"]
|
||
B --> B3["绑定阿里云鉴权体系"]
|
||
|
||
C --> C1["兼容 S3/OSS/COS/GCS 等"]
|
||
C --> C2["API 接近标准库 io.Reader"]
|
||
C --> C3["推荐用于多云混合云场景"]
|
||
|
||
D --> D1["AWS 官方 SDK"]
|
||
D --> D2["功能最全面"]
|
||
D --> D3["体积大 编译慢"]
|
||
|
||
style C1 fill:#e8f5e9,stroke:#2e7d32
|
||
style B1 fill:#fff3e0,stroke:#f57c00
|
||
style D1 fill:#e3f2fd,stroke:#1565c0
|
||
```
|
||
|
||
| 对比项 | Aliyun OSS SDK | Minio Client | AWS S3 SDK v2 |
|
||
|--------|---------------|-------------|---------------|
|
||
| 适配服务 | 仅阿里云 OSS | S3/OSS/COS/GCS等 | AWS S3 / Cloudflare R2 |
|
||
| 安装大小 | ~5MB | ~2MB | ~150MB |
|
||
| API 风格 | RESTful 包装 | `io.Reader` 流式 | 深度异步链式 |
|
||
| 预签名 URL | ✅ 原生支持 | ✅ `PresignedGetObject` | ✅ `PresignObjectAPI` |
|
||
| 推荐度 | 只用阿里云 | **多云通用首选** | AWS 重度用户 |
|
||
|
||
> [!summary] 选型结论
|
||
> 如果你只做阿里云,用官方 SDK;如果需要同时对接 S3、MinIO 或其他兼容 S3 的服务,选择 Minio Client SDK。
|
||
|
||
### 3. 初始化客户端
|
||
|
||
**方式一:阿里云官方 SDK(v2)**
|
||
|
||
```go
|
||
import (
|
||
oss "github.com/alibabacloud-go/oss-20190517/v2/client"
|
||
)
|
||
|
||
// 从环境变量读取凭证,避免硬编码
|
||
client := oss.NewClient(&oss.Config{
|
||
Endpoint: oss.String("oss-cn-hangzhou.aliyuncs.com"),
|
||
AccessKeyId: oss.String(os.Getenv("OSS_ACCESS_KEY_ID")),
|
||
AccessKeySecret: oss.String(os.Getenv("OSS_ACCESS_KEY_SECRET")),
|
||
})
|
||
```
|
||
|
||
**方式二:Minio Client(更简洁,跨云兼容)— `github.com/minio/minio-go/v7`**
|
||
|
||
```go
|
||
import (
|
||
"github.com/minio/minio-go/v7"
|
||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||
)
|
||
|
||
client, err := minio.New("oss-cn-hangzhou.aliyuncs.com", &minio.Options{
|
||
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
|
||
Secure: true, // HTTPS
|
||
})
|
||
if err != nil {
|
||
log.Fatal(err)
|
||
}
|
||
```
|
||
|
||
> [!danger] 安全红线
|
||
> 永远不要将 AccessKey 写在代码里。生产环境应使用环境变量、密钥管理服务(KMS / Vault / 云厂商 IAM Role)注入。AK/SK 泄露 = 别人可以读写你的数据甚至产生账单。
|
||
|
||
### 4. Bucket 操作
|
||
|
||
#### 创建与查询
|
||
|
||
```go
|
||
// 创建 Bucket(Minio 写法)
|
||
bucketName := "my-app-uploads"
|
||
location := "cn-hangzhou"
|
||
|
||
err := client.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{
|
||
Region: location,
|
||
})
|
||
// 如果已存在则 ErrBucketExists 忽略即可
|
||
|
||
// 检查 Bucket 是否存在
|
||
exists, _ := client.BucketExists(ctx, bucketName)
|
||
```
|
||
|
||
#### 权限模型
|
||
|
||
| ACL | 说明 | 适用场景 |
|
||
|-----|------|---------|
|
||
| `Private`(默认) | 只有 Owner 可读写 | 用户上传的文件、备份 |
|
||
| `PublicRead` | Owner 读写 + 所有人可读 | CDN 加速的图片资源 |
|
||
| `PublicReadWrite` | 所有人可读写 | **极不推荐** — 任何人都可以上传和删除文件,可能被恶意刷费 |
|
||
|
||
### 5. 文件上传:三种架构对比
|
||
|
||
前端要上传图片/文件到 OSS,主要有以下三种架构方案。选择合适的方案直接影响系统的**可扩展性**、**安全性**和**开发成本**。
|
||
|
||
#### 方案 A:服务端中转上传(Server Relay)
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant FE as 浏览器 / 客户端
|
||
participant GO as Gin Server
|
||
participant OSS as OSS 服务
|
||
|
||
FE->>GO: POST 文件 multipart/form-data
|
||
Note over GO: 解析请求体 io.Reader 流式
|
||
GO->>OSS: PUT 上传 Stream 转发
|
||
OSS-->>GO: 200 OK ETag Location
|
||
GO-->>FE: 200 OK 返回 OSS 访问地址
|
||
```
|
||
|
||
**工作流程:**
|
||
1. 客户端将文件 POST 到 Go 服务端(multipart/form-data)
|
||
2. Go 服务端接收后,通过 SDK 将文件写入 OSS
|
||
3. 返回文件的 OSS 访问地址给客户端
|
||
|
||
```go
|
||
func uploadWithRelay(c *gin.Context) {
|
||
// 限制文件大小(防内存溢出 + 防恶意大文件)
|
||
c.Request.ParseMultipartForm(32 << 20) // 最大 32MB
|
||
|
||
file, header, err := c.Request.FormFile("file")
|
||
if err != nil {
|
||
c.JSON(400, gin.H{"error": "invalid file"})
|
||
return
|
||
}
|
||
defer file.Close()
|
||
|
||
// 校验文件类型
|
||
buf := make([]byte, 512)
|
||
file.Read(buf)
|
||
contentType := http.DetectContentType(buf)
|
||
allowed := map[string]bool{
|
||
"image/jpeg": true, "image/png": true, "image/webp": true,
|
||
}
|
||
if !allowed[contentType] {
|
||
c.JSON(400, gin.H{"error": "unsupported file type"})
|
||
return
|
||
}
|
||
// rewind for reading again
|
||
file.Seek(0, io.SeekStart)
|
||
|
||
// 重命名防止冲突(UUID_原文件名)
|
||
fileName := uuid.New().String() + "_" + header.Filename
|
||
|
||
_, err = client.PutObject(c.Request.Context(), bucketName, fileName, file, -1, minio.PutObjectOptions{
|
||
ContentType: contentType,
|
||
})
|
||
if err != nil {
|
||
c.JSON(500, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
// 记录元信息到数据库
|
||
db.Create(&FileRecord{Key: fileName, Size: header.Size, Type: contentType})
|
||
|
||
c.JSON(200, gin.H{
|
||
"url": fmt.Sprintf("https://%s.%s/%s", bucketName, endpoint, fileName),
|
||
"size": header.Size,
|
||
})
|
||
}
|
||
```
|
||
|
||
这段代码演示了服务端中转上传的完整流程:首先通过 `ParseMultipartForm` 限制文件体积防止内存溢出;接着用 `http.DetectContentType` 读取文件头 512 字节进行类型校验,避免恶意文件伪装上传;然后使用 UUID 重命名解决命名冲突和路径遍历风险;最后将对象写入 OSS 并将元信息持久化到数据库。需要注意的是,客户端文件先被读入 Go 进程的内存缓冲区(`file.Seek(0, io.SeekStart)` rewind),再通过 SDK Stream 转发到 OSS——这就是为什么该方案在并发量大时会成为瓶颈。
|
||
|
||
#### 方案 B:预签名 URL 直传(Pre-Signed URL)
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant FE as 浏览器 / 客户端
|
||
participant GO as Gin Server
|
||
participant OSS as OSS 服务
|
||
|
||
FE->>GO: GET /upload/url filename photo.jpg
|
||
Note over GO: 验证登录态和权限
|
||
GO->>OSS: PresignedPutObject photo.jpg 15min
|
||
OSS-->>GO: 预签名URL含签名和过期参数
|
||
GO-->>FE: 返回URL和文件元信息
|
||
|
||
FE->>OSS: PUT 直接上传 Content-Type image/jpeg 文件二进制数据
|
||
OSS-->>FE: 200 OK
|
||
|
||
opt 通知服务端完成
|
||
FE->>GO: POST /upload/callback 文件名和元信息
|
||
Note over GO: 验证对象存在于 OSS
|
||
GO-->>FE: 200 OK 记录元信息到 DB
|
||
end
|
||
```
|
||
|
||
**工作流程:**
|
||
1. 客户端请求服务端获取预签名 URL
|
||
2. 服务端调用 OSS 的 `PresignedPutObject` 生成有时效性的上传链接
|
||
3. 客户端拿到 URL 后**直接 PUT 到 OSS**,不经服务端
|
||
4. 上传完成后回调服务端,记录元信息到数据库
|
||
|
||
> [!info] 详细说明
|
||
> 预签名 URL 的安全要点、完整代码实现及回调验证逻辑详见 [§7](hhs/DEV/OSS/OSS.md)。
|
||
|
||
#### 方案 C:STS 临时凭证直传(STS Credentials)
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant FE as 浏览器 / 客户端
|
||
participant GO as Gin Server
|
||
participant STS as STS 服务
|
||
participant OSS as OSS 服务
|
||
|
||
FE->>GO: 请求上传权限
|
||
Note over GO: 验证用户身份
|
||
GO->>STS: AssumeRole 角色Arn加SessionName
|
||
STS-->>GO: AccessKeyId加AccessKeySecret加SecurityToken有效期15分钟
|
||
GO-->>FE: 返回临时凭证
|
||
|
||
FE->>OSS: 用临时凭证直接上传 PostObject API
|
||
OSS-->>FE: 200 OK
|
||
```
|
||
|
||
**工作流程:**
|
||
1. 客户端请求服务端获取上传权限
|
||
2. 服务端调用 STS(Security Token Service)AssumeRole 获取临时凭据
|
||
3. 将临时凭据(AccessKeyId + Secret + Token)发给客户端
|
||
4. 客户端直接使用这些凭据通过 OSS PostObject API 上传
|
||
|
||
```go
|
||
import "github.com/aliyun/aliyun-sts-go-sdk"
|
||
|
||
func getSTSCredentials(c *gin.Context) {
|
||
stsClient := stssdk.NewClientWithAccessKey("cn-hangzhou", masterAK, masterSK)
|
||
request := stssdk.CreateAssumeRoleRequest()
|
||
request.RoleArn = "acs:ram::<YOUR_ACCOUNT_ID>:role/oss-upload-role"
|
||
request.RoleSessionName = "web-session-" + userID
|
||
request.DurationSeconds = 900 // 15 分钟
|
||
request.Policy = `{
|
||
"Statement": [{
|
||
"Action": ["oss:PutObject", "oss:PostObject"],
|
||
"Effect": "Allow",
|
||
"Resource": ["acs:oss:*:*:my-bucket/uploads/*"]
|
||
}]
|
||
}`
|
||
|
||
response, err := stsClient.AssumeRole(request)
|
||
if err != nil {
|
||
c.JSON(500, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
c.JSON(200, gin.H{
|
||
"accessKeyId": response.Credentials.AccessKeyId,
|
||
"accessKeySecret": response.Credentials.AccessKeySecret,
|
||
"securityToken": response.Credentials.SecurityToken,
|
||
"expiration": response.Credentials.Expiration,
|
||
})
|
||
}
|
||
```
|
||
|
||
> [!tip] 关键区别
|
||
> 预签名 URL 是**一次性**的、针对特定文件的上传链接;STS 临时凭证是一组**通用**的上传凭据,可以在有效期内用于上传任意文件(受 Policy 约束)。
|
||
|
||
#### 三种方案对比
|
||
|
||
| 维度 | 方案 A:服务端中转 | 方案 B:预签名 URL 直传 | 方案 C:STS 临时凭证 |
|
||
|------|-------------------|------------------------|--------------------|
|
||
| **流量走向** | 客户端 → 服务端 → OSS | 客户端 → OSS | 客户端 → OSS |
|
||
| **服务端带宽消耗** | N × 文件大小 ⚠️ | 零带宽 ✅ | 零带宽 ✅ |
|
||
| **水平扩展性** | 差(服务端成为瓶颈) | 优秀 | 优秀 |
|
||
| **延迟** | 往返两次(双 RTT) | 一次直传(单 RTT) | 一次直传(单 RTT) |
|
||
| **控制粒度** | 粗(整个文件过服务端) | 中(URL 级控制) | 细(Policy 级可控) |
|
||
| **实现复杂度** | 低 ✅ | 中 ⚡ | 高 ❌ |
|
||
| **安全性** | 高(服务端全拦截) | 中高(签名+过期) | 中(需配置好 Policy) |
|
||
| **适合场景** | 小文件 (<1MB)、需要服务端二次处理 | 普通上传(头像、截图等) | 大文件、批量上传、企业级应用 |
|
||
|
||
#### 如何选型?
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
A["用户上传文件"] --> B{"文件大小?"}
|
||
B -- "<= 1MB" --> C{"是否需要服务端内容分析转码?"}
|
||
B -- "> 1MB 或不确定" --> D{"预期并发量?"}
|
||
|
||
C -- "否" --> E["方案 B: 预签名 URL 最简单加零带宽开销"]
|
||
C -- "是" --> F["方案 A: 服务端中转 可控但限并发"]
|
||
|
||
D -- "QPS < 100" --> G["方案 B: 预签名 URL"]
|
||
D -- "QPS >= 100 或大批量大文件" --> H["方案 C: STS 临时凭证 灵活且可扩展"]
|
||
```
|
||
|
||
> [!success] 经验法则
|
||
> 大多数 Web 应用选**方案 B(预签名 URL)**就足够了。只有在需要对文件内容进行服务端分析(如病毒扫描、图像压缩、OCR),或者并发量极大时才考虑方案 A 或 C。
|
||
|
||
---
|
||
|
||
*本节各方案代码已包含完整实现要点;更多细节可参考:[对象基本操作](#6-对象基本操作)、[预签名 URL 详解](hhs/DEV/OSS/OSS.md) 、[并发与安全注意事项](#9-并发与安全注意事项)。*
|
||
|
||
### 6. 对象基本操作
|
||
|
||
#### 上传(服务端直传示例)
|
||
|
||
```go
|
||
// 小文件:< 100MB 时直接用 FPutObject
|
||
err := client.FPutObject(ctx, bucketName, "uploads/photo.jpg", "./photo.jpg", minio.PutObjectOptions{
|
||
ContentType: "image/jpeg",
|
||
})
|
||
|
||
// 大文件:自动分片上传(Minio SDK 内部处理)
|
||
file, _ := os.Open("large-video.mp4")
|
||
_, err = client.PutObject(ctx, bucketName, "videos/large.mp4", file, -1, minio.PutObjectOptions{
|
||
PartSize: 10 << 20, // 每片 10MB
|
||
ContentType: "video/mp4",
|
||
})
|
||
```
|
||
|
||
> [!info] 分片阈值
|
||
> Minio SDK 默认当文件超过 **5MB** 时自动启动分片上传,最大支持单文件 **50TB**。无需手动实现。
|
||
|
||
#### 下载
|
||
|
||
```go
|
||
// 完整下载 → io.Reader
|
||
obj, err := client.GetObject(ctx, bucketName, "uploads/photo.jpg", minio.GetObjectOptions{})
|
||
defer obj.Close()
|
||
|
||
data, _ := io.ReadAll(obj)
|
||
_ = data // 写入数据库或直接返回给前端
|
||
|
||
// 范围下载(只读文件的某个区间)
|
||
opts := minio.GetObjectOptions{}
|
||
opts.Set("Range", "bytes=0-1023") // 只读前 1KB
|
||
```
|
||
|
||
这里演示了两种下载模式:`GetObject` 返回的是 `io.ReadCloser`,可以配合 `io.ReadAll` 一次性加载(适合小文件),也可以通过 HTTP `Range` 头实现**断点续传/视频拖拽**——OSS 只返回请求的字节区间,大幅节省带宽。
|
||
|
||
#### 删除
|
||
|
||
```go
|
||
// 删除单个对象
|
||
client.RemoveObject(ctx, bucketName, "uploads/old-photo.jpg", minio.RemoveObjectOptions{})
|
||
|
||
// 批量删除
|
||
objsCh := make(chan minio.ObjectInfo, 100)
|
||
for info := range objsCh {
|
||
client.RemoveObject(ctx, bucketName, info.Key, minio.RemoveObjectOptions{})
|
||
}
|
||
close(objsCh)
|
||
client.RemoveObjects(ctx, bucketName, objsCh, minio.RemoveObjectsOptions{})
|
||
```
|
||
|
||
`RemoveObject` 是同步调用,适合单文件删除。批量删除使用 `RemoveObjects` API——它接收一个 object channel,SDK 内部**并发执行删除请求**(默认并发数 10),比逐个循环调 `RemoveObject` 快数个量级。注意 channel 必须先 `close()` 再调用 `RemoveObjects`,否则会阻塞。
|
||
|
||
#### 列出对象
|
||
|
||
```go
|
||
// 列出以 "uploads/" 开头的对象(模拟目录效果)
|
||
objects := client.ListObjects(ctx, bucketName, minio.ListObjectsOptions{
|
||
Prefix: "uploads/", // 前缀过滤
|
||
Recursive: true, // false 则只在虚拟"目录"层面
|
||
})
|
||
|
||
for obj := range objects {
|
||
fmt.Printf("%-40s %8d bytes %s\n", obj.Key, obj.Size, obj.LastModified)
|
||
}
|
||
```
|
||
|
||
`ListObjects` 返回的是一个 channel,可以**边取边处理**而无需一次性加载全部结果——对于百万级文件的 Bucket,这避免了将全量元数据拉入内存。当 `Recursive=false` 时配合 `Delimiter("/")` 可模拟"目录树"分页效果,类似 AWS S3 的"共同前缀"语义。
|
||
|
||
### 7. 预签名 URL — 免服务端中转
|
||
|
||
**核心原理:** 预签名 URL 的本质是服务端用 AK/SK 对 HTTP 请求做一次 HMAC 签名,把签名结果以查询参数的形式附加到 URL 上。OSS 收到请求后,用同样的算法重算签名并比对——一致则放行,否则拒绝。整个过程不需要服务端暴露任何凭证。
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
subgraph SERVER["服务端 (持有永久 AK/SK)"]
|
||
S1["用户身份验证"] --> S2["调用 OSS API PresignedPutObject"]
|
||
S2 --> S3["HMAC-SHA256 签名计算"]
|
||
S3 --> S4["返回含签名参数的 URL"]
|
||
end
|
||
|
||
subgraph CLIENT["浏览器 (无凭证)"]
|
||
C1["收到预签名 URL"] --> C2["PUT 请求直达 OSS ?Signature=xxx&Expires=yyy"]
|
||
C2 --> C3["文件直传到 OSS"]
|
||
end
|
||
|
||
subgraph OSS["对象存储"]
|
||
O1["重算签名比对"] --> O2{"一致?"}
|
||
O2 -- "是" --> O3["接受上传 ✅"]
|
||
O2 -- "否" --> O4["拒绝 ❌ 403"]
|
||
C3 --> O1
|
||
end
|
||
|
||
S4 --> C1
|
||
style SERVER fill:#fff3e0,stroke:#f57c00
|
||
style CLIENT fill:#e3f2fd,stroke:#1565c0
|
||
style OSS fill:#e8f5e9,stroke:#2e7d32
|
||
```
|
||
|
||
**三个实用技巧:**
|
||
|
||
1. **限制 Content-Type 伪装**:`PresignedPutObject` 本身不约束 `Content-Type`,攻击者可能上传 `.php` 恶意脚本。解决方法是在 Callback 校验中加上类型白名单,或者使用 Object Lock(如果云厂商支持)。
|
||
|
||
2. **自动命名优于前端传文件名**:不要直接用前端传来的文件名生成 Key,容易路径遍历(`../../../etc/passwd`)。服务端统一用 UUID 命名,前端只负责传一个友好的展示名称:
|
||
|
||
```go
|
||
// 好:服务端控制实际存储路径
|
||
func getPresignedURL(c *gin.Context) {
|
||
displayName := c.Query("displayName")
|
||
key := uuid.New().String() + filepath.Ext(displayName)
|
||
// ... 生成预签名 URL
|
||
}
|
||
```
|
||
|
||
3. **过期时间按需分配**:小文件(头像、截图)给 5 分钟足够了;大视频或批量导出可以给 30 分钟~2 小时。过短 → 用户没传完就过期,过长 → 泄露后的风险窗口变大。
|
||
|
||
**代码示例:**
|
||
|
||
```go
|
||
import (
|
||
"context"
|
||
"path/filepath"
|
||
"time"
|
||
)
|
||
|
||
func getPresignedUploadURL(c *gin.Context) {
|
||
filename := c.Query("filename")
|
||
contentType := c.DefaultQuery("contentType", "application/octet-stream")
|
||
|
||
// 生成一个有效期 15 分钟的 PUT 预签名 URL
|
||
// 注意:PresignedPutObject 不允许设置 Content-Type 约束
|
||
// 如需严格校验,应在 Callback 中二次确认
|
||
url, err := client.PresignedPutObject(
|
||
context.Background(),
|
||
bucketName,
|
||
filepath.Join("uploads", filename),
|
||
15*time.Minute,
|
||
)
|
||
if err != nil {
|
||
c.JSON(500, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
c.JSON(200, gin.H{
|
||
"uploadURL": url.String(),
|
||
"fileName": filepath.Join("uploads", filename),
|
||
"contentType": contentType,
|
||
"expiresIn": 900, // 秒
|
||
})
|
||
}
|
||
```
|
||
|
||
**POST 端回调验证(防越权上传):**
|
||
|
||
```go
|
||
func uploadCallback(c *gin.Context) {
|
||
var req struct {
|
||
FileName string `json:"fileName" binding:"required"`
|
||
FileSize int64 `json:"fileSize" binding:"required"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(400, gin.H{"error": "invalid params"})
|
||
return
|
||
}
|
||
|
||
key := filepath.Join("uploads", req.FileName)
|
||
|
||
// 检查对象是否真的存在于 OSS 中
|
||
_, err := client.StatObject(context.Background(), bucketName, key, minio.StatObjectOptions{})
|
||
if err != nil {
|
||
c.JSON(404, gin.H{"error": "file not found in OSS"})
|
||
return
|
||
}
|
||
|
||
// 保存元信息到数据库
|
||
db.Create(&FileRecord{Key: key, Size: req.FileSize})
|
||
c.JSON(200, gin.H{"status": "recorded"})
|
||
}
|
||
```
|
||
|
||
### 8. 生命周期管理(Lifecycle)
|
||
|
||
频繁删除和上传会产生大量碎片化的旧版本,合理配置 Lifecycle 可以节省成本并避免磁盘爆炸:
|
||
|
||
```go
|
||
import "github.com/minio/minio-go/v7/pkg/lifecycle"
|
||
|
||
err := client.SetBucketLifecycle(ctx, bucketName, &lifecycle.Configuration{
|
||
Rules: []lifecycle.Rule{
|
||
{
|
||
ID: "expire-old-uploads",
|
||
Status: lifecycle.RuleEnabled,
|
||
Prefix: "uploads/",
|
||
Expiration: lifecycle.Expiration{Days: 90}, // 90天后自动删除
|
||
},
|
||
{
|
||
ID: "transition-rare-access",
|
||
Status: lifecycle.RuleEnabled,
|
||
Prefix: "backups/",
|
||
Transition: lifecycle.Transition{
|
||
Days: 30,
|
||
StorageClass: "GLACIER", // 转归档存储(S3 兼容写法)
|
||
},
|
||
},
|
||
},
|
||
})
|
||
```
|
||
|
||
这里定义了两条规则:**第一条**清理 `uploads/` 目录下超过 90 天的旧文件,防止用户缓存无限膨胀;**第二条**将 `backups/` 下超过 30 天的备份数据从标准存储自动迁移到更便宜的 Glacier/归档存储。两种动作都按 Key 前缀(Prefix)作用——相当于对虚拟目录设置独立策略。注意阿里云 SDK 对应的是 `SetBucketLifecycleRule` API,参数名略有不同但语义一致。
|
||
|
||
**存储类型与成本对比(阿里云为例):**
|
||
|
||
| 存储类型 | 单价 (元/GB/月) | 取回费用 | 最低存储时长 | 适用场景 |
|
||
|---------|---------------|---------|------------|---------|
|
||
| 标准存储(Standard) | ~0.12 | 无 | 无 | 活跃数据、频繁访问 |
|
||
| 低频访问(IA) | ~0.084 | 有 | 30天 | 很少读取但需即时访问 |
|
||
| 归档存储 | ~0.042 | 有 + 恢复时间 | 60天 | 合规存档、历史备份 |
|
||
| 冷归档 | ~0.021 | 有 + 数小时恢复 | 180天 | 极低频、合规留存 |
|
||
|
||
> [!tip] 策略建议
|
||
> 新创建的 Bucket 使用标准存储即可。在业务逻辑中标记哪些文件属于"冷数据",然后通过 Lifecycle 规则自动降级。不要人为在每个上传接口中判断冷热。
|
||
|
||
### 9. 并发与安全注意事项
|
||
|
||
**常见陷阱和解决方案:**
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
subgraph TRAPS["常见陷阱"]
|
||
T1["AccessKey泄露 导致账单爆炸"]
|
||
T2["未限流 OSS请求被拒绝"]
|
||
T3["命名冲突 同名文件覆盖"]
|
||
T4["内存溢出 全量读入内存"]
|
||
T5["内网跨地域 用了公网Endpoint"]
|
||
end
|
||
|
||
subgraph FIXES["解决方案"]
|
||
F1["使用RAM子账号加STS临时凭证15min过期"]
|
||
F2["客户端侧限速加重试退避指数退避算法"]
|
||
F3["使用UUID或NanoID重命名fileUUID下划线扩展名"]
|
||
F4["始终用io.Reader流式处理避免ReadAll读大文件"]
|
||
F5["确保ECS与Bucket同Region使用内网internal endpoint"]
|
||
end
|
||
|
||
T1 --> F1
|
||
T2 --> F2
|
||
T3 --> F3
|
||
T4 --> F4
|
||
T5 --> F5
|
||
|
||
style T1 fill:#ffebee,stroke:#c62828
|
||
style T2 fill:#ffebee,stroke:#c62828
|
||
style T3 fill:#fff3e0,stroke:#f57c00
|
||
style T4 fill:#ffebee,stroke:#c62828
|
||
style T5 fill:#fff3e0,stroke:#f57c00
|
||
|
||
style F1 fill:#e8f5e9,stroke:#2e7d32
|
||
style F2 fill:#e8f5e9,stroke:#2e7d32
|
||
style F3 fill:#e8f5e9,stroke:#2e7d32
|
||
style F4 fill:#e8f5e9,stroke:#2e7d32
|
||
style F5 fill:#e8f5e9,stroke:#2e7d32
|
||
```
|
||
|
||
**正确使用流式下载避免 OOM:**
|
||
|
||
```go
|
||
// ❌ 错误做法:大文件会把整个内容加载到内存
|
||
obj, _ := client.GetObject(ctx, bucketName, "large-backup.sql.gz", minio.GetObjectOptions{})
|
||
data, _ := io.ReadAll(obj) // 10GB 文件 → 10GB 内存占用 = 必然 OOM
|
||
|
||
// ✅ 正确做法:边读边写,零拷贝
|
||
obj, _ := client.GetObject(ctx, bucketName, "large-backup.sql.gz", minio.GetObjectOptions{})
|
||
defer obj.Close()
|
||
|
||
dst, _ := os.Create("./backup-latest.sql.gz")
|
||
defer dst.Close()
|
||
|
||
_, err := io.Copy(dst, obj) // 内存占用恒定(~64KB 内部缓冲区)
|
||
```
|
||
|
||
这段代码对比了两种下载方式的核心区别。`io.ReadAll` 会将数据一次性全部读入切片,对于大文件会导致内存爆炸;而 `io.Copy` 使用固定大小的缓冲区(默认 32KB ~ 64KB),无论源文件大小如何,内存增长始终为常量级别——这就是**流式处理**的精髓。
|
||
|
||
**STS 调用方式对比:**
|
||
|
||
| 使用场景 | 所在章节 | 返回形式 | 典型消费者 |
|
||
|---------|---------|---------|-----------|
|
||
| HTTP 端点直传 | [§5.C](hhs/DEV/OSS/OSS.md) | `gin.H` JSON | 浏览器前端 |
|
||
| 内部服务间传递 | 下方 `GetTempCreds()` | `*sts.Credentials` 结构体 | 其他 Go 微服务 |
|
||
|
||
两种方式的 **AssumeRole 调用链路完全相同**(见下),区别仅在封装层级:HTTP handler 多了一层入参解析和响应序列化。生产环境中通常将其抽象为一个内部 `GetTempCreds(roleArn string, userID string)` 函数,由 HTTP handler 或其他服务统一调用,避免重复实现。
|
||
|
||
```go
|
||
import "github.com/aliyun/aliyun-sts-go-sdk"
|
||
|
||
// GetTempCreds 内部服务调用的核心函数(§5.C 的 HTTP handler 底层即调用此函数)
|
||
func GetTempCreds(roleArn, sessionName string) (*sts.Credentials, error) {
|
||
client := stssdk.NewClientWithAccessKey("cn-hangzhou", ak, sk)
|
||
request := stssdk.CreateAssumeRoleRequest()
|
||
request.RoleArn = roleArn
|
||
request.RoleSessionName = sessionName
|
||
request.DurationSeconds = 900 // 15 分钟
|
||
|
||
response, err := client.AssumeRole(request)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &response.Credentials, nil
|
||
}
|
||
```
|
||
|
||
> [!abstract] 教学提示:STS 的核心思路是"最小权限原则"——前端只获得一个有时效性的短期令牌,即使被拦截也只能在 15 分钟内使用,且只能用于指定的 Bucket 和路径。
|
||
|
||
### 10. 部署检查清单
|
||
|
||
新建 OSS 集成项目时,逐项确认:
|
||
|
||
- [ ] 创建独立 RAM 子账号(不要直接用主账号 AK)
|
||
- [ ] 最小权限授权:只允许目标 Bucket 的操作
|
||
- [ ] 服务端和内网都使用 `-internal` Endpoint
|
||
- [ ] 文件大小限制前置到中间件层(参考 `MaxMultipartMemory`)
|
||
- [ ] 文件名使用 UUID 重命名,防止冲突和路径遍历
|
||
- [ ] 生成预签名 URL 时设置合理的过期时间
|
||
- [ ] 上传成功后通过 callback 或事件通知更新数据库元信息
|
||
- [ ] 配置 Lifecycle 自动清理过期文件
|
||
- [ ] 大文件传输加进度条和断点续传支持(客户端侧)
|
||
|
||
## 关联笔记
|
||
|
||
- [[GIN/8-file-upload]] — 文件上传后如何存入 OSS,以及 MaxMultipartMemory 的内存优化
|
||
- [[GIN/11-static-files]] — 静态文件服务架构中 OSS/S3 + CDN 的集成模式
|
||
- [[部署与运维基础]] — 容器环境中磁盘、网络和密钥管理的注意事项
|