From 57346735d3f779a8d632da8cdbd06c07766472b0 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Mon, 18 May 2026 21:09:55 +0800 Subject: [PATCH] vault backup: 2026-05-18 21:09:55 --- hhs/DEV/Nginx/Nginx.md | 1005 ++++++++++++++++++++++++++++++++++ hhs/DEV/OSS/OSS.md | 667 ++++++++++++++++++++++ hhs/DEV/跨域问题/跨域问题.md | 388 +++++++++++++ 3 files changed, 2060 insertions(+) create mode 100644 hhs/DEV/Nginx/Nginx.md create mode 100644 hhs/DEV/OSS/OSS.md create mode 100644 hhs/DEV/跨域问题/跨域问题.md diff --git a/hhs/DEV/Nginx/Nginx.md b/hhs/DEV/Nginx/Nginx.md new file mode 100644 index 0000000..7d6c046 --- /dev/null +++ b/hhs/DEV/Nginx/Nginx.md @@ -0,0 +1,1005 @@ +--- +tags: [tech, infrastructure, web-server, proxy] +create time: 2026-05-18 20:30 +--- + +# Nginx + +## 概述 + +Nginx(读作 "engine-x")是一个高性能的 **HTTP 服务器**和**反向代理服务器**,同时也支持 IMAP/POP3/SMTP 协议。由 Igor Sysoev 开发,以其高并发、低内存消耗闻名。 + +> [!QUESTION] 为什么选择 Nginx? +> - **异步事件驱动架构**:能轻松支撑数万并发连接 +> - **轻量级**:处理静态文件只需 Apache 1/5 的内存 +> - **热部署**:配置文件修改后可无缝 reload,不中断现有请求 +> - **生态成熟**:大量第三方模块和社区方案 + +核心用途:**反向代理**、**负载均衡**、**静态资源服务**、**SSL/TLS 终止**、**API 网关**。 + +## 安装 + +### 主流方式 + +| 方式 | 适用场景 | 命令示例 | +|------|---------|---------| +| 系统包管理器 | 快速部署、稳定优先 | `sudo apt install nginx` / `sudo yum install nginx` | +| 官方仓库 | 需要最新版本 | 添加 `nginx.org` 源后安装 | +| Docker | 容器化部署 | `docker run -d --name nginx -p 80:80 nginx:alpine` | +| 源码编译 | 需要自定义模块 | `./configure --with-http_ssl_module && make && sudo make install` | + +> [!TIP] 生产环境推荐 +> 使用官方仓库或 Docker Alpine 镜像,体积小且更新及时。开发环境直接用系统包管理器即可。 + +### 验证安装 + +```bash +nginx -v # 查看版本 +nginx -t # 测试配置语法 +systemctl status nginx # 查看服务状态 +``` + +## 核心概念 + +### 架构模型 + +```mermaid +graph LR + Master["Master Process 管理进程"] --> WorkerA["Worker Process 1 处理请求"] + Master --> WorkerB["Worker Process 2 处理请求"] + Master --> WorkerC["Worker Process 3 处理请求"] + WorkerA --> Conn1["Connection A"] + WorkerA --> Conn2["Connection B"] + WorkerB --> Conn3["Connection C"] + WorkerB --> Conn4["Connection D"] + WorkerC --> Conn5["Connection E"] + style Master fill:#f9d,stroke:#333 + style WorkerA fill:#ccf,stroke:#333 + style WorkerB fill:#ccf,stroke:#333 + style WorkerC fill:#ccf,stroke:#333 +``` + +Nginx 采用 **Master-Worker 多进程模型**: + +- **Master 进程**:负责读取配置、管理 Worker、平滑重载 +- **Worker 进程**:每个 Worker 独立运行,用**事件驱动**方式处理请求。一个请求不会在其他 Worker 中排队 + +> [!NOTE] 关键问题 +> 为什么不用多线程?因为线程切换开销大、锁竞争严重。Nginx 的事件驱动模型(epoll/kqueue)在 Linux/BSD 上能做到单个 Worker 处理上万并发连接,这就是著名的 **"C10K 问题"**解决方案之一。 + +### 配置文件结构 + +主配置文件通常是 `/etc/nginx/nginx.conf`,采用**嵌套块结构**: + +```nginx +# 全局块 +worker_processes auto; # Worker 数量(auto = CPU 核数) +error_log /var/log/nginx/error.log warn; + +events { # events 块 + worker_connections 1024; # 每个 Worker 的最大连接数 +} + +http { # http 块 + include mime.types; + default_type application/octet-stream; + sendfile on; + + # upstream 块 — 定义后端服务器池 + upstream backend { + server 127.0.0.1:8080; + } + + server { # server 块 — 虚拟主机 + listen 80; + server_name example.com; + + location / { # location 块 — URL 匹配路由 + proxy_pass http://backend; + } + } +} +``` + +配置文件加载顺序:**全局块 → events 块 → http 块 → server 块 → location 块**。外层指令对内层生效(继承关系)。 + +## 常用配置实战 + +### 1. 反向代理 + +最经典的用法——将客户端请求转发到后端服务。 + +```nginx +server { + listen 80; + server_name api.example.com; + + location / { + proxy_pass http://127.0.0.1:3000; + + # 必须传递的信息头 + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket 支持 + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # ── Proxy Buffering(缓冲控制)── + proxy_buffering on; # 默认开启,先接收后端完整响应再发给客户端 + proxy_buffer_size 4k; # 第一块缓冲区大小(通常存响应头) + proxy_buffers 8 4k; # 后续缓冲区数量和大小 + proxy_busy_buffers_size 8k; # 忙缓冲区(一边发客户端一边收后端) + # proxy_buffering off; # 关闭缓冲 = 流式转发,适合 SSE/WebSocket + } +} +``` + +> [!NOTE] 反向代理 vs 正向代理 +> - **正向代理**:代理**客户端**,帮内网用户访问外网(如 VPN) +> - **反向代理**:代理**服务端**,对外隐藏后端真实地址(Nginx 的主要角色) + +> [!NOTE] Proxy Buffering 工作原理 +> ```mermaid +> sequenceDiagram +> participant Client as 客户端 +> participant Nginx as Nginx (Buffer) +> participant Backend as 后端服务 +> Client->>Nginx: 请求 +> Nginx->>Backend: 转发请求 +> Backend-->>Nginx: 响应数据写入 Buffer +> Note over Nginx: 整个响应写完后... +> Nginx-->>Client: 从 Buffer 发送给客户端 +> alt buffering off +> Nginx-->>Client: 实时转发 (Stream) +> end +> ``` +> - **开启 buffering**(默认):后端可以先完成处理再发送,Nginx 承担内存压力,提升吞吐 +> - **关闭 buffering**:实时转发,内存占用低但要求后端尽快返回;适合 SSE、大文件下载 + +#### Proxy Cache — 反向缓存 + +Nginx 还可以作为**反向缓存代理**,将后端的响应缓存在本地磁盘。 + +```nginx +http { + # 定义缓存区域 + proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=app_cache:10m + max_size=1g inactive=60m use_temp_path=off; + + server { + location /api/ { + proxy_pass http://backend; + + # 启用缓存 + proxy_cache app_cache; # 使用哪个缓存区 + proxy_cache_key "$scheme$request_method$host$request_uri"; # 缓存 key + proxy_cache_valid 200 302 10m; # 200/302 响应缓存 10 分钟 + proxy_cache_valid 404 1m; # 404 缓存 1 分钟 + proxy_cache_valid any 5m; # 其他状态码缓存 5 分钟 + + # 缓存命中时添加头部(方便调试) + add_header X-Cache-Status $upstream_cache_status; + + # 绕过缓存的条件(未登录用户不走缓存) + proxy_cache_bypass $http_authorization; + proxy_no_cache $http_authorization; + + # 过期后仍向客户端提供旧缓存(stale),后台异步刷新 + proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; + proxy_cache_background_update on; + proxy_cache_revalidate on; + } + } +} +``` + +`X-Cache-Status` 返回值说明: + +| 值 | 含义 | +|------|------| +| `HIT` | 缓存命中,直接返回 | +| `MISS` | 缓存未命中,回源获取并缓存 | +| `EXPIRED` | 缓存过期,向后端重新验证 | +| `UPDATING` | 后台正在更新缓存,返回旧版本 | +| `BYPASS` | 被配置主动跳过 | + +### 2. 负载均衡 + +当后端有多台服务器时,Nginx 可以将流量分发到不同节点。 + +```nginx +upstream frontend { + # 三种常用策略: + + # 轮询(默认)— 按顺序依次分配 + # server 10.0.0.1:8080; + # server 10.0.0.2:8080; + + # 权重 — 数值越大分到的请求越多 + server 10.0.0.1:8080 weight=3; + server 10.0.0.2:8080 weight=1; + + # IP Hash — 同一客户端 IP 固定分配到同一台后端 + # ip_hash; + + # 最少连接 — 分配到当前活跃连接最少的节点 + # least_conn; +} + +server { + listen 80; + location / { + proxy_pass http://frontend; + } +} +``` + +常见策略对比: + +| 策略 | 适用场景 | 注意 | +|------|---------|------| +| **轮询 (round-robin)** | 通用场景 | 各节点性能相近时效果最好 | +| **加权轮询 (weight)** | 节点性能差异大 | 可根据硬件能力分配权重 | +| **IP Hash** | 有 Session 粘性需求 | Cookie 登录可能失效 | +| **最少连接 (least_conn)** | 请求处理时间不均 | 长连接场景需留意偏差 | + +### 3. 健康检查与后端管理 + +Nginx 社区版提供**被动式**健康检查(通过 fail_timeout 和 max_fails 参数),商业版(Plus)支持主动探测。 + +```nginx +upstream backend { + server 10.0.0.1:8080 weight=3 max_fails=3 fail_timeout=30s; + server 10.0.0.2:8080 weight=1 max_fails=3 fail_timeout=30s; + + # 标记为 down — 不参与任何流量(常用于灰度发布逐步放量) + # server 10.0.0.3:8080 down; + + # max_conns — 限制分配到该节点的并发数,超限时返回错误 + # server 10.0.0.4:8080 max_conns=100; + + # keepalive — 保持与后端的长连接,避免频繁建连开销 + keepalive 32; # 每个 Worker 最多保持的空闲连接数 + keepalive_timeout 60s; + keepalive_requests 100; +} +``` + +> [!NOTE] passive health check 机制 +> `max_fails=3 fail_timeout=30s` 含义:**在 30 秒内,某个后端失败超过 3 次,则该后端在 30 秒内被剔除**。失败次数计数是从后端恢复后重新累加的。 + +使用 `keepalive` 时需要在 proxy 层做配套设置: + +```nginx +http { + upstream backend { + server 127.0.0.1:8080; + keepalive 32; + } + + server { + location / { + proxy_pass http://backend; + + # 关键:使用 HTTP/1.1 + Connection 头告诉 Nginx 复用连接 + proxy_http_version 1.1; + proxy_set_header Connection ""; + } + } +} +``` + +> [!QUESTION] 为什么需要 `proxy_set_header Connection ""`? +> +> HTTP/1.0 默认关闭 keepalive,如果后端期望长连接,客户端(Nginx)发送的 `Connection: close` 会导致连接立即关闭,使 `keepalive` 配置形同虚设。清空该头部即可让 TCP 连接持续复用。 + +### 4. 静态文件服务 + +利用 Nginx 处理静态资源的优势,大幅减轻后端压力。 + +```nginx +server { + listen 80; + server_name cdn.example.com; + + # 根目录指向 + root /var/www/html; + index index.html index.htm; + + # 精确匹配 + location /assets/ { + expires 30d; # 浏览器缓存 30 天 + add_header Cache-Control "public, immutable"; + gzip_static on; # 提供 .gz 预压缩文件 + } + + # 错误页面 + error_page 404 /404.html; + error_page 500 502 503 504 /50x.html; +} +``` + +> [!TIP] 静态文件优化要点 +> - **gzip_static on**:如果预先生成了 `.gz` 文件,直接发送,节省 CPU +> - **sendfile on**:启用内核零拷贝,静态文件传输效率极高 +> - **expires**:合理设置缓存头部,减少重复请求 + +### 5. HTTPS / SSL + +```nginx +server { + listen 443 ssl http2; + server_name example.com; + + ssl_certificate /etc/nginx/ssl/cert.pem; + ssl_certificate_key /etc/nginx/ssl/key.pem; + + # 推荐的现代 TLS 配置 + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; + ssl_prefer_server_ciphers off; + + location / { + proxy_pass http://127.0.0.1:3000; + } +} + +# HTTP → HTTPS 自动跳转 +server { + listen 80; + server_name example.com; + return 301 https://$host$request_uri; +} +``` + +> [!NOTE] Let's Encrypt 免费证书 +> 用 Certbot 可以一键申请和续期免费证书: +> ```bash +> sudo certbot --nginx -d example.com +> ``` +> Certbot 会自动帮你写入 Nginx 配置并完成 SSL 设置。 + +### 6. 读写分离 & 限流 + +```nginx +# 请求速率限制 +limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s; + +# 连接数限制 +limit_conn_zone $binary_remote_addr zone=conn_limit:10m; + +server { + location /api/ { + limit_req zone=api_limit burst=20 nodelay; # 突发 20 个请求 + limit_conn conn_limit 10; # 每个 IP 最多 10 连接 + + proxy_pass http://backend; + } +} +``` + +### 7. IP 访问控制 + +通过 `allow` / `deny` 指令实现基于客户端 IP 的白名单和黑名单。 + +```nginx +# ── 场景 1:管理后台仅允许内网访问 ── +location /admin/ { + allow 10.0.0.0/8; # 允许内网段 + allow 192.168.1.0/24; # 允许办公网 + deny all; # 拒绝其余所有 IP + + proxy_pass http://admin-backend; +} + +# ── 场景 2:特定接口封禁恶意 IP ── +map $remote_addr $blocked_ip { + default 0; + 192.168.1.100 1; # 已知攻击者 + 10.99.0.0/16 1; # 整个 C 段封禁 +} + +server { + location / { + if ($blocked_ip) { + return 403; # 直接返回,不转发到后端 + } + + proxy_pass http://backend; + } +} +``` + +> [!TIP] 推荐做法 +> `allow/deny` 是在 Nginx 层面直接拦截(return 403),比让请求到达后端更安全高效。对于大名单封禁场景,使用 `geo` / `map` 模块配合数组匹配更灵活。 + +### 8. HTTP Basic Auth 认证 + +为特定接口添加用户名密码登录。 + +```bash +# 生成密码文件(首次) +sudo htpasswd -c /etc/nginx/.htpasswd admin +# 后续添加用户去掉 -c 参数即可 +sudo htpasswd /etc/nginx/.htpasswd developer +``` + +```nginx +location /api/internal/ { + auth_basic "Restricted Area"; # 弹窗提示标题 + auth_basic_user_file /etc/nginx/.htpasswd; + + proxy_pass http://backend; +} +``` + +## 安全加固 + +生产环境中的 Nginx 是第一道防线,需要做好基础安全设置。 + +```nginx +http { + # ── 隐藏版本信息 ── + server_tokens off; # response header 中不显示 Nginx 版本 + more_clear_headers Server; # 需 headers-more 模块,完全抹除 Server 头 + + # ── 安全响应头 ── + add_header X-Frame-Options "SAMEORIGIN" always; # 防止点击劫持 + add_header X-Content-Type-Options "nosniff" always; # 禁止 MIME 嗅探 + add_header X-XSS-Protection "1; mode=block" always; # XSS 过滤器 + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # ── HSTS(强制 HTTPS)── + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; + + # ── CORS 跨域支持 ── + set $cors_origin ""; + if ($http_origin ~* "^https://(app\.example\.com|cdn\.example\.com)$") { + set $cors_origin $http_origin; + } + + add_header Access-Control-Allow-Origin $cors_origin always; + add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With" always; + + location /api/ { + # 处理浏览器预检请求 (OPTIONS) + if ($request_method = OPTIONS) { + return 204; + } + + proxy_pass http://backend; + } +} +``` + +### 防盗链 + +```nginx +location ~* \.(jpg|jpeg|png|gif|webp|svg|ico)$ { + root /var/www/static; + + # valid_referers — 只允许指定域名引用图片 + valid_referers none blocked server_names + *.example.com example.com; + + if ($invalid_referer) { + # 非法引用时返回 403 或一张占位图 + return 403; + # rewrite ^/ https://example.com/no-hotlink.png break; + } +} +``` + +## 高级特性 + +### Location 匹配优先级 + +```mermaid +graph TD + A["URL请求到达"] --> B{"精确匹配\\n^~ ?"} + B -->|"是"| E["使用精确匹配"] + B -->|"否"| C{"正则匹配\\n按顺序?"} + C -->|"第一个匹配"| F["使用正则匹配 \\n立即停止"] + C -->|"无匹配"| D{"前缀最长匹配\\n是否有^~ ?"} + D -->|"是"| G["使用前缀匹配 \\n跳过正则"] + D -->|"否"| H["使用最长前缀匹配"] + style E fill:#9f9,stroke:#333 + style F fill:#f99,stroke:#333 + style G fill:#9cf,stroke:#333 + style H fill:#ff9,stroke:#333 +``` + +Location 匹配遵循严格优先级: + +| 优先级 | 类型 | 写法 | 说明 | +|--------|------|------|------| +| ① | 精确 | `=` | 完全相等才命中 | +| ② | 最佳前缀 | `^~` | 最长前缀匹配后立即停止搜索正则 | +| ③ | 正则 | `~` / `~*` | 按配置文件中的**顺序**匹配,第一个命中就停止 | +| ④ | 普通前缀 | (无前缀符) | 记录最长匹配,但最终可能被正则覆盖 | + +```nginx +location = /exact-match { # ① 精确匹配 + return 200 "exact"; +} + +location ^~ /static/ { # ② 最佳前缀,跳过正则 + root /var/www; +} + +location ~* \.(jpg|png|gif)$ { # ③ 正则,忽略大小写 + expires 7d; +} + +location /images { # ④ 普通前缀 + alias /var/www/images; +} +``` + +> [!QUESTION] 思考题 +> 请求 `/images/logo.png` 会被哪个 location 处理? +> - ① `/images` 和普通前缀 +> - ② `~* \.(jpg|png|gif)$` 正则 +> +> **答案**:②。虽然 `/images` 的前缀更长,但正则匹配的优先级高于普通前缀。只有 `^~` 才能阻止正则匹配。 + +### Gzip 压缩 + +```nginx +http { + gzip on; + gzip_vary on; # 添加 Vary: Accept-Encoding + gzip_proxied any; # 对所有代理请求都压缩 + gzip_comp_level 6; # 压缩级别 1-9,6 是性价比最优 + gzip_min_length 1k; # 小于 1k 的不压缩 + + gzip_types text/plain text/css application/json application/javascript + text/xml application/xml application/xml+rss text/javascript + image/svg+xml; # 需要压缩的内容类型 +} +``` + +### 日志配置 + +```nginx +http { + # 自定义日志格式 + log_format main '$remote_addr - $remote_user [$time_local] ' + '"$request" $status $body_bytes_sent ' + '"$http_referer" "$http_user_agent" ' + '$request_time $upstream_response_time'; + + access_log /var/log/nginx/access.log main; + error_log /var/log/nginx/error.log warn; + + # 按小时分割日志 + map $time_iso8601 $logdate { + ~^(\d{4}-\d{2}-\d{2}) %Y-%m-%d; + } +} +``` + +> [!NOTE] 常用变量速查 +> | 变量 | 含义 | +> |------|------| +> | `$remote_addr` | 客户端真实 IP | +> | `$request_uri` | 原始请求 URI(含参数) | +> | `$status` | 响应状态码 | +> | `$body_bytes_sent` | 发送给客户端的字节数 | +> | `$request_time` | 请求处理总耗时(秒) | +> | `$upstream_response_time` | 后端响应耗时 | +> | `$http_host` | Host 请求头 | +> | `$https` | 是否 HTTPS(on/off) | + +### Stream 模块 — L4 四层代理 + +Nginx 不仅可以代理 HTTP(L7),通过 `stream` 块还可以做 **TCP/UDP 四层负载均衡**。 + +```nginx +# stream 块在 http 块同级 +stream { + # SSH 连接到内网跳板机 + upstream ssh_backend { + server 10.0.0.5:22; + server 10.0.0.6:22; + } + + # Redis / MySQL 等数据库代理 + upstream redis_backend { + least_conn; + server 10.0.1.10:6379; + server 10.0.1.11:6379; + } + + server { + listen 8022; # 对外暴露 SSH 端口 + proxy_pass ssh_backend; + proxy_timeout 30s; + } + + server { + listen 6379; # 对外暴露 Redis 端口 + proxy_pass redis_backend; + } +} + +http { + # ... HTTP 配置照常 +} +``` + +> [!NOTE] Stream vs HTTP +> | 维度 | `http {}` 块 | `stream {}` 块 | +> |------|-------------|----------------| +> | 工作层级 | L7 应用层 | L4 传输层 | +> | 协议支持 | HTTP、HTTPS | TCP、UDP | +> | 可解析内容 | URL、Header、Cookie | 无法解析应用数据 | +> | 典型场景 | Web 代理、静态服务 | SSH/DB 代理、游戏服务器 | + +### SSI — 服务端包含 + +Nginx 内置 SSI 支持,可以在 HTML 页面中嵌入其他页面的片段。 + +```nginx +location / { + ssi on; # 启用 SSI + ssi_silent_errors on; # 忽略子请求错误,不影响主响应 + root /var/www; +} +``` + +HTML 中使用 `` 指令: + +```html + +
主内容区域
+ +``` + +> [!CAUTION] 性能注意 +> SSI 会为每个 include 发起内部子请求,频繁使用会增加响应延迟。现代架构中更推荐前端组件化方案(React/Vue)。仅在遗留系统维护时考虑 SSI。 + +--- + +## 性能调优 + +### 核心参数参考值 + +```nginx +worker_processes auto; # 等于 CPU 核心数 + +events { + worker_connections 4096; # 根据预期并发调整 + use epoll; # Linux 默认就是 epoll,可显式指定 +} + +http { + sendfile on; # 零拷贝传输静态文件 + tcp_nopush on; # 与 sendfile 配合,减少数据包数量 + tcp_nodelay on; # 禁用 Nagle 算法,降低延迟 + + keepalive_timeout 65; # 保持连接超时 + client_max_body_size 20m; # 上传文件大小限制 + + open_file_cache max=10000 inactive=20s; # 文件描述符缓存 + open_file_cache_valid 30s; + open_file_cache_min_uses 2; +} +``` + +### 调优原则 + +| 方向 | 方法 | 效果 | +|------|------|------| +| **并发提升** | `worker_connections` + `worker_processes` | 最大并发 ≈ workers × connections | +| **I/O 优化** | `sendfile` + `tcp_nopush` | 静态文件传输减少 30%~50% | +| **连接复用** | `keepalive_timeout` + `proxy_keepalive` | 减少 TCP 握手开销 | +| **磁盘 I/O** | `open_file_cache` | 避免频繁打开文件描述符 | + +## 故障排查 + +### 常用命令 + +```bash +# 查看 Nginx 进程信息 +ps aux | grep nginx + +# 重新加载配置(零停机) +sudo nginx -s reload + +# 测试配置文件语法 +sudo nginx -t + +# 查看监听端口 +sudo ss -tlnp | grep nginx + +# 实时查看错误日志 +sudo tail -f /var/log/nginx/error.log + +# 检查配置中包含的所有 server_name +grep -r server_name /etc/nginx/ +``` + +### 常见问题速查 + +> [!ERROR] 502 Bad Gateway +> **原因**:后端服务未启动或无法连接。 +> **排查**:确认后端服务正常运行;检查 `proxy_pass` 的地址和端口;查看后端日志。 + +> [!ERROR] 504 Gateway Timeout +> **原因**:后端响应超时(默认 60 秒)。 +> **解决**:增加 `proxy_read_timeout`,或优化后端查询性能。 +> ```nginx +> proxy_connect_timeout 5s; +> proxy_send_timeout 10s; +> proxy_read_timeout 60s; +> ``` + +> [!ERROR] 413 Request Entity Too Large +> **原因**:上传文件超过 `client_max_body_size`。 +> **解决**:在对应 location 中增大限制。 +> ```nginx +> client_max_body_size 100m; +> ``` + +> [!WARNING] 403 Forbidden(静态文件) +> **常见原因**:权限不足或 `index` 文件不存在。 +> **解决**:确认 Nginx 用户(`www-data`/`nginx`)对目录有读权限;确认文件存在。 + +## 配置管理与部署 + +### 配置文件拆分 + +生产环境中,不建议把所有配置写在一个大文件中。利用 `include` 实现模块化拆分。 + +``` +/etc/nginx/ +├── nginx.conf # 主配置(全局 + events + http) +├── conf.d/ +│ ├── api.conf # API 代理配置 +│ ├── web.conf # Web 前端配置 +│ └── static.conf # 静态资源服务配置 +├── sites-available/ # 可用站点配置(软链到 enabled) +│ ├── example.com +│ └── staging.example.com +├── sites-enabled/ # 已启用站点 +├── upstream/ +│ ├── backend-pool.conf # 后端服务器池定义 +│ └── cache.conf +├── snippets/ # 可复用片段 +│ ├── ssl-params.conf # SSL 安全参数 +│ ├── rate-limit.conf # 限流规则 +│ └── security-headers.conf +└── mime.types +``` + +```nginx +# nginx.conf — 主入口保持精简 +worker_processes auto; +pid /run/nginx.pid; +error_log /var/log/nginx/error.log warn; + +events { worker_connections 1024; } + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # 引入所有 upstream 定义 + include /etc/nginx/upstream/*.conf; + + # 引入所有 server 配置 + include /etc/nginx/conf.d/*.conf; + include /etc/nginx/sites-enabled/*; +} +``` + +> [!TIP] 为什么这样设计? +> - `conf.d/` 下的 `.conf` 文件按功能划分,每个文件一个主题 +> - `sites-available` / `sites-enabled` 模式源自 Apache:修改后通过 `ln -s` 启用,方便回滚 +> - `snippets/` 存放通用模板,避免多处复制粘贴同一份 SSL 或安全配置 + +### Docker Compose 完整示例 + +用 Docker Compose 一键拉起 Nginx + 后端服务的开发环境。 + +```yaml +# docker-compose.yml +services: + nginx: + image: nginx:alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/conf.d:/etc/nginx/conf.d:ro + - ./nginx/certs:/etc/nginx/certs:ro + - ./frontend/dist:/usr/share/nginx/html:ro + - nginx-cache:/var/cache/nginx + - nginx-run:/var/run + depends_on: + - api + - webapp + restart: unless-stopped + + api: + build: ./api + environment: + - NODE_ENV=production + - DATABASE_URL=${DATABASE_URL} + expose: + - "3000" + restart: unless-stopped + + webapp: + build: ./webapp + expose: + - "8080" + restart: unless-staged + +volumes: + nginx-cache: + nginx-run: +``` + +```nginx +# nginx/conf.d/default.conf +upstream api_backend { + server api:3000; +} + +server { + listen 80; + server_name _; + + location /api/ { + proxy_pass http://api_backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location / { + root /usr/share/nginx/html; + try_files $uri $uri/ /index.html; # SPA 路由兜底 + } +} +``` + +> [!NOTE] Docker Nginx 注意事项 +> - 挂载配置时使用 `:ro` 只读标志,防止容器内被篡改 +> - `/var/run` 和 `/var/cache/nginx` 需要用 volume,否则重载时会因缺少 pid 文件而报错 +> - SPA 应用需要 `try_files` 兜底到 `index.html`,否则直接刷新子路由会 404 + +### 灰度发布(Canary Release) + +Nginx 可以实现基于权重或客户端特征的渐进式流量切分。 + +```nginx +http { + # 基于请求头实现灰度(前端在特定版本时携带 x-canary: true) + map $http_x_canary $canary_upstream { + default ""; + "true" canary_group; + } + + # 基于用户 IP 的哈希分流(同一 IP 始终访问同一版本) + geo $hash_upstream { + default production_pool; + 10.0.0.0/8 canary_group; # 仅内部 IP 走新版 + } + + upstream production_pool { + server 10.1.0.1:8080; + server 10.1.0.2:8080; + } + + upstream canary_group { + server 10.2.0.1:8080; # 新版本仅一台机器测试 + } + + server { + location / { + # 优先使用 header 分流,其次 IP hash,最后默认生产 + set $target $canary_upstream; + if ($target = "") { + set $target $hash_upstream; + } + if ($target = "") { + set $target production_pool; + } + + proxy_pass http://$target; + } + } +} +``` + +> [!TIP] 灰度发布常见策略 +> | 阶段 | 流量分配 | 适用场景 | +> |------|---------|---------| +> | **内部测试** | 100% 内部 IP → 新版 | 开发团队自测 | +> | **小流量灰度** | 1% ~ 5% 随机 → 新版 | 验证核心指标 | +> | **逐步放量** | 逐步提升 10% → 50% → 100% | A/B 结果良好 | +> | **全量上线** | 100% → 新版 | 灰度结束 | + +### NJS — Nginx JavaScript 脚本 + +Nginx 支持嵌入 JavaScript 代码,实现动态配置逻辑(无需 reload)。 + +```bash +# 安装 njs 模块 +apt install nginx-module-njs # Debian/Ubuntu +``` + +```nginx +# 加载 JS 脚本 +load_module /usr/lib/nginx/modules/ngx_http_js_module.so; + +http { + js_import filter.js; + + server { + location / { + js_content filter.add_custom_header; + proxy_pass http://backend; + } + } +} +``` + +```javascript +// filter.js +export function addCustomHeader(r) { + r.headersOut["X-Custom-Version"] = "v2.1"; + r.headersOut["X-Served-By"] = r.serverName; + r.sendHeader(); + r.finish(); +} + +export default { addCustomHeader }; +``` + +> [!NOTE] NJS vs OpenResty +> - **NJS**:轻量级,适合简单逻辑(改 Header、条件路由),语法受限但性能极高 +> - **OpenResty**(Nginx + Lua):完整的编程平台,可以操作数据库、调用外部 API,适合复杂网关场景 + +--- + +## 与 CDN 的配合 + +典型的生产架构中,Nginx 常作为 **边缘节点** 或 **回源层** 与 CDN 配合工作: + +```mermaid +sequenceDiagram + participant Client as 客户端 + participant CDN as CDN 边缘节点 + participant Nginx as Nginx 边缘/回源 + participant App as 应用后端 + + Client->>CDN: 请求静态资源 + CDN->>CDN: 缓存命中? + alt 命中 + CDN-->>Client: 返回缓存内容 + else 未命中 + CDN->>Nginx: 回源请求 + Nginx->>App: 转发请求 + App-->>Nginx: 响应数据 + Nginx-->>CDN: 返回内容并缓存 + CDN-->>Client: 返回内容 + end +``` + +> [!TIP] Nginx + CDN 的最佳实践 +> - 静态资源走 CDN,动态请求回源到 Nginx + 后端 +> - Nginx 可以作为 CDN 的边缘服务器,做一层统一入口 +> - 通过 `X-Forwarded-For` 追踪真实的用户 IP(即使经过了 CDN) + +## 关联笔记 + +- [[hhs/DEV/Docker/Docker.md]] +- [[hhs/DEV/Linux/Linux.md]] diff --git a/hhs/DEV/OSS/OSS.md b/hhs/DEV/OSS/OSS.md new file mode 100644 index 0000000..a7d4cfd --- /dev/null +++ b/hhs/DEV/OSS/OSS.md @@ -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
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-oss-go-sdk-v2"] + A --> C["Minio Client SDK
minio/go-minio"] + 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(更简洁,跨云兼容)** + +```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 直接上传
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 节](#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
有效期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:::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 详解](#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
?filename=photo.jpg + Note over GO: 验证用户登录态和权限 + GO->>OSS: PresignedGetObject(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
文件名和元信息 + 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泄露
导致账单爆炸"] + 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](#方案-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 的集成模式 +- [[部署与运维基础]] — 容器环境中磁盘、网络和密钥管理的注意事项 diff --git a/hhs/DEV/跨域问题/跨域问题.md b/hhs/DEV/跨域问题/跨域问题.md new file mode 100644 index 0000000..7ec7909 --- /dev/null +++ b/hhs/DEV/跨域问题/跨域问题.md @@ -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["浏览器
localhost:5173"] --> Backend["Go API :8080
(请求 /api/users)"] +``` + +这是开发阶段最常见的跨域场景——前端 dev server 跑在 `localhost:5173`,后端服务在 `:8080`,端口不同即触发跨域。 + +### 生产环境(多子域名) + +```mermaid +flowchart LR + App["app.company.com"] --> Api["api.company.com
(需配 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
(GET /api/users)"] + Nginx --> Backend["后端 :8080
(proxy_pass)"] + Backend --> JSONResponse["JSON 响应"] + Nginx --> BrowserReply["返回含 CORS 头的
JSON 响应"] + BrowserReply --> Browser +``` + +对比之下,不加 Nginx 时的直连模式: + +```mermaid +flowchart TD + Browser["浏览器
app.example.com
❌ 跨域 + 无 CORS 头"] --> 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 + +利用 ` + +``` + +```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
每次请求都发 OPTIONS"] --> B["⚠️ 高延迟,
服务器压力大"] + C["Max-Age = 86400
24小时内复用预检结果"] --> D["✅ 首慢后续快,
负载低"] +``` + +#### 设置方式 + +服务端或 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 网关统一处理跨域