Skip to content
Gains Summary
Main Navigation 首页 / Home
C++ 编程 / C++ Programming
系统与高性能 / Systems & Performance
Web 开发 / Web Development
人工智能 / Artificial Intelligence
工业软件 / Industrial Software
其他内容 / Other Topics
C++ 编程 / C++系统与性能 / SystemsWeb 开发 / Web人工智能 / AI工业软件 / Industrial

外观

Sidebar Navigation

← Web 开发 / Web Development

Node.js 与框架 / Node.js & Frameworks

1. Node.js 与后端框架学习路线 / Node.js and Backend Frameworks Learning Path

2. Node.js 运行时内部原理 / Node.js Runtime Internals

3. Node.js 异步模式与错误处理 / Async Patterns and Error Handling in Node.js

4. Express.js 深度剖析 / Express.js Deep Dive

5. Express 高级模式与生产实践 / Express Advanced Patterns and Production Practices

6. 现代 Node.js 框架对比 / Modern Node.js Framework Comparison

7. RESTful API 设计原则与实践 / RESTful API Design Principles and Practice

8. Node.js 环境配置与 12-Factor App / Environment Configuration and 12-Factor App

本页目录

RESTful API 设计原则与实践 / RESTful API Design Principles and Practice ​

📅 创建时间:2026-07-28 🏷️ 标签:#REST #API #GraphQL #tRPC #OpenAPI 📚 前置知识:[[03-express-deep-dive]]


📋 本章目标 ​

  • 掌握 REST 的六大约束与 Richardson 成熟度模型,能评估 API 的设计成熟度
  • 能设计出符合资源导向的 URL 结构,正确处理嵌套、过滤、排序和批量操作
  • 理解 offset / cursor / keyset 三种分页方案的原理、实现与适用场景
  • 掌握 API 版本化的四种策略,能制定兼容性承诺与废弃流程
  • 能用 ETag/Last-Modified 实现条件请求,正确设置 Cache-Control 指令
  • 能基于 RFC 7807 构建统一错误响应格式,并处理异步操作的状态反馈
  • 能对比 REST / GraphQL / tRPC 的适用场景,做出合理的技术选型

第1部分:REST 核心原则 —— 不只是 CRUD over HTTP ​

1.1 REST 的六大约束 ​

Roy Fielding 在 2000 年博士论文中定义了 REST 的六大约束。理解这些约束,才能理解 REST 设计决策背后的"为什么"。

┌─────────────────────────────────────────────────────────────┐
│                    REST 六大约束                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. 客户端-服务器 (Client-Server)                            │
│     关注点分离:客户端负责 UI,服务器负责数据存储            │
│     → 各自独立演进,只要接口契约不变                         │
│                                                             │
│  2. 无状态 (Stateless)                                      │
│     每个请求包含理解该请求所需的全部信息                     │
│     → 服务器不保存客户端会话上下文                           │
│     → 好处:水平扩展简单,故障恢复容易                       │
│     → 代价:每次请求需要重复传输认证信息                     │
│                                                             │
│  3. 可缓存 (Cacheable)                                      │
│     响应必须隐式或显式标记自身是否可缓存                     │
│     → GET 请求天然可缓存                                    │
│     → Cache-Control / ETag / Last-Modified                  │
│                                                             │
│  4. 统一接口 (Uniform Interface)                            │
│     REST 最核心的约束,包含四个子约束:                      │
│     • 资源标识 (resource identification)                    │
│     • 通过表述操作资源 (manipulation through representations)│
│     • 自描述消息 (self-descriptive messages)                │
│     • HATEOAS (hypermedia as the engine of application state)│
│                                                             │
│  5. 分层系统 (Layered System)                               │
│     客户端无法区分是直接连服务器还是经过中间层               │
│     → 负载均衡、CDN、API 网关对客户端透明                   │
│                                                             │
│  6. 按需代码 (Code-on-Demand) —— 可选约束                   │
│     服务器可以向客户端发送可执行代码(如 JS)               │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

1.2 Richardson 成熟度模型 ​

评估一个 API 的 REST 成熟度,可以用 Richardson Maturity Model(Level 0-3):

┌─────────────────────────────────────────────────────────────┐
│                 Richardson Maturity Model                    │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  Level 0: 单入口 (The Swamp of POX)                         │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ POST /api/service                                    │   │
│  │ Body: { "action": "getUser", "id": 1 }               │   │
│  │ → 所有操作都通过一个 URL + POST,协议当 RPC 用       │   │
│  │ → 典型:SOAP、XML-RPC                                │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  Level 1: 资源 (Resources)                                  │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ GET /api/users/1    → 有了资源的概念,URL 区分实体   │   │
│  │ POST /api/orders    → 但所有操作只用 GET 和 POST     │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  Level 2: HTTP 动词 (HTTP Verbs)                            │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ GET /api/users/1    → 查询                           │   │
│  │ POST /api/users     → 创建                           │   │
│  │ PUT /api/users/1    → 完整更新                        │   │
│  │ PATCH /api/users/1  → 部分更新                        │   │
│  │ DELETE /api/users/1 → 删除                            │   │
│  │ → 正确使用 HTTP 方法与状态码(多数"RESTful"API在这层)│   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  Level 3: HATEOAS (Hypermedia Controls)                     │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ {                                                    │   │
│  │   "id": 1, "name": "Alice",                          │   │
│  │   "_links": {                                        │   │
│  │     "self": "/users/1",                              │   │
│  │     "orders": "/users/1/orders",                     │   │
│  │     "deactivate": "/users/1/deactivate"               │   │
│  │   }                                                  │   │
│  │ }                                                    │   │
│  │ → 响应中包含可用的下一步操作链接                      │   │
│  │ → 客户端通过这些链接导航,而不是硬编码 URL           │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  现实中:绝大多数"RESTful API"停留在 Level 2              │
│  Level 3 更多出现在超媒体驱动的应用中(如 GitHub API)     │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

1.3 资源导向设计原则 ​

REST 的核心思想是把一切抽象为"资源"(Resource),用 URL 标识资源,用 HTTP 方法操作资源。

typescript
// --- 正确:资源 = 名词复数,操作 = HTTP 方法 ---
GET    /api/articles          // 文章列表
GET    /api/articles/42       // 单篇文章
POST   /api/articles          // 创建文章
PUT    /api/articles/42       // 完整替换文章
PATCH  /api/articles/42       // 部分更新文章
DELETE /api/articles/42       // 删除文章

// --- 子资源:用层级表达从属关系 ---
GET    /api/articles/42/comments          // 文章下的评论
GET    /api/articles/42/comments/7        // 文章的某条评论

// --- 反例:不要在 URL 中放动词 ---
// ❌ GET  /api/getArticles
// ❌ POST /api/createArticle
// ❌ POST /api/deleteArticle?id=42
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

1.4 统一接口:HTTP 方法的正确语义 ​

┌─────────────────────────────────────────────────────────────┐
│               HTTP 方法语义与幂等性                          │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  方法     │ 语义        │ 幂等 │ 安全 │ 请求体 │ 缓存     │
│  ─────────┼─────────────┼──────┼──────┼────────┼───────── │
│  GET      │ 查询资源     │  ✅  │  ✅  │  无    │  ✅      │
│  POST     │ 创建资源     │  ❌  │  ❌  │  有    │  ❌      │
│  PUT      │ 完整替换     │  ✅  │  ❌  │  有    │  ❌      │
│  PATCH    │ 部分更新     │  ⚠️  │  ❌  │  有    │  ❌      │
│  DELETE   │ 删除资源     │  ✅  │  ❌  │  可选  │  ❌      │
│  HEAD     │ 获取元信息   │  ✅  │  ✅  │  无    │  ✅      │
│  OPTIONS  │ 获取能力     │  ✅  │  ✅  │  无    │  ❌      │
│                                                             │
│  幂等:多次相同请求,服务器状态与执行一次相同                │
│  安全:不修改服务器状态(只读)                              │
│                                                             │
│  ⚠️  PATCH 的幂等性取决于具体实现:                          │
│  • JSON Merge Patch (RFC 7396): 大多数场景下幂等             │
│  • JSON Patch (RFC 6902): 可能不幂等(如 { "op": "add" })  │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
typescript
// 关键实践:POST 创建始终返回 201 + Location header
app.post('/api/articles', async (req, res) => {
  const article = await articleService.create(req.body);
  res
    .status(201)
    .setHeader('Location', `/api/articles/${article.id}`)
    .json({ data: article });
});

// PUT 的语义:客户端发送完整替换
// 缺失的字段应被重置为默认值或拒绝(取决于业务)
// 实践中大多用 PATCH 替代 PUT 进行更新

// DELETE 幂等实现:
// 第一次 → 204 No Content
// 第二次 → 404 Not Found(资源已不存在,仍是"删除"的结果状态)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

1.5 无状态性的现实取舍 ​

┌─────────────────────────────────────────────────────────────┐
│                    无状态 vs 有状态                           │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  纯无状态:                                                  │
│  • 每个请求自包含(认证 Token + 所有上下文)                 │
│  • 服务器不维护会话 → 任意实例可处理任意请求 → 水平扩展容易  │
│  • 代价:认证信息在每个请求中重复传输                        │
│                                                             │
│  现实妥协:                                                  │
│  • 认证状态交给 Token(JWT / opaque token),不交给服务器会话 │
│  • 业务状态(如购物车)交客户端或用独立的状态服务(Redis)   │
│  • 不要在 API 服务器内存中维护"用户登录状态"               │
│  • 不要把 API 服务器变成"会话亲和"(sticky session)       │
│                                                             │
│  判断标准:重启一个 API 实例后,客户端                        │
│  是否还能正常发起新请求?如果答案是"是",                    │
│  你的 API 就是无状态的。                                     │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

1.6 HATEOAS:理想与现实 ​

HATEOAS 是 Richardson Level 3 的核心特征——响应中携带可用操作的链接,客户端通过这些链接发现下一步可执行的操作。

typescript
// HATEOAS 响应示例
{
  "id": 42,
  "status": "pending_payment",
  "total": 99.00,
  "_links": {
    "self":     { "href": "/api/orders/42" },
    "pay":      { "href": "/api/orders/42/pay", "method": "POST" },
    "cancel":   { "href": "/api/orders/42/cancel", "method": "POST" },
    "items":    { "href": "/api/orders/42/items" }
  }
}

// 客户端不硬编码 URL,而是根据 _links 中的 rel 导航:
// if (order._links.pay) { await fetch(order._links.pay.href, ...) }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

HATEOAS 的实际地位:除少数大型公开 API(GitHub、Stripe、AWS)外,绝大多数 API 不实现 HATEOAS。原因有三:客户端仍然硬编码 URL 和业务逻辑(仅靠链接导航不够);增加了响应体积和序列化开销;前端生态(React/Vue)没有成熟的 HATEOAS 消费工具。不建议在普通项目强制推行 HATEOAS,但理解其思想能帮助你思考 API 的可发现性。


第2部分:URL 设计规范 ​

2.1 命名约定与风格 ​

typescript
// --- 基本规则 ---
// 1. 使用 kebab-case(SEO 友好,URL 可读性最佳)
GET /api/order-items          // ✅
GET /api/orderItems           // ❌ camelCase(不区分大小写可能出问题)
GET /api/order_items          // ❌ snake_case(不如短横线可读)

// 2. 资源名使用名词复数
GET /api/users                // ✅ 用户集合
GET /api/users/42             // ✅ 集合中的单个资源
GET /api/user                 // ❌ 单数(语义不一致)

// 3. 特殊情况:只能存在一个的资源用单数
GET /api/users/42/profile     // ✅ 每个用户只有一个 profile

// 4. 不要在末尾加斜杠
GET /api/users                // ✅
GET /api/users/               // ❌ 两个不同的 URL,造成混淆
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

2.2 嵌套深度控制 ​

┌─────────────────────────────────────────────────────────────┐
│                    资源嵌套深度控制                           │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  规则:嵌套最多 2 层                                         │
│                                                             │
│  ✅ 可接受:                                                │
│  GET /api/authors/42/articles          → 作者的文章         │
│  GET /api/authors/42/articles/7        → 作者的某篇文章     │
│  GET /api/articles/42/comments         → 文章的评论         │
│                                                             │
│  ❌ 过深:                                                  │
│  /api/authors/42/articles/7/comments/3/replies/1            │
│  → URL 臃肿,路由逻辑复杂,重构困难                         │
│                                                             │
│  解决方案:                                                 │
│  1. 用查询参数替代深层嵌套:                                │
│     GET /api/replies?comment_id=3                           │
│  2. 用独立端点访问关联资源:                                │
│     GET /api/replies/1 (通过 ID 直接访问)                 │
│  3. 当子资源可以独立存在时,给它们独立的顶层端点            │
│                                                             │
│  判断标准:如果某个子资源有自己的生命周期                   │
│  (可以脱离父资源存在),它应该有自己的顶层端点。           │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

2.3 查询参数设计 ​

typescript
// --- 过滤 (Filtering) ---
GET /api/articles?status=published&category=tech
// 等值过滤,多值用逗号
GET /api/articles?tags=javascript,typescript,nodejs

// --- 范围过滤 ---
GET /api/orders?created_at[gte]=2026-01-01&created_at[lte]=2026-07-01
GET /api/orders?total[gte]=100&total[lte]=500

// --- 排序 (Sorting) ---
GET /api/articles?sort=-created_at,+title
// + 升序(可省略),- 降序

// --- 字段选择 (Sparse Fieldsets) ---
GET /api/articles?fields=id,title,author,created_at
// 只返回需要的字段,减少响应体积

// --- 搜索 (Search) ---
GET /api/articles?q=restful+api+design
// q 参数表示全文搜索,与其他过滤参数独立

// --- 包含关联资源 (Embed/Include) ---
GET /api/articles?include=author,category
// 类似 GraphQL 的关联加载,避免 N+1

// 综合示例
GET /api/articles?status=published&category=tech&sort=-created_at&fields=id,title,summary&limit=20&offset=0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

2.4 批量操作设计模式 ​

REST 没有内置的批量操作规范,以下是两种主流方案:

typescript
// --- 方案1:查询参数指定批量(适合批量查询)---
GET /api/articles?id=1,2,3,4,5

// --- 方案2:专用批量端点(适合批量创建/更新/删除)---
POST /api/articles/batch
// Body: { "action": "create", "items": [...] }
// 返回: { "succeeded": [...], "failed": [{ "index": 2, "error": "..." }] }

// 关键设计原则:
// 1. 批量操作应部分成功——一个失败不影响其他
// 2. 返回结果中区分成功和失败项
// 3. 失败项包含索引,方便客户端定位
// 4. 批量大小需要限制(如最多 100 条),防止请求过大

type BatchResult<T> = {
  succeeded: T[];
  failed: Array<{ index: number; error: string; detail?: unknown }>;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

第3部分:分页全方案 ​

3.1 三种分页方案对比 ​

┌─────────────────────────────────────────────────────────────┐
│               分页方案全景对比                                │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  方案       │ 原理           │ 适用场景      │ 主要问题      │
│  ───────────┼────────────────┼───────────────┼───────────── │
│  Offset     │ ?page=2&       │ 传统 Web 页面  │ 数据变动导致  │
│  -based     │ limit=20       │ 管理后台      │ 重复/遗漏     │
│  ───────────┼────────────────┼───────────────┼───────────── │
│  Cursor     │ ?cursor=abc    │ 无限滚动      │ 无法跳到      │
│  -based     │ 返回 next 指针 │ 实时数据流    │ 任意页        │
│  ───────────┼────────────────┼───────────────┼───────────── │
│  Keyset     │ ?after_id=100  │ 增量同步      │ 只能按排序    │
│             │ &limit=20      │ 数据导出      │ 字段翻页      │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

3.2 Offset-based 分页 ​

typescript
// 请求:GET /api/articles?page=2&limit=20
// SQL:  SELECT * FROM articles ORDER BY id LIMIT 20 OFFSET 20

// 问题:数据变动导致分页漂移
// ┌─────────────────────────────────────────────────────────┐
// │  T1: 查询第1页 (LIMIT 20 OFFSET 0)  → id 1-20          │
// │  T2: 有人删除了 id 5                                    │
// │  T3: 查询第2页 (LIMIT 20 OFFSET 20) → 实际跳过了 id 21  │
// │  → id 21 永远不会出现在任何一页                          │
// └─────────────────────────────────────────────────────────┘

// 标准响应格式
interface PaginatedResponse<T> {
  data: T[];
  pagination: {
    page: number;          // 当前页码
    limit: number;         // 每页条数
    total: number;         // 总记录数
    totalPages: number;    // 总页数
    hasNext: boolean;      // 是否有下一页
    hasPrev: boolean;      // 是否有上一页
  };
}

// ⚠️ SELECT count(*) FROM ... 在百万级表中可能成为性能瓶颈
// 缓解策略:超过一定阈值后不返回 total,或使用估算值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

3.3 Cursor-based 分页 ​

Cursor-based 分页用上一页最后一条记录的指针(cursor)请求下一页,天然免疫数据变动问题。

typescript
// 请求:GET /api/articles?cursor=eyJpZCI6NTB9&limit=20
// cursor 通常是 base64 编码的指针,如 { "id": 50 }

// 实现(Express + TypeScript)
interface CursorParams {
  cursor?: string;   // base64 编码的游标
  limit: number;     // 默认 20,最大 100
}

interface CursorResponse<T> {
  data: T[];
  pagination: {
    limit: number;
    hasNext: boolean;
    nextCursor: string | null; // 下一页的游标
  };
}

function encodeCursor(id: number): string {
  return Buffer.from(JSON.stringify({ id })).toString('base64url');
}

function decodeCursor(cursor: string): number {
  const { id } = JSON.parse(Buffer.from(cursor, 'base64url').toString());
  return id;
}

// SQL: SELECT * FROM articles WHERE id > :cursorId ORDER BY id ASC LIMIT :limit + 1
// 多取一条 (limit + 1) 来判断 hasNext
// 如果结果数 > limit,则 hasNext = true,否则 false

// cursor 方案的局限:
// 1. 无法直接跳到第 N 页(没有"总页数"概念)
// 2. 只能沿排序方向翻页(往前或往后)
// 3. 排序字段必须形成确定的、唯一的序列
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

3.4 Keyset Pagination(也被称为 "seek method") ​

typescript
// 比 cursor 更透明,直接用排序字段的值作为翻页依据
// GET /api/articles?after_id=100&limit=20
// GET /api/articles?after_created_at=2026-06-01T00:00:00Z&after_id=100&limit=20

// SQL: SELECT * FROM articles
//      WHERE (created_at, id) > (:afterCreatedAt, :afterId)
//      ORDER BY created_at ASC, id ASC
//      LIMIT :limit

// 优势:
// 1. URL 可读性更好
// 2. 客户端可以构造请求(知道上一页的最后一条记录)
// 3. 天然免疫数据插入/删除导致的重复/遗漏

// 局限:
// 1. 只能沿排序方向翻页
// 2. 排序字段必须在 WHERE 子句中有可用索引
// 3. 如果排序字段可能重复,必须引入 tiebreaker(如 id)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

3.5 Link Header(RFC 8288)与分页元数据 ​

typescript
// Link Header 方式 —— GitHub API 风格
// Response Header:
// Link: <https://api.example.com/articles?page=3>; rel="next",
//       <https://api.example.com/articles?page=1>; rel="prev",
//       <https://api.example.com/articles?page=1>; rel="first",
//       <https://api.example.com/articles?page=10>; rel="last"

// 设置 Link Header 的辅助函数
function setPaginationLinks(
  res: Response,
  baseUrl: string,
  page: number,
  totalPages: number
): void {
  const links: string[] = [];

  if (page < totalPages) {
    links.push(`<${baseUrl}?page=${page + 1}>; rel="next"`);
  }
  if (page > 1) {
    links.push(`<${baseUrl}?page=${page - 1}>; rel="prev"`);
  }
  links.push(`<${baseUrl}?page=1>; rel="first"`);
  links.push(`<${baseUrl}?page=${totalPages}>; rel="last"`);

  res.setHeader('Link', links.join(', '));
}

// 推荐:同时返回 body 元数据 + Link Header
// Header 用于机器解析(如 API 客户端),body 用于人类可读
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

3.6 分页方案选择决策树 ​

┌─────────────────────────────────────────────────────────────┐
│                    分页方案选择决策                           │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  你的数据是否频繁增删?                                      │
│    ├── 是 → Cursor-based 或 Keyset pagination               │
│    │   → 用户是否需要跳转到任意页?                          │
│    │       ├── 是 → Keyset pagination + 前端虚拟滚动        │
│    │       └── 否 → Cursor-based(如无限滚动 Feed)         │
│    │                                                        │
│    └── 否(数据相对稳定,如管理后台)                        │
│        → 用户是否需要看到"总页数"?                          │
│            ├── 是 → Offset-based + 分页组件                 │
│            └── 否 → Keyset pagination(性能更好)           │
│                                                             │
│  数据量极大(千万级以上)?                                  │
│    → 不要用 count(*) → Keyset pagination                    │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

第4部分:API 版本化 ​

4.1 四种版本化策略 ​

┌─────────────────────────────────────────────────────────────┐
│                    API 版本化策略对比                         │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  策略             │ 示例                  │ 优点 / 缺点      │
│  ─────────────────┼───────────────────────┼───────────────── │
│  URL 路径前缀     │ /api/v1/articles      │ 直观、易调试     │
│                   │                       │ 路由冗余         │
│  ─────────────────┼───────────────────────┼───────────────── │
│  Accept Header    │ Accept:              │ 纯正的 REST      │
│  (Content-Type)   │ application/         │ 调试不方便       │
│                   │ vnd.myapp.v2+json     │ 缓存键复杂       │
│  ─────────────────┼───────────────────────┼───────────────── │
│  Query 参数       │ /api/articles        │ 简单             │
│                   │ ?version=2            │ 容易被忽略       │
│  ─────────────────┼───────────────────────┼───────────────── │
│  自定义 Header    │ X-API-Version: 2      │ 灵活             │
│                   │                       │ 非标准           │
│                                                             │
│  推荐:URL 前缀 (/api/v1/...) + 内部路由转发                 │
│  原因:最直观、CDN 友好、调试方便、生态工具支持最好          │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

4.2 版本化实现策略 ​

typescript
// --- 路由前缀方式 ---
// 不同版本映射到不同 Router
import { Router } from 'express';

const v1Router = Router();
v1Router.get('/articles', v1ArticleController.list);
v1Router.get('/articles/:id', v1ArticleController.get);

const v2Router = Router();
v2Router.get('/articles', v2ArticleController.list);  // 返回新字段结构
v2Router.get('/articles/:id', v2ArticleController.get);

app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);

// --- 内部代码复用:v2 复用 v1 的 Service 层 ---
// Controller v2 可以调用同一个 ArticleService,
// 只修改响应映射逻辑(DTO),不重复业务代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

4.3 兼容性承诺与废弃策略 ​

typescript
// 废弃时间线模板:
// T+0:   发布 v2,v1 标记为 deprecated
// T+3m:  发送 Sunset header,提醒仍在使用 v1 的客户端
// T+6m:  v1 仅返回 410 Gone(或仅限白名单客户端)
// T+12m: 下线 v1

// Sunset Header (RFC 8594)
app.use('/api/v1', (req, res, next) => {
  // 在所有 v1 响应中附加废弃提醒
  res.setHeader('Sunset', 'Sat, 01 Jan 2027 00:00:00 GMT');
  res.setHeader(
    'Deprecation',
    'true'
  );
  res.setHeader(
    'Link',
    '</api/v2/articles>; rel="successor-version"'
  );
  next();
});

// --- 版本兼容性检查清单 ---
// 1. 是否有客户端在使用旧版本?(检查日志/监控)
// 2. 新版本是否包含旧版本的所有必要数据?
// 3. 废弃通知是否已经发送?(邮件/文档/Banner/Sunset header)
// 4. 有没有内部服务调用旧 API?(最容易遗漏)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

4.4 什么时候需要新版本? ​

并非所有改动都需要新版本号。区分破坏性变更和非破坏性变更:

破坏性变更(需要新版本):        非破坏性变更(不需要新版本):
├── 删除字段或端点                ├── 新增端点
├── 修改字段类型                  ├── 新增可选字段
├── 修改字段语义                  ├── 新增可选查询参数
├── 修改认证方式                  ├── 放宽速率限制
├── 修改错误响应结构              ├── 支持新的 Content-Type
└── 修改分页默认值                └── 新增 Link Header
1
2
3
4
5
6
7

第5部分:条件请求与缓存 ​

5.1 ETag 与 If-None-Match ​

┌─────────────────────────────────────────────────────────────┐
│               ETag 条件请求流程                               │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  客户端                           服务器                    │
│  ────────                         ────────                  │
│  GET /articles/42 ─────────────→  计算 ETag: "abc123"      │
│                   ←─────────────  200 OK                    │
│                                    ETag: "abc123"           │
│                                    Body: { ... }            │
│                                                             │
│  再次请求同一资源:                                          │
│  GET /articles/42 ─────────────→  比较 ETag                 │
│  If-None-Match: "abc123"          "abc123" === "abc123"?   │
│                   ←─────────────  304 Not Modified          │
│  使用本地缓存                      Body 为空                 │
│                                                             │
│  优势:                                                     │
│  • 节省带宽(304 响应体为空)                               │
│  • 节省服务器 CPU(跳过后端查询和序列化)                   │
│  • 客户端可以用任何缓存策略(内存、文件、CDN)             │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
typescript
// ETag 实现(Express)
import crypto from 'crypto';

function generateETag(data: unknown): string {
  // 对响应体的 JSON 进行 hash
  // 弱 ETag 以 W/ 开头:W/"abc123"
  const hash = crypto
    .createHash('md5')
    .update(JSON.stringify(data))
    .digest('hex');
  return `"${hash}"`;
}

app.get('/api/articles/:id', async (req, res) => {
  const article = await articleService.getById(req.params.id);
  if (!article) return res.status(404).json({ error: 'Not Found' });

  const responseBody = { data: article };
  const etag = generateETag(responseBody);

  // 检查客户端是否有最新版本
  if (req.headers['if-none-match'] === etag) {
    return res.status(304).end(); // 无 body
  }

  res.setHeader('ETag', etag);
  res.json(responseBody);
});

// Last-Modified 方式(精度较低,秒级,作为 ETag 的 fallback)
app.get('/api/articles/:id', async (req, res) => {
  const article = await articleService.getById(req.params.id);
  if (!article) return res.status(404).json({ error: 'Not Found' });

  const lastModified = article.updatedAt.toUTCString();
  res.setHeader('Last-Modified', lastModified);

  if (req.headers['if-modified-since'] === lastModified) {
    return res.status(304).end();
  }

  res.json({ data: article });
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

5.2 Cache-Control 策略设计 ​

typescript
// --- 不同端点的缓存策略 ---

// 公开的、不常变的数据:缓存 1 小时
// GET /api/articles(公开文章列表)
res.setHeader('Cache-Control', 'public, max-age=3600');

// 公开的、但需要重新验证的数据:
// GET /api/articles/42(单篇文章,可能被编辑)
res.setHeader('Cache-Control', 'public, max-age=300, must-revalidate');

// 私有的、用户相关的数据:禁止共享缓存
// GET /api/users/me/profile
res.setHeader('Cache-Control', 'private, max-age=0, no-cache');
// no-cache:可以缓存,但每次使用前必须向服务器验证(配合 ETag)

// 敏感数据:完全禁止缓存
// GET /api/users/me/payment-methods
res.setHeader('Cache-Control', 'no-store');

// 不可变的静态资源(带版本 hash 的)
// GET /assets/app-a1b2c3.js
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');

// --- Cache-Control 指令速查 ---
// public           → 可被 CDN、代理缓存
// private          → 仅浏览器缓存,CDN 不缓存
// max-age=N        → 缓存 N 秒后过期
// must-revalidate  → 过期后必须向服务器验证
// no-cache         → 可缓存但每次使用前验证
// no-store         → 完全不可缓存
// immutable        → 资源内容永不改变
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

第6部分:错误处理规范 ​

6.1 RFC 7807 Problem Details ​

┌─────────────────────────────────────────────────────────────┐
│                 RFC 7807 Problem Details                     │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  统一错误响应的最小结构:                                    │
│  {                                                          │
│    "type": "https://api.example.com/errors/validation",     │
│    "title": "Validation Error",                             │
│    "status": 422,                                           │
│    "detail": "The 'email' field must be a valid email",    │
│    "instance": "/api/users/register"                        │
│  }                                                          │
│                                                             │
│  type     → 错误类型 URI(可点击查看文档)                  │
│  title    → 人类可读的错误标题                              │
│  status   → HTTP 状态码                                     │
│  detail   → 针对本次请求的详细错误描述                      │
│  instance → 发生错误的请求路径                              │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

6.2 生产级错误响应设计 ​

typescript
// --- 错误码体系 ---
enum ErrorCode {
  // 通用 4xx
  VALIDATION_ERROR       = 'VALIDATION_ERROR',        // 400/422
  UNAUTHORIZED           = 'UNAUTHORIZED',             // 401
  FORBIDDEN              = 'FORBIDDEN',                // 403
  NOT_FOUND              = 'NOT_FOUND',                 // 404
  CONFLICT               = 'CONFLICT',                  // 409
  RATE_LIMITED           = 'RATE_LIMITED',             // 429

  // 业务 4xx
  USER_ALREADY_EXISTS    = 'USER_ALREADY_EXISTS',
  INSUFFICIENT_BALANCE   = 'INSUFFICIENT_BALANCE',
  ORDER_ALREADY_SHIPPED  = 'ORDER_ALREADY_SHIPPED',
  INVALID_CREDENTIALS    = 'INVALID_CREDENTIALS',
  TOKEN_EXPIRED          = 'TOKEN_EXPIRED',

  // 5xx
  INTERNAL_ERROR         = 'INTERNAL_ERROR',
  SERVICE_UNAVAILABLE    = 'SERVICE_UNAVAILABLE',
  DATABASE_ERROR         = 'DATABASE_ERROR',
}

// --- 统一错误响应格式 ---
interface ApiErrorResponse {
  success: false;
  error: {
    code: ErrorCode;
    message: string;           // 用户可读的消息
    details?: ValidationErrorDetail[]; // 验证错误的详细字段信息
    requestId: string;         // 用于追踪和排查
    timestamp: string;         // ISO 8601
  };
}

interface ValidationErrorDetail {
  field: string;               // 哪个字段出错
  message: string;             // 具体错误原因
  code: string;                // 验证规则代码
  received?: unknown;          // 客户端发送的值(调试用,生产可省略)
}

// --- Express 错误处理中间件 ---
class AppError extends Error {
  constructor(
    public statusCode: number,
    public code: ErrorCode,
    message: string,
    public details?: ValidationErrorDetail[]
  ) {
    super(message);
  }
}

function errorHandler(err: Error, req: Request, res: Response, _next: NextFunction) {
  const requestId = req.headers['x-request-id'] as string || 'unknown';

  if (err instanceof AppError) {
    return res.status(err.statusCode).json({
      success: false,
      error: {
        code: err.code,
        message: err.message,
        details: err.details,
        requestId,
        timestamp: new Date().toISOString(),
      },
    });
  }

  // 未知错误:不泄露内部详情
  console.error(`[${requestId}] Unhandled error:`, err);
  return res.status(500).json({
    success: false,
    error: {
      code: ErrorCode.INTERNAL_ERROR,
      message: 'An unexpected error occurred',
      requestId,
      timestamp: new Date().toISOString(),
    },
  });
}

// --- 验证错误示例(配合 Zod)---
import { z } from 'zod';

const createArticleSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1),
  tags: z.array(z.string()).max(10).optional(),
});

function validate(schema: z.ZodSchema) {
  return (req: Request, _res: Response, next: NextFunction) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      const details: ValidationErrorDetail[] = result.error.issues.map(issue => ({
        field: issue.path.join('.'),
        message: issue.message,
        code: issue.code,
        received: issue.path.reduce((obj: any, key) => obj?.[key], req.body),
      }));
      throw new AppError(422, ErrorCode.VALIDATION_ERROR, 'Validation failed', details);
    }
    req.body = result.data;
    next();
  };
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108

第7部分:长操作与异步模式 ​

7.1 同步 vs 异步 API 的决策 ​

┌─────────────────────────────────────────────────────────────┐
│              长操作处理决策树                                 │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  操作预计耗时?                                              │
│    ├── < 200ms → 同步返回(标准 REST)                      │
│    │   POST /api/orders → 201 Created                       │
│    │                                                        │
│    ├── 200ms ~ 2s → 同步 + 合理的超时配置                   │
│    │   确保客户端和网关的超时设置充足                        │
│    │                                                        │
│    └── > 2s 或不确定 → 异步模式                             │
│        ├── 客户端需要实时进度? → SSE                       │
│        ├── 客户端只关心最终结果? → 202 + 轮询              │
│        └── 需要通知多个系统? → Webhook 回调                │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

7.2 202 Accepted + Location 轮询 ​

typescript
// 模式:POST 创建异步任务,返回 202 + 状态查询端点
// POST /api/exports → 202 Accepted
//   Location: /api/exports/status/abc-123
//   Body: { "status": "processing", "statusUrl": "/api/exports/status/abc-123" }

// GET /api/exports/status/abc-123 → 200 OK
//   { "status": "completed", "resultUrl": "/api/exports/download/abc-123" }
//   或
//   { "status": "processing", "progress": 65, "estimatedSeconds": 30 }
//   或
//   { "status": "failed", "error": { "code": "...", "message": "..." } }

// --- 实现 ---
interface AsyncTask {
  id: string;
  status: 'processing' | 'completed' | 'failed';
  progress?: number;         // 0-100
  estimatedSeconds?: number; // 预计剩余时间
  resultUrl?: string;        // 完成后可获取结果的 URL
  error?: { code: string; message: string };
}

app.post('/api/exports', async (req, res) => {
  const taskId = crypto.randomUUID();
  // 将任务放入队列,后台处理
  await taskQueue.add('export', { taskId, params: req.body });
  res.status(202)
    .setHeader('Location', `/api/exports/status/${taskId}`)
    .json({
      status: 'processing',
      taskId,
      statusUrl: `/api/exports/status/${taskId}`,
    });
});

app.get('/api/exports/status/:taskId', async (req, res) => {
  const task = await taskStore.get(req.params.taskId);
  if (!task) return res.status(404).json({ error: 'Task not found' });

  // 完成后可以返回 303 See Other 重定向到结果 URL
  if (task.status === 'completed') {
    res.setHeader('Location', task.resultUrl!);
    return res.status(303).json(task);
  }

  // 仍在处理中
  res.status(200).json(task);
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48

7.3 Webhook 回调模式 ​

typescript
// 客户端在发起请求时提供一个回调 URL
// POST /api/exports
// Body: {
//   "filters": { "startDate": "2026-01-01" },
//   "webhookUrl": "https://client.example.com/hooks/export-ready"
// }

// 服务器处理完成后,POST 到 webhookUrl:
// POST https://client.example.com/hooks/export-ready
// Header: X-Signature: sha256=...  (HMAC 签名,防伪造)
// Body: { "taskId": "abc-123", "status": "completed", "resultUrl": "..." }

// Webhook 设计要点:
// 1. 签名验证:用共享密钥对 payload 做 HMAC-SHA256 签名
// 2. 重试策略:失败后指数退避重试(1s, 5s, 25s, 125s...),最多 N 次
// 3. 幂等消费:taskId 去重,防止重复处理
// 4. 顺序保证:同一 taskId 的事件可能乱序到达,靠 version/sequence 排序

interface WebhookPayload {
  eventId: string;          // 事件唯一 ID(去重用)
  taskId: string;
  eventType: string;        // "export.completed" | "export.failed"
  timestamp: string;
  data: Record<string, unknown>;
}

// 验证 Webhook 签名的 Express 中间件
function verifyWebhook(req: Request, res: Response, next: NextFunction) {
  const signature = req.headers['x-signature'] as string;
  const payload = JSON.stringify(req.body);
  const computed = crypto
    .createHmac('sha256', process.env.WEBHOOK_SECRET!)
    .update(payload)
    .digest('hex');

  if (!crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(`sha256=${computed}`)
  )) {
    return res.status(401).json({ error: 'Invalid signature' });
  }
  next();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

7.4 Server-Sent Events 进度推送 ​

typescript
// SSE 适合:长时间任务需要向客户端实时推送进度
// 对比 WebSocket:SSE 单向(服务器→客户端),更简单,HTTP 原生支持

app.get('/api/exports/:taskId/progress', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('X-Accel-Buffering', 'no'); // 禁用 nginx 缓冲

  const taskId = req.params.taskId;

  // 定期查询进度并推送
  const interval = setInterval(async () => {
    const task = await taskStore.get(taskId);
    if (!task) {
      res.write(`event: error\ndata: ${JSON.stringify({ error: 'Task not found' })}\n\n`);
      clearInterval(interval);
      return res.end();
    }

    res.write(`data: ${JSON.stringify({
      status: task.status,
      progress: task.progress,
      estimatedSeconds: task.estimatedSeconds,
    })}\n\n`);

    if (task.status === 'completed' || task.status === 'failed') {
      clearInterval(interval);
      res.end();
    }
  }, 2000); // 每 2 秒推送一次

  // 客户端断开连接时清理
  req.on('close', () => {
    clearInterval(interval);
  });
});

// 客户端消费(浏览器):
// const source = new EventSource('/api/exports/abc-123/progress');
// source.onmessage = (e) => {
//   const data = JSON.parse(e.data);
//   updateProgressBar(data.progress);
// };
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

第8部分:GraphQL 与 tRPC 定位 ​

8.1 三种方案的比较框架 ​

┌─────────────────────────────────────────────────────────────┐
│            REST vs GraphQL vs tRPC 全景对比                   │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  维度           │ REST         │ GraphQL      │ tRPC         │
│  ───────────────┼──────────────┼──────────────┼───────────── │
│  类型安全       │ 需额外工具   │ 代码生成     │ ✅ 端到端    │
│                 │ (OpenAPI)    │ (GraphQL     │ (编译时)     │
│                 │              │  Codegen)    │              │
│  ───────────────┼──────────────┼──────────────┼───────────── │
│  缓存           │ ✅ HTTP 原生 │ ❌ 需要       │ ❌ 无原生    │
│                 │ (CDN/Browser)│ persisted    │ 缓存支持     │
│                 │              │ queries 等   │              │
│  ───────────────┼──────────────┼──────────────┼───────────── │
│  字段按需获取   │ ❌ 需手动    │ ✅ 核心特性  │ ✅ 编译时    │
│                 │ (sparse      │ (请求指定    │ (类型推导    │
│                 │  fieldsets)  │  字段集合)   │  自动映射)   │
│  ───────────────┼──────────────┼──────────────┼───────────── │
│  工具链         │ ✅ 最成熟    │ ⚠️ 生态丰富  │ ❌ 仅 TS     │
│                 │ (Postman,    │ (Apollo,     │ 生态         │
│                 │  curl, CDN)  │  Relay)      │              │
│  ───────────────┼──────────────┼──────────────┼───────────── │
│  学习成本       │ ✅ 低        │ ⚠️ 中等      │ ✅ 低        │
│                 │ (HTTP 基础)  │ (Schema语言) │ (TS 即可)    │
│  ───────────────┼──────────────┼──────────────┼───────────── │
│  适用场景       │ 公开 API     │ 多客户端     │ 全栈 TS      │
│                 │ BFF / 微服务 │ 复杂数据图   │ Monorepo     │
│  ───────────────┼──────────────┼──────────────┼───────────── │
│  版本化         │ ✅ 多种策略  │ ⚠️ 不鼓励    │ ❌ N/A       │
│                 │              │ (用deprecated│ (随代码演进) │
│                 │              │ + 渐进演进)  │              │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

8.2 GraphQL 的 N+1 问题与复杂度控制 ​

typescript
// N+1 问题示例:
// Query: { articles { id title author { name } } }
// 结果:1 次查询 articles,每条 article 再查 1 次 author
// 100 篇文章 = 101 次数据库查询

// 解决方案:DataLoader 批处理
import DataLoader from 'dataloader';

const authorLoader = new DataLoader(async (authorIds: readonly number[]) => {
  // 将所有 authorId 合并为一次查询
  const authors = await db.query(
    'SELECT * FROM authors WHERE id = ANY($1)',
    [authorIds]
  );
  // DataLoader 要求按输入顺序返回结果
  const authorMap = new Map(authors.map(a => [a.id, a]));
  return authorIds.map(id => authorMap.get(id));
});

// 在 resolver 中使用 loader:
// author: (article) => authorLoader.load(article.authorId)

// GraphQL 复杂度控制策略:
// 1. 查询深度限制:graphql-depth-limit (max depth = 7)
// 2. 查询复杂度评分:graphql-query-complexity
//    → 为每个字段分配复杂度权重(标量=1,列表=10,嵌套×5)
//    → 限制单次查询的最大复杂度
// 3. 节点数量限制:限制返回的节点总数
// 4. 查询超时:设置查询执行的最大时间
// 5. 持久化查询 (persisted queries):只允许预注册的查询
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

8.3 tRPC 的全栈类型安全 ​

typescript
// tRPC 的核心价值:编译器保证前端调用后端的类型一致性
// 不需要手动维护 API 类型定义或生成 SDK

// --- 后端定义 ---
import { initTRPC } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.create();

export const appRouter = t.router({
  article: t.router({
    list: t.procedure
      .input(z.object({
        status: z.enum(['draft', 'published']).optional(),
        limit: z.number().min(1).max(100).default(20),
      }))
      .query(({ input }) => {
        // input 的类型自动推导为 { status?: 'draft' | 'published', limit: number }
        return articleService.list(input);
      }),

    create: t.procedure
      .input(z.object({
        title: z.string().min(1).max(200),
        content: z.string().min(1),
      }))
      .mutation(({ input }) => {
        return articleService.create(input);
      }),
  }),
});

export type AppRouter = typeof appRouter;

// --- 前端调用(类型安全)---
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server';

const trpc = createTRPCReact<AppRouter>();

// 前端直接享受编译时类型检查和自动补全
function ArticleList() {
  const { data, isLoading } = trpc.article.list.useQuery({
    status: 'published',  // 类型安全:只能是 'draft' | 'published'
    limit: 20,
  });
  // data 的类型自动推导为 list 过程的返回值类型
}

// tRPC 的最佳使用场景:
// ✅ 全栈 TypeScript monorepo(前后端共享类型)
// ✅ 小到中型团队,不需要多语言客户端
// ✅ BFF 层(为特定前端定制的后端)
// 
// 不适合:
// ❌ 需要公开 API 给第三方
// ❌ 团队需要多语言客户端(Python/Go/Kotlin 等)
// ❌ 已有成熟的 REST 基础设施(CDN/网关/缓存层)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58

8.4 选型建议 ​

┌─────────────────────────────────────────────────────────────┐
│                API 技术选型决策矩阵                           │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  你的需求是……                                               │
│                                                             │
│  对外公开 API(第三方集成)                                  │
│    → REST + OpenAPI(可缓存、可版本化、多语言客户端友好)   │
│                                                             │
│  多客户端(iOS/Android/Web + 不同数据需求)                  │
│    → GraphQL(客户端按需获取、避免 over-fetching)          │
│    → 或 REST + sparse fieldsets(简单场景)                 │
│                                                             │
│  全栈 TypeScript + Monorepo                                 │
│    → tRPC(零模板代码、编译时安全、开发体验最好)           │
│                                                             │
│  内部微服务间通信                                            │
│    → gRPC(高性能二进制协议)+ REST(调试友好)              │
│                                                             │
│  需要极致的 CDN 缓存                                        │
│    → REST(GET 请求天然适配 HTTP 缓存基础设施)             │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

第9部分:OpenAPI 规范 ​

9.1 OpenAPI 3.1 核心结构 ​

yaml
# OpenAPI 3.1.0
openapi: "3.1.0"
info:
  title: "文章管理 API"
  version: "1.0.0"
  description: "文章的创建、查询、更新与删除"
  contact:
    name: "API Team"
    email: "api@example.com"

servers:
  - url: "https://api.example.com/v1"
    description: "生产环境"
  - url: "https://staging-api.example.com/v1"
    description: "预发布环境"

paths:
  /articles:
    get:
      summary: "获取文章列表"
      operationId: listArticles
      tags: [Articles]
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [draft, published, archived]
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: cursor
          in: query
          schema:
            type: string
          description: "分页游标(cursor-based pagination)"
      responses:
        "200":
          description: "文章列表"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ArticleListResponse"
        "400":
          $ref: "#/components/responses/ValidationError"

    post:
      summary: "创建文章"
      operationId: createArticle
      tags: [Articles]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateArticleRequest"
      responses:
        "201":
          description: "文章创建成功"
          headers:
            Location:
              schema:
                type: string
              description: "新文章的 URL"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Article"
        "422":
          $ref: "#/components/responses/ValidationError"

  /articles/{articleId}:
    get:
      summary: "获取单篇文章"
      operationId: getArticle
      tags: [Articles]
      parameters:
        - name: articleId
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: "文章详情"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Article"
        "404":
          $ref: "#/components/responses/NotFound"

components:
  schemas:
    Article:
      type: object
      required: [id, title, content, status, createdAt]
      properties:
        id:
          type: integer
        title:
          type: string
          maxLength: 200
        content:
          type: string
        status:
          type: string
          enum: [draft, published, archived]
        tags:
          type: array
          items:
            type: string
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    CreateArticleRequest:
      type: object
      required: [title, content]
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 200
        content:
          type: string
          minLength: 1
        tags:
          type: array
          items:
            type: string
          maxItems: 10

    ErrorResponse:
      type: object
      required: [success, error]
      properties:
        success:
          type: boolean
          enum: [false]
        error:
          $ref: "#/components/schemas/ErrorDetail"

    ErrorDetail:
      type: object
      required: [code, message, requestId, timestamp]
      properties:
        code:
          type: string
        message:
          type: string
        details:
          type: array
          items:
            $ref: "#/components/schemas/ValidationErrorItem"
        requestId:
          type: string
        timestamp:
          type: string
          format: date-time

    ValidationErrorItem:
      type: object
      properties:
        field:
          type: string
        message:
          type: string
        code:
          type: string

  responses:
    ValidationError:
      description: "请求参数验证失败"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    NotFound:
      description: "资源不存在"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191

9.2 从代码生成 OpenAPI Spec ​

typescript
// -- 方案1:zod-to-openapi(库无关,适合 Express/Fastify)--
import { z } from 'zod';
import { OpenAPIRegistry, OpenApiGeneratorV31 } from '@asteasolutions/zod-to-openapi';

const registry = new OpenAPIRegistry();

registry.registerPath({
  method: 'get',
  path: '/api/articles',
  summary: '获取文章列表',
  tags: ['Articles'],
  request: {
    query: z.object({
      status: z.enum(['draft', 'published']).optional(),
      limit: z.number().min(1).max(100).default(20),
    }),
  },
  responses: {
    200: {
      description: '文章列表',
      content: {
        'application/json': {
          schema: z.object({
            data: z.array(ArticleSchema),
            pagination: PaginationSchema,
          }),
        },
      },
    },
  },
});

const generator = new OpenApiGeneratorV31(registry.definitions);
const openApiDoc = generator.generateDocument({
  openapi: '3.1.0',
  info: { title: 'My API', version: '1.0.0' },
  servers: [{ url: 'https://api.example.com/v1' }],
});

// -- 方案2:Fastify + @fastify/swagger ---
import fastifySwagger from '@fastify/swagger';
import fastifySwaggerUi from '@fastify/swagger-ui';

await fastify.register(fastifySwagger, {
  openapi: {
    info: { title: 'My API', version: '1.0.0' },
  },
});

// Fastify 从 JSON Schema 自动生成 OpenAPI 文档
fastify.get('/api/articles', {
  schema: {
    querystring: {
      type: 'object',
      properties: {
        status: { type: 'string', enum: ['draft', 'published'] },
        limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
      },
    },
    response: {
      200: { /* response schema */ },
    },
  },
}, async (request, reply) => {
  return { data: await articleService.list(request.query) };
});

await fastify.register(fastifySwaggerUi, {
  routePrefix: '/docs',
});

// -- 方案3:NestJS + @nestjs/swagger ---
import { ApiProperty, ApiTags } from '@nestjs/swagger';

class CreateArticleDto {
  @ApiProperty({ minLength: 1, maxLength: 200 })
  title: string;

  @ApiProperty({ minLength: 1 })
  content: string;

  @ApiProperty({ type: [String], maxItems: 10, required: false })
  tags?: string[];
}

@ApiTags('Articles')
@Controller('articles')
class ArticleController {
  @Post()
  @ApiCreatedResponse({ type: Article })
  create(@Body() dto: CreateArticleDto) { /* ... */ }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92

9.3 交互式文档工具 ​

文档工具选择:
┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  Swagger UI                                                  │
│  → 生态最成熟,功能齐全(Try It Out / Auth / 示例)         │
│  → 缺点:UI 较旧,大型 spec 加载较慢                        │
│                                                             │
│  Scalar (https://scalar.com)                                │
│  → 现代化 UI,加载速度快,支持暗色主题                      │
│  → 支持多种 API 规范(OpenAPI / GraphQL / Postman)        │
│  → 推荐用于新项目                                           │
│                                                             │
│  Redoc                                                      │
│  → 只读文档,渲染美观,适合公开发布                         │
│  → 不支持 Try It Out                                        │
│                                                             │
│  Postman                                                    │
│  → 完整的 API 开发平台,导入 OpenAPI spec                   │
│  → 适合团队协作和手动测试                                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

第10部分:API 安全设计 ​

10.1 认证方案对比 ​

┌─────────────────────────────────────────────────────────────┐
│                  认证方案对比                                 │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  方案           │ 原理            │ 适用场景                 │
│  ───────────────┼─────────────────┼───────────────────────── │
│  Bearer Token   │ Authorization:   │ SPA、移动端、API 到 API │
│  (JWT)          │ Bearer <jwt>     │ 无状态、自包含声明      │
│  ───────────────┼─────────────────┼───────────────────────── │
│  API Key        │ X-API-Key: ...   │ 服务间调用、简单的      │
│                 │                  │ 机器对机器通信           │
│  ───────────────┼─────────────────┼───────────────────────── │
│  OAuth 2.0      │ 授权码流程       │ 第三方应用授权、        │
│                 │ + 令牌刷新       │ 用户委托授权             │
│  ───────────────┼─────────────────┼───────────────────────── │
│  Session Cookie │ Cookie: sid=...  │ 传统服务端渲染、        │
│                 │                  │ B2C Web 应用             │
│                                                             │
│  对于 REST API:优先选择 Bearer Token (JWT)                 │
│  → 无状态、跨域友好、适合移动端                             │
│  → 注意:JWT 不适合做"会话黑名单"——要用短期 access token   │
│     + 长期 refresh token 的组合                             │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

10.2 CORS 策略设计 ​

typescript
import cors from 'cors';

// ❌ 反模式:允许所有来源
// app.use(cors({ origin: '*' }));

// ✅ 白名单策略
const allowedOrigins = [
  'https://myapp.com',
  'https://admin.myapp.com',
  ...(process.env.NODE_ENV === 'development'
    ? ['http://localhost:3000', 'http://localhost:5173']
    : []
  ),
];

app.use(cors({
  origin: (origin, callback) => {
    // 允许无 origin 的请求(如 Postman、curl、服务端调用)
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
  exposedHeaders: ['Link', 'X-RateLimit-Remaining', 'X-RateLimit-Reset'],
  credentials: true,           // 允许携带 Cookie 和 Authorization header
  maxAge: 86400,               // 预检请求缓存 24 小时
}));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

10.3 Rate Limiting 策略 ​

typescript
// --- 多层限流策略 ---

// 第1层:全局限流(防止系统过载)
// 每秒总请求数上限
// 使用 Redis 滑动窗口或 Token Bucket

// 第2层:按 IP/User 限流(防止单用户滥用)
// 每用户每分钟 60 次请求

// 第3层:按端点限流(保护重资源端点)
// POST /api/exports 每用户每小时 10 次

// 示例:使用 express-rate-limit + Redis store
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';

const globalLimiter = rateLimit({
  store: new RedisStore({
    prefix: 'rl:global:',
    // @ts-expect-error - rate-limit-redis types
    sendCommand: (...args: string[]) => redisClient.call(...args),
  }),
  windowMs: 60 * 1000,  // 1 分钟
  max: 600,              // 每窗口 600 次请求
  standardHeaders: true, // 返回 RateLimit-* 头
  legacyHeaders: false,  // 禁用 X-RateLimit-* 头
  message: {
    success: false,
    error: {
      code: 'RATE_LIMITED',
      message: 'Too many requests, please try again later',
    },
  },
});

const authLimiter = rateLimit({
  store: new RedisStore({
    prefix: 'rl:auth:',
    sendCommand: (...args: string[]) => redisClient.call(...args),
  }),
  windowMs: 15 * 60 * 1000,  // 15 分钟
  max: 5,                     // 15 分钟内最多 5 次登录尝试
  skipSuccessfulRequests: true, // 成功登录不计数
});

// 全局应用
app.use('/api', globalLimiter);
// 敏感端点特殊限制
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/register', authLimiter);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50

10.4 输入验证与 Mass Assignment 防护 ​

typescript
// --- Mass Assignment 攻击 ---
// 请求:POST /api/users
// Body: { "username": "alice", "password": "secret", "role": "admin" }
// 如果后端直接 req.body 写入数据库,攻击者可以提权为 admin

// 防护:用 DTO + Zod 显式定义允许的字段
const createUserSchema = z.object({
  username: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/),
  email: z.string().email(),
  password: z.string().min(8).max(128),
  // role 字段不在 schema 中 → 即使客户端发送也会被 stripped
}).strict(); // strict():拒绝未知字段,而不是静默丢弃

// --- 输出过滤 ---
// 返回用户时,永远不要包含 password 字段
function serializeUser(user: User): SafeUser {
  const { password, passwordHash, refreshToken, ...safe } = user;
  return safe;
}

// 更激进的方案:定义严格的返回类型
interface UserResponse {
  id: number;
  username: string;
  email: string;
  avatarUrl: string | null;
  createdAt: string;
  // password 不在类型中 → TypeScript 编译时就能发现泄露
}

// --- 输入清理 ---
// 1. 对字符串做 trim() 和长度截断
// 2. HTML 实体编码(如果响应是 JSON,浏览器通常不会执行,但仍要防护)
// 3. SQL 注入防护:始终使用参数化查询或 ORM
// 4. 对 URL 参数做白名单校验(防止开放重定向)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

10.5 安全 Headers 检查清单 ​

typescript
// Express 安全中间件(推荐 helmet)
import helmet from 'helmet';

app.use(helmet({
  contentSecurityPolicy: false, // 如果是纯 API,不需要 CSP
  crossOriginResourcePolicy: { policy: 'same-origin' },
  crossOriginOpenerPolicy: { policy: 'same-origin' },
}));

// 手动设置额外的安全头
app.use((_req, res, next) => {
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.setHeader('X-Frame-Options', 'DENY');
  res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
  next();
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

核心总结 ​

总结1:REST 的本质 ​

REST 不是"把 URL 写成名词"或"用 JSON 返回数据"。REST 的核心是 统一接口 ——用 URL 标识资源、用 HTTP 方法表达操作、用状态码传达结果、用超媒体驱动状态转换。真正的 RESTful API 遵守六大约束,但实践中大多数 API 停留在 Richardson Level 2,这已经足够好。

总结2:分页方案选择 ​

Offset-based 适合数据稳定的管理后台;Cursor-based 适合数据频繁变动的 Feed 流;Keyset pagination 在性能和数据一致性上最优,但使用限制最多。默认推荐 Keyset,只在需要"跳转到任意页"时回退到 Offset。

总结3:版本化的核心原则 ​

破坏性变更才需要新版本。非破坏性变更(新增字段、新增端点)在旧版本上追加,不应当触发版本号升级。废弃周期要给足客户端迁移时间(至少 3-6 个月),并用 Sunset header 明确告知截止日期。

总结4:技术选型没有银弹 ​

REST 的缓存和工具链优势无可替代,适合公开 API;GraphQL 的多客户端灵活查询对复杂前端场景最优;tRPC 的全栈类型安全对 TypeScript monorepo 团队效率提升巨大。关键是理解各自适合什么场景,而不是争论哪个"更好"。

总结5:安全不是可选项 ​

认证、CORS、Rate Limiting、输入验证、Mass Assignment 防护是每个 API 的基线。不实现这些的 API 不是 MVP,是技术债务。


章节测试 ​

测试1:Richardson 模型 ​

一个 API 使用 GET /api/users/1 获取用户、POST /api/users 创建用户、DELETE /api/users/1 删除用户,但在创建订单时使用 POST /api/orders/create。这个 API 达到了 Richardson 的哪个 Level?

测试2:幂等性 ​

以下哪些 HTTP 方法在正确实现下是幂等的?哪些是不幂等的? A. GET B. POST C. PUT D. PATCH E. DELETE

测试3:分页场景 ​

你正在设计一个社交媒体 Feed API,数据频繁增删,用户通过无限滚动浏览。应该选择哪种分页方案?写出请求和响应的结构。

测试4:版本化判断 ​

以下哪些改动需要发布新的 API 版本号? A. 在 GET /api/users 响应中新增一个可选字段 bio B. 新增端点 POST /api/users/bulk C. 将 GET /api/users 的默认分页大小从 10 改为 100 D. 将 DELETE /api/users/:id 改为软删除(返回 200 而非 204)

测试5:错误设计 ​

请写出一个符合 RFC 7807 的验证错误响应,场景:用户注册时 email 格式不正确、password 长度不足。

测试6:技术选型 ​

你需要为一个电商平台设计 API。场景包括:(a) 公开的商家入驻 API(第三方开发者调用);(b) 内部 Web 端和移动端的 BFF 层(全栈 TypeScript 团队)。请为每个场景推荐技术方案,并说明理由。

测试7:Mass Assignment ​

以下代码有什么安全风险?如何修复?

typescript
app.post('/api/users', async (req, res) => {
  const user = await User.create(req.body);
  res.status(201).json(user);
});
1
2
3
4

参考答案 ​

测试1答案 ​

答案:Level 2。API 正确使用了 HTTP 方法(GET/POST/DELETE)和资源 URL,满足了 Level 1(资源)和 Level 2(HTTP 动词)。/orders/create 中的 create 动词在 URL 中虽然不优雅,但整体仍然达到了 Level 2。真正的 Level 3 需要 HATEOAS 支持。

测试2答案 ​

答案:

  • 幂等:A (GET)、C (PUT)、E (DELETE)
  • 不幂等:B (POST)
  • 视实现而定:D (PATCH) —— JSON Merge Patch 通常幂等,JSON Patch 不一定

测试3答案 ​

答案:使用 Cursor-based pagination。

请求:GET /api/feed?cursor=eyJpZCI6NTB9&limit=20

响应:

json
{
  "data": [ /* 20 条 feed 项目 */ ],
  "pagination": {
    "limit": 20,
    "hasNext": true,
    "nextCursor": "eyJpZCI6NzB9"
  }
}
1
2
3
4
5
6
7
8

测试4答案 ​

答案:

  • A:不需要。新增可选字段是非破坏性变更。旧客户端自动忽略。
  • B:不需要。新增端点不会影响现有客户端。
  • C:需要。修改默认值会影响所有客户端的已有行为,属于破坏性变更。
  • D:需要。响应状态码从 204 变为 200 改变了语义,客户端的成功判断逻辑可能被破坏。

测试5答案 ​

json
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "请求参数验证失败",
    "details": [
      {
        "field": "email",
        "message": "必须是有效的电子邮件地址",
        "code": "invalid_string",
        "received": "not-an-email"
      },
      {
        "field": "password",
        "message": "密码长度不能少于 8 个字符",
        "code": "too_small",
        "received": "123"
      }
    ],
    "requestId": "req_a1b2c3d4",
    "timestamp": "2026-07-28T10:30:00Z"
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

测试6答案 ​

(a) 公开商家入驻 API → REST + OpenAPI

  • 理由:第三方开发者可能使用多种语言;HTTP 缓存/CDN 可降本增效;版本化策略成熟;Postman/Swagger 等工具生态完善。

(b) 内部 BFF 层 → tRPC

  • 理由:全栈 TS 团队零类型转换成本;monorepo 下类型改动即时感知;BFF 不需要公开文档;不需要多语言客户端。

测试7答案 ​

风险:Mass Assignment 攻击。攻击者可以在 req.body 中添加 role: "admin"、isVerified: true 等字段直接修改敏感属性。

修复:

typescript
const createUserSchema = z.object({
  username: z.string().min(3).max(30),
  email: z.string().email(),
  password: z.string().min(8),
}).strict(); // 拒绝未声明的字段

app.post('/api/users', validate(createUserSchema), async (req, res) => {
  // req.body 已被 Zod 清洗,只包含 schema 中声明的字段
  const user = await User.create(req.body);
  res.status(201).json(serializeUser(user)); // 输出时也过滤敏感字段
});
1
2
3
4
5
6
7
8
9
10
11

相关笔记 ​

  • [[03-express-deep-dive]] — Express 中间件与请求生命周期
  • [[../03-web-platform-and-api/03-api-design]] — API 设计基础与 OpenAPI 入门
  • [[../../08-identity-and-security/01-auth-security]] — 认证与鉴权深度覆盖
  • [[../../08-identity-and-security/03-jwt-guide]] — JWT 设计与实践
  • [[../../07-middleware/05-cache-strategy]] — 缓存策略与 Redis 实践

下一步学习 ​

  • [ ] 用 Fastify 写一个带 Zod 验证的 CRUD API,自动生成 OpenAPI 文档
  • [ ] 为你的 API 实现 Cursor-based 分页,体验与 Offset-based 的区别
  • [ ] 了解 GraphQL 官方文档 和 tRPC 的全栈类型安全方案
  • [ ] 尝试用 tRPC 重写一个简单的 Express 路由,感受类型安全差异

学习状态:🟡 开始学习

最后更新于:

Pager
上一篇6. 现代 Node.js 框架对比 / Modern Node.js Framework Comparison
下一篇8. Node.js 环境配置与 12-Factor App / Environment Configuration and 12-Factor App

持续记录,持续成长

Copyright © Tidenflow