vault backup: 2026-05-18 21:09:55
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,667 @@
|
||||
---
|
||||
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<br/>Body: (PNG binary data)"]
|
||||
O2["Key: docs/report.pdf<br/>Body: (PDF binary data)"]
|
||||
O3["Key: backups/db-dump.sql.gz<br/>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<br/>alibabacloud-oss-go-sdk-v2"]
|
||||
A --> C["Minio Client SDK<br/>minio/go-minio"]
|
||||
A --> D["AWS SDK for Go v2<br/>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(更简洁,跨云兼容)**
|
||||
|
||||
```go
|
||||
import "github.com/minio/minio-go/v7"
|
||||
|
||||
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 直接上传<br/>Content-Type: image/jpeg<br/>文件二进制数据
|
||||
OSS-->>FE: 200 OK
|
||||
|
||||
opt 通知服务端完成
|
||||
FE->>GO: POST /upload/callback<br/>文件名和元信息
|
||||
Note over GO: 验证对象存在于 OSS
|
||||
GO-->>FE: 200 OK (记录元信息到 DB)
|
||||
end
|
||||
```
|
||||
|
||||
**工作流程:**
|
||||
1. 客户端请求服务端获取预签名 URL
|
||||
2. 服务端调用 OSS 的 `PresignedPutObject` 生成有时效性的上传链接
|
||||
3. 客户端拿到 URL 后**直接 PUT 到 OSS**,不经服务端
|
||||
4. 上传完成后回调服务端,记录元信息到数据库
|
||||
|
||||
> [!info] 详细说明
|
||||
> 预签名 URL 的安全要点、完整代码实现及回调验证逻辑详见 [第 7 节](#7-预签名-url-免服务端中转)。
|
||||
|
||||
#### 方案 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<br/>有效期15分钟
|
||||
GO-->>FE: (返回临时凭证)
|
||||
|
||||
FE->>OSS: 用临时凭证直接上传<br/>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{"是否需要服务端<br/>内容分析/转码?"}
|
||||
B -- "> 1MB 或不确定" --> D{"预期并发量?"}
|
||||
|
||||
C -- "否" --> E["✅ 方案 B: 预签名 URL<br/>最简单+零带宽开销"]
|
||||
C -- "是" --> F["✅ 方案 A: 服务端中转<br/>可控但限并发"]
|
||||
|
||||
D -- "QPS < 100" --> G["✅ 方案 B: 预签名 URL"]
|
||||
D -- "QPS >= 100 或<br/>大批量/大文件" --> H["✅ 方案 C: STS 临时凭证<br/>灵活且可扩展"]
|
||||
```
|
||||
|
||||
> [!success] 经验法则
|
||||
> 大多数 Web 应用选**方案 B(预签名 URL)**就足够了。只有在需要对文件内容进行服务端分析(如病毒扫描、图像压缩、OCR),或者并发量极大时才考虑方案 A 或 C。
|
||||
|
||||
---
|
||||
|
||||
*本节各方案代码已包含完整实现要点;更多细节可参考:[对象基本操作](#6-对象基本操作)、[预签名 URL 详解](#7-预签名-url-免服务端中转)、[并发与安全注意事项](#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 — 免服务端中转
|
||||
|
||||
**这是对象存储最常用的高级模式之一。**
|
||||
|
||||
> **核心问题**:用户上传图片时,如果不走对象存储直传,而是先 POST 到 Go 服务端再由服务端转发到 OSS——当 1000 个用户同时上传 5MB 图片时,Go 进程需要消耗多少带宽?
|
||||
|
||||
答案很简单:**5MB × 1000 = 5GB 内存 + 带宽压力**。这就是为什么应该让客户端直接跟 OSS 对话。
|
||||
|
||||
**预签名 URL 流程:**
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant FE as 浏览器 / 客户端
|
||||
participant GO as Gin Server
|
||||
participant OSS as OSS 服务
|
||||
|
||||
FE->>GO: GET /upload/url<br/>?filename=photo.jpg
|
||||
Note over GO: 验证用户登录态和权限
|
||||
GO->>OSS: PresignedGetObject(photo.jpg, 15min)
|
||||
OSS-->>GO: (预签名URL)<br/>含签名和过期参数
|
||||
GO-->>FE: (返回URL和类型信息)
|
||||
|
||||
FE->>OSS: PUT直接上传<br/>Content-Type: image/jpeg<br/>文件二进制数据
|
||||
OSS-->>FE: 200 OK
|
||||
|
||||
opt 通知服务端完成
|
||||
FE->>GO: POST /upload/callback<br/>文件名和元信息
|
||||
GO-->>FE: 200 OK
|
||||
end
|
||||
```
|
||||
|
||||
> [!note] 安全要点
|
||||
> 预签名 URL 有时间限制(通常 5~15 分钟),过期后无效。即使 URL 泄露也无法长期滥用。
|
||||
|
||||
**代码示例:**
|
||||
|
||||
```go
|
||||
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泄露<br/>导致账单爆炸"]
|
||||
T2["未限流<br/>OSS请求被拒绝"]
|
||||
T3["命名冲突<br/>同名文件覆盖"]
|
||||
T4["内存溢出<br/>全量读入内存"]
|
||||
T5["内网跨地域<br/>用了公网Endpoint"]
|
||||
end
|
||||
|
||||
subgraph FIXES["解决方案"]
|
||||
F1["使用RAM子账号+STS<br/>临时凭证(15min过期)"]
|
||||
F2["客户端侧限速+重试退避<br/>指数退避算法"]
|
||||
F3["使用UUID或NanoID重命名<br/>fileUUID_扩展名"]
|
||||
F4["始终用io.Reader流式处理<br/>避免ReadAll读大文件"]
|
||||
F5["确保ECS与Bucket同Region<br/>使用内网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](#方案-csts-临时凭证直传) | `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 的集成模式
|
||||
- [[部署与运维基础]] — 容器环境中磁盘、网络和密钥管理的注意事项
|
||||
@@ -0,0 +1,388 @@
|
||||
---
|
||||
tags: [CORS, 跨域, 安全, HTTP, Web开发, 前后端分离]
|
||||
create time: 2026-05-18 14:30
|
||||
---
|
||||
|
||||
# 企业项目跨域问题解决方案
|
||||
|
||||
## 概述
|
||||
|
||||
跨域(Cross-Origin Resource Sharing, CORS)是前后端分离架构中最常见的网络限制问题。本文从浏览器同源策略出发,系统讲解跨域产生的原理、常见场景及完整解决方案,涵盖服务端配置、Nginx 反向代理和客户端降级方案,并附带 Go / Spring Boot / Node.js / Nginx 的实际代码示例。
|
||||
|
||||
> [!question] 思考:为什么浏览器要阻止跨域请求?
|
||||
>
|
||||
> 根源在于浏览器的「同源策略」(Same-Origin Policy)——它是一种安全机制,防止恶意网站读取你当前页面的 Cookie、LocalStorage 和 API 响应。但这一保护也带来了开发的困扰:**我们需要在保持安全的前提下,让不同域的服务之间正常通信。**
|
||||
|
||||
## 什么是跨域
|
||||
|
||||
### 同源的判定
|
||||
|
||||
两个 URL 只要 **协议(protocol)**、**域名(host)**、**端口(port)** 三者完全一致,就称为同源。任意一项不同,即为跨域。
|
||||
|
||||
| 当前 URL | 目标 URL | 是否跨域 | 原因 |
|
||||
|----------|----------|----------|------|
|
||||
| `https://api.example.com` | `https://app.example.com` | ✅ 跨域 | 域名不同 |
|
||||
| `http://localhost:3000` | `http://localhost:8080` | ✅ 跨域 | 端口不同 |
|
||||
| `https://example.com` | `http://example.com` | ✅ 跨域 | 协议不同 |
|
||||
| `https://example.com/api` | `https://example.com/data` | ❌ 同域 | 仅路径不同(不算跨域) |
|
||||
|
||||
### 跨域的本质
|
||||
|
||||
跨域不是请求发不出去,而是 **浏览器拒绝接收响应**。实际过程如下:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["前端发起 AJAX / Fetch 请求"] --> B{"是否为跨域请求?"}
|
||||
B -- '否' --> C["正常发送, 浏览器接收响应"]
|
||||
B -- '是' --> D{"是否预检请求?"}
|
||||
D -- '简单请求' --> E["直接发送请求"]
|
||||
D -- '复杂请求' --> F["先发 OPTIONS 预检"]
|
||||
F --> G{"服务端返回 200 + 正确 CORS 头?"}
|
||||
G -- '是' --> E
|
||||
G -- '否' --> H["浏览器拦截预检, 不发送真实请求"]
|
||||
E --> I{"服务端是否携带 CORS 响应头?"}
|
||||
I -- '是' --> C
|
||||
I -- '否' --> J["浏览器拒绝响应, 控制台报错"]
|
||||
```
|
||||
|
||||
关键结论:**服务器必须设置正确的 CORS 响应头,浏览器才会放行响应给 JavaScript。**
|
||||
|
||||
## 常见跨域场景
|
||||
|
||||
### 开发环境(本地 vs 后端)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
DevBrowser["浏览器<br/>localhost:5173"] --> Backend["Go API :8080<br/>(请求 /api/users)"]
|
||||
```
|
||||
|
||||
这是开发阶段最常见的跨域场景——前端 dev server 跑在 `localhost:5173`,后端服务在 `:8080`,端口不同即触发跨域。
|
||||
|
||||
### 生产环境(多子域名)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
App["app.company.com"] --> Api["api.company.com<br/>(需配 CORS)"]
|
||||
```
|
||||
|
||||
主站和 API 属于不同子域名,同样需要处理跨域。
|
||||
|
||||
### 嵌入场景(iframe / CDN)
|
||||
|
||||
- 页面加载第三方 CDN 上的静态资源(字体、JS、CSS)→ 通常没问题(GET 请求天然不受限)
|
||||
- 页面内 iframe 向自身域名发 XHR 请求 → 受跨域限制
|
||||
- 调用第三方 API(如支付回调、地图 SDK)→ 取决于对方是否支持 CORS
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 方案一:服务端配置 CORS(推荐首选)
|
||||
|
||||
**适用场景**:自有服务端,可直接修改响应头。
|
||||
|
||||
#### Go (Gin 框架)
|
||||
|
||||
使用官方 `gin-contrib/cors` 中间件:
|
||||
|
||||
```go
|
||||
import "github.com/gin-contrib/cors"
|
||||
|
||||
router := gin.Default()
|
||||
|
||||
config := cors.DefaultConfig()
|
||||
config.AllowOrigins = []string{"https://app.example.com"} // 明确允许的源
|
||||
config.AllowMethods = []string{"GET", "POST", "PUT", "DELETE"}
|
||||
config.AllowHeaders = []string{"Origin", "Content-Type", "Authorization"}
|
||||
config.AllowCredentials = true // 携带 Cookie 时必须设为 true
|
||||
config.MaxAge = 12 * time.Hour // 预检请求缓存时长
|
||||
|
||||
router.Use(cors.New(config))
|
||||
```
|
||||
|
||||
`AllowOrigins` 白名单是安全的第一道防线——它告诉浏览器"只有这些域名可以读取我的接口数据"。`AllowCredentials` 用于允许携带 Cookie(如登录态),但配合白名单使用,绝不可与通配符 `*` 同时出现。`MaxAge` 设置预检请求在浏览器的缓存时长,减少不必要的 OPTIONS 请求。
|
||||
|
||||
> [!tip] 生产环境安全要点
|
||||
>
|
||||
> - **不要用 `AllowOrigins = ["*"]` 配合 `AllowCredentials = true`**,这会引发 panic 或无效配置。
|
||||
> - 应显式列出允许的前端域名白名单,避免恶意站点劫持接口。
|
||||
> - 如需支持多个前端域名,可从环境变量或配置中心动态读取。
|
||||
|
||||
#### Java (Spring Boot)
|
||||
|
||||
Spring Boot 提供了多种 CORS 配置方式:
|
||||
|
||||
**全局配置(实现 WebMvcConfigurer):**
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
public class CorsConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/api/**") // 生效的路径
|
||||
.allowedOrigins("https://app.example.com")
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600); // 预检缓存 1 小时
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注解级别(细粒度控制单个 Controller):**
|
||||
|
||||
```java
|
||||
@CrossOrigin(origins = "https://app.example.com", maxAge = 3600)
|
||||
@RestController
|
||||
@RequestMapping("/api/admin")
|
||||
public class AdminController { ... }
|
||||
```
|
||||
|
||||
#### Node.js (Express)
|
||||
|
||||
```js
|
||||
const cors = require('cors');
|
||||
|
||||
app.use(cors({
|
||||
origin: ['https://app.example.com', 'https://admin.example.com'],
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
}));
|
||||
```
|
||||
|
||||
此配置传入 Express 的中间件链中,会对所有路由生效。如需限定路径范围,可改为 `app.use('/api', cors(config))` 。与 Go / Java 方案相比,Node.js 方案最轻量——无需额外注解或配置类,只需在启动时注册一次即可。
|
||||
|
||||
### 方案二:Nginx 反向代理(零侵入)
|
||||
|
||||
**适用场景**:无法修改后端代码、多语言混合后端统一治理、隐藏后端地址提升安全。
|
||||
|
||||
核心思路:让前端访问 Nginx 的 `/api` 路径,Nginx 把请求转发到后端,同时注入 CORS 响应头。
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name app.example.com;
|
||||
|
||||
# --- 前端静态资源 ---
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# --- API 反代 + CORS ---
|
||||
location /api/ {
|
||||
# 处理预检请求
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Allow-Origin' '$http_origin';
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';
|
||||
add_header 'Access-Control-Max-Age' 1728000;
|
||||
add_header 'Content-Type' 'text/plain; charset=utf-8';
|
||||
add_header 'Content-Length' 0;
|
||||
return 204; # 直接结束预检,不走后端
|
||||
}
|
||||
|
||||
# 真实请求,注入响应头
|
||||
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
|
||||
|
||||
proxy_pass http://backend_server:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!warning] Nginx 注意事项
|
||||
>
|
||||
> - `if ($request_method = 'OPTIONS')` 中的 `if` 在 Nginx 中有陷阱,但它在这里是安全的用法(只操作 header)。
|
||||
> - `always` 参数确保即使错误响应(4xx / 5xx)也带上 CORS 头。
|
||||
> - `return 204` 直接返回空体,避免 OPTIONS 请求打到后端增加负载。
|
||||
|
||||
### Nginx 方案的架构图
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Browser["浏览器 app.example.com"] --> Nginx["Nginx 443<br/>(GET /api/users)"]
|
||||
Nginx --> Backend["后端 :8080<br/>(proxy_pass)"]
|
||||
Backend --> JSONResponse["JSON 响应"]
|
||||
Nginx --> BrowserReply["返回含 CORS 头的<br/>JSON 响应"]
|
||||
BrowserReply --> Browser
|
||||
```
|
||||
|
||||
对比之下,不加 Nginx 时的直连模式:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Browser["浏览器<br/>app.example.com<br/><b>❌ 跨域 + 无 CORS 头</b>"] --> Backend["后端 api.example.com:8080"]
|
||||
```
|
||||
|
||||
此时浏览器直接访问后端接口,因域名和端口均不同触发跨域限制,且服务端未配置 CORS 头,浏览器拦截响应。
|
||||
|
||||
### 方案三:开发期 Vite/Webpack Proxy(开发专用)
|
||||
|
||||
**适用场景**:本地开发时绕过跨域,上线后由 Nginx 处理。
|
||||
|
||||
#### Vite 配置 (`vite.config.ts`)
|
||||
|
||||
```ts
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080', // 后端地址
|
||||
changeOrigin: true, // 改写 Host 头
|
||||
secure: false, // 自签证书时可关闭校验
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
这样前端发 `fetch('/api/users')` 时,Vite dev server 会转发到 `http://localhost:8080/api/users`,因为是同源请求,不存在跨域。
|
||||
|
||||
#### Webpack (`vue.config.js` / `webpack.config.js`)
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
devServer: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
`changeOrigin: true` 会将被代理请求的 Host 头改为后端地址,这对于某些依赖 Host 做鉴权的后端服务是必要的。Webpack proxy 同样只在本地开发生效,生产环境仍需 Nginx 或 CORS 中间件配合。
|
||||
|
||||
> [!info] Vite/Webpack Proxy 与正式方案的区别
|
||||
>
|
||||
> | | 开发 Proxy | Nginx / 服务端 CORS |
|
||||
> |---|---|---|
|
||||
> | 用途 | 仅限本地开发 | 生产环境 |
|
||||
> | 需要后端改吗 | 不需要 | 不需要(Nginx)/ 需要(服务端) |
|
||||
> | HTTPS 支持 | 需额外配置 | 原生支持 |
|
||||
> | 部署复杂度 | 无 | 低 |
|
||||
|
||||
### 方案四:JSONP / 服务端重定向(历史方案,了解即可)
|
||||
|
||||
#### JSONP
|
||||
|
||||
利用 `<script>` 标签不受同源策略限制的特性,通过回调函数接收数据。仅支持 GET,且存在 XSS 风险,已属历史方案。
|
||||
|
||||
**原理**:后端返回 `callbackName(data)` 这样的 JavaScript 代码,前端动态插入 `<script>` 标签触发执行。
|
||||
|
||||
```html
|
||||
<!-- 前端 -->
|
||||
<script>
|
||||
function jsonpCallback(data) {
|
||||
console.log(data); // 拿到后端返回的数据
|
||||
}
|
||||
</script>
|
||||
<script src="https://api.example.com/data?callback=jsonpCallback"></script>
|
||||
```
|
||||
|
||||
```go
|
||||
// Go 后端示例
|
||||
func JSONPHandler(w http.ResponseWriter, r *http.Request) {
|
||||
callback := r.URL.Query().Get("callback")
|
||||
data := `{"name": "John", "age": 30}`
|
||||
w.Header().Set("Content-Type", "application/javascript")
|
||||
fmt.Fprintf(w, "%s(%s)", callback, data)
|
||||
}
|
||||
```
|
||||
|
||||
> [!note] JSONP 的局限性
|
||||
>
|
||||
> - **只支持 GET**——POST/PUT/DELETE 等无法实现
|
||||
> - **无错误处理**——脚本加载失败时只能通过 `onerror` 粗略捕获
|
||||
> - **安全风险**——任何提供 JSONP 的接口都可能被恶意站点调用,需额外做 Referer 校验
|
||||
> - **已被 CORS 全面取代**——现代浏览器全部支持 CORS,新项目不应考虑此方案
|
||||
|
||||
#### 服务端重定向
|
||||
|
||||
后端自己调后端,前端只请求同源接口。适合微服务间调用,但不解决浏览器侧跨域。
|
||||
|
||||
## 进阶话题
|
||||
|
||||
### 带凭据的请求(Cookie / Authorization Header)
|
||||
|
||||
当 `withCredentials: true`(fetch)或 `xhr.withCredentials = true`(XMLHttpRequest)时:
|
||||
|
||||
1. 服务端 `Access-Control-Allow-Origin` **不能**为 `*`,必须是具体域名
|
||||
2. 服务端需设置 `Access-Control-Allow-Credentials: true`
|
||||
3. 浏览器会在请求中带上 Cookie
|
||||
|
||||
```ts
|
||||
// 前端 fetch 带 Cookie
|
||||
fetch('https://api.example.com/user/profile', {
|
||||
method: 'GET',
|
||||
credentials: 'include', // 关键:带上 Cookie
|
||||
headers: { 'Authorization': 'Bearer xxx' },
|
||||
});
|
||||
```
|
||||
|
||||
`credentials: 'include'` 是让浏览器发送 Cookie 的关键。如果只设 `credentials` 而不配服务端 `Access-Control-Allow-Credentials: true`,或者后端 `Allow-Origin` 仍为 `*`,请求同样会被拦截——这是一个"双方都满足才能通过"的条件。
|
||||
|
||||
### 预检请求的优化
|
||||
|
||||
复杂请求(自定义头、PUT/DELETE 等)会先发送 `OPTIONS` 预检。可通过以下方式减少开销:
|
||||
|
||||
#### Max-Age 缓存的影响
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Max-Age = 0<br/><b>每次请求都发 OPTIONS</b>"] --> B["⚠️ 高延迟,<br/>服务器压力大"]
|
||||
C["Max-Age = 86400<br/><b>24小时内复用预检结果</b>"] --> D["✅ 首慢后续快,<br/>负载低"]
|
||||
```
|
||||
|
||||
#### 设置方式
|
||||
|
||||
服务端或 Nginx 中设置响应头即可:
|
||||
|
||||
- `Access-Control-Max-Age: 86400` — 缓存 24 小时,超过需重新预检
|
||||
- 一般建议设为 **1 天到 7 天**,具体取决于接口变更频率
|
||||
|
||||
### RESTful 动词与预检的关系
|
||||
|
||||
| 方法 | 是否可能预检 | 原因 |
|
||||
|------|-------------|------|
|
||||
| GET | ❌ 不会 | 简单请求 |
|
||||
| POST (application/x-www-form-urlencoded / multipart/form-data / text/plain) | ❌ 可能不会 | Content-Type 在白名单内 |
|
||||
| POST (application/json) | ✅ 会预检 | Content-Type 不在简单请求白名单 |
|
||||
| PUT / DELETE / PATCH | ✅ 会预检 | 不在简单请求方法的白名单 |
|
||||
|
||||
### 常见问题排查清单
|
||||
|
||||
> [!check] 跨域问题排查流程
|
||||
>
|
||||
> 1. **确认跨域类型** — 打开浏览器 DevTools → Network,看请求状态码是 `200` 还是 `cors error`
|
||||
> 2. **检查请求头** — Request Headers 里是否有 `Origin`,值是否正确
|
||||
> 3. **检查响应头** — Response Headers 中是否有 `Access-Control-Allow-Origin`,值和前端域名匹配吗
|
||||
> 4. **区分简单请求和预检** — 有无单独的 `OPTIONS` 请求发出?OPTIONS 返回什么?
|
||||
> 5. **credentials 场景** — 是否同时满足了 `具体 Allow-Origin` + `Allow-Credentials: true`
|
||||
> 6. **多级代理链路** — 经过网关 / WAF / CDN 时,确认每一层都不删除 CORS 头
|
||||
|
||||
## 方案选型速查表
|
||||
|
||||
| 场景 | 推荐方案 | 理由 |
|
||||
|------|----------|------|
|
||||
| 前后端同公司,后端可改 | 服务端 CORS 中间件 | 最直接,语义清晰 |
|
||||
| 多语言后端 / 不便改代码 | Nginx 反代注入 CORS 头 | 统一治理,零侵入 |
|
||||
| 本地开发 | Vite/Webpack Proxy | 开发体验最佳 |
|
||||
| 生产环境隐藏后端 IP | Nginx 反代 | 安全 + 跨域双收益 |
|
||||
| 纯静态页调用第三方公开 API | 让第三方配好 CORS | 自身无能为力 |
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/GIN/3-middleware/cors-registration-scope/cors-preflight]] — Gin 框架下预检请求的详细机制
|
||||
- [[hhs/DEV/Nginx/Nginx]] — Nginx 配置速查,含反向代理、负载均衡等
|
||||
- [[hhs/DEV/XSS与CSRF攻击/XSS与CSRF攻击]] — 同源策略是防御 XSS/CSRF 的基础
|
||||
- [[hhs/KingSoft/docs/Go语言web开发/03-服务端鉴权认证方案]] — Cookie / Session 鉴权与 CORS 凭据的配合
|
||||
- [[hhs/MS/02-服务治理/01-API网关]] — 微服务架构中通过 API 网关统一处理跨域
|
||||
Reference in New Issue
Block a user