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 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.3 资源导向设计原则
REST 的核心思想是把一切抽象为"资源"(Resource),用 URL 标识资源,用 HTTP 方法操作资源。
// --- 正确:资源 = 名词复数,操作 = 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=421.4 统一接口:HTTP 方法的正确语义
┌─────────────────────────────────────────────────────────────┐
│ HTTP 方法语义与幂等性 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 方法 │ 语义 │ 幂等 │ 安全 │ 请求体 │ 缓存 │
│ ─────────┼─────────────┼──────┼──────┼────────┼───────── │
│ GET │ 查询资源 │ ✅ │ ✅ │ 无 │ ✅ │
│ POST │ 创建资源 │ ❌ │ ❌ │ 有 │ ❌ │
│ PUT │ 完整替换 │ ✅ │ ❌ │ 有 │ ❌ │
│ PATCH │ 部分更新 │ ⚠️ │ ❌ │ 有 │ ❌ │
│ DELETE │ 删除资源 │ ✅ │ ❌ │ 可选 │ ❌ │
│ HEAD │ 获取元信息 │ ✅ │ ✅ │ 无 │ ✅ │
│ OPTIONS │ 获取能力 │ ✅ │ ✅ │ 无 │ ❌ │
│ │
│ 幂等:多次相同请求,服务器状态与执行一次相同 │
│ 安全:不修改服务器状态(只读) │
│ │
│ ⚠️ PATCH 的幂等性取决于具体实现: │
│ • JSON Merge Patch (RFC 7396): 大多数场景下幂等 │
│ • JSON Patch (RFC 6902): 可能不幂等(如 { "op": "add" }) │
│ │
└─────────────────────────────────────────────────────────────┘// 关键实践: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.5 无状态性的现实取舍
┌─────────────────────────────────────────────────────────────┐
│ 无状态 vs 有状态 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 纯无状态: │
│ • 每个请求自包含(认证 Token + 所有上下文) │
│ • 服务器不维护会话 → 任意实例可处理任意请求 → 水平扩展容易 │
│ • 代价:认证信息在每个请求中重复传输 │
│ │
│ 现实妥协: │
│ • 认证状态交给 Token(JWT / opaque token),不交给服务器会话 │
│ • 业务状态(如购物车)交客户端或用独立的状态服务(Redis) │
│ • 不要在 API 服务器内存中维护"用户登录状态" │
│ • 不要把 API 服务器变成"会话亲和"(sticky session) │
│ │
│ 判断标准:重启一个 API 实例后,客户端 │
│ 是否还能正常发起新请求?如果答案是"是", │
│ 你的 API 就是无状态的。 │
│ │
└─────────────────────────────────────────────────────────────┘1.6 HATEOAS:理想与现实
HATEOAS 是 Richardson Level 3 的核心特征——响应中携带可用操作的链接,客户端通过这些链接发现下一步可执行的操作。
// 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, ...) }HATEOAS 的实际地位:除少数大型公开 API(GitHub、Stripe、AWS)外,绝大多数 API 不实现 HATEOAS。原因有三:客户端仍然硬编码 URL 和业务逻辑(仅靠链接导航不够);增加了响应体积和序列化开销;前端生态(React/Vue)没有成熟的 HATEOAS 消费工具。不建议在普通项目强制推行 HATEOAS,但理解其思想能帮助你思考 API 的可发现性。
第2部分:URL 设计规范
2.1 命名约定与风格
// --- 基本规则 ---
// 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,造成混淆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. 当子资源可以独立存在时,给它们独立的顶层端点 │
│ │
│ 判断标准:如果某个子资源有自己的生命周期 │
│ (可以脱离父资源存在),它应该有自己的顶层端点。 │
│ │
└─────────────────────────────────────────────────────────────┘2.3 查询参数设计
// --- 过滤 (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=02.4 批量操作设计模式
REST 没有内置的批量操作规范,以下是两种主流方案:
// --- 方案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 }>;
};第3部分:分页全方案
3.1 三种分页方案对比
┌─────────────────────────────────────────────────────────────┐
│ 分页方案全景对比 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 方案 │ 原理 │ 适用场景 │ 主要问题 │
│ ───────────┼────────────────┼───────────────┼───────────── │
│ Offset │ ?page=2& │ 传统 Web 页面 │ 数据变动导致 │
│ -based │ limit=20 │ 管理后台 │ 重复/遗漏 │
│ ───────────┼────────────────┼───────────────┼───────────── │
│ Cursor │ ?cursor=abc │ 无限滚动 │ 无法跳到 │
│ -based │ 返回 next 指针 │ 实时数据流 │ 任意页 │
│ ───────────┼────────────────┼───────────────┼───────────── │
│ Keyset │ ?after_id=100 │ 增量同步 │ 只能按排序 │
│ │ &limit=20 │ 数据导出 │ 字段翻页 │
│ │
└─────────────────────────────────────────────────────────────┘3.2 Offset-based 分页
// 请求: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,或使用估算值3.3 Cursor-based 分页
Cursor-based 分页用上一页最后一条记录的指针(cursor)请求下一页,天然免疫数据变动问题。
// 请求: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. 排序字段必须形成确定的、唯一的序列3.4 Keyset Pagination(也被称为 "seek method")
// 比 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)3.5 Link Header(RFC 8288)与分页元数据
// 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 用于人类可读3.6 分页方案选择决策树
┌─────────────────────────────────────────────────────────────┐
│ 分页方案选择决策 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 你的数据是否频繁增删? │
│ ├── 是 → Cursor-based 或 Keyset pagination │
│ │ → 用户是否需要跳转到任意页? │
│ │ ├── 是 → Keyset pagination + 前端虚拟滚动 │
│ │ └── 否 → Cursor-based(如无限滚动 Feed) │
│ │ │
│ └── 否(数据相对稳定,如管理后台) │
│ → 用户是否需要看到"总页数"? │
│ ├── 是 → Offset-based + 分页组件 │
│ └── 否 → Keyset pagination(性能更好) │
│ │
│ 数据量极大(千万级以上)? │
│ → 不要用 count(*) → Keyset pagination │
│ │
└─────────────────────────────────────────────────────────────┘第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 友好、调试方便、生态工具支持最好 │
│ │
└─────────────────────────────────────────────────────────────┘4.2 版本化实现策略
// --- 路由前缀方式 ---
// 不同版本映射到不同 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),不重复业务代码4.3 兼容性承诺与废弃策略
// 废弃时间线模板:
// 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?(最容易遗漏)4.4 什么时候需要新版本?
并非所有改动都需要新版本号。区分破坏性变更和非破坏性变更:
破坏性变更(需要新版本): 非破坏性变更(不需要新版本):
├── 删除字段或端点 ├── 新增端点
├── 修改字段类型 ├── 新增可选字段
├── 修改字段语义 ├── 新增可选查询参数
├── 修改认证方式 ├── 放宽速率限制
├── 修改错误响应结构 ├── 支持新的 Content-Type
└── 修改分页默认值 └── 新增 Link Header第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) │
│ │
└─────────────────────────────────────────────────────────────┘// 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 });
});5.2 Cache-Control 策略设计
// --- 不同端点的缓存策略 ---
// 公开的、不常变的数据:缓存 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 → 资源内容永不改变第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 → 发生错误的请求路径 │
│ │
└─────────────────────────────────────────────────────────────┘6.2 生产级错误响应设计
// --- 错误码体系 ---
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();
};
}第7部分:长操作与异步模式
7.1 同步 vs 异步 API 的决策
┌─────────────────────────────────────────────────────────────┐
│ 长操作处理决策树 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 操作预计耗时? │
│ ├── < 200ms → 同步返回(标准 REST) │
│ │ POST /api/orders → 201 Created │
│ │ │
│ ├── 200ms ~ 2s → 同步 + 合理的超时配置 │
│ │ 确保客户端和网关的超时设置充足 │
│ │ │
│ └── > 2s 或不确定 → 异步模式 │
│ ├── 客户端需要实时进度? → SSE │
│ ├── 客户端只关心最终结果? → 202 + 轮询 │
│ └── 需要通知多个系统? → Webhook 回调 │
│ │
└─────────────────────────────────────────────────────────────┘7.2 202 Accepted + Location 轮询
// 模式: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);
});7.3 Webhook 回调模式
// 客户端在发起请求时提供一个回调 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();
}7.4 Server-Sent Events 进度推送
// 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);
// };第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│ (随代码演进) │
│ │ │ + 渐进演进) │ │
│ │
└─────────────────────────────────────────────────────────────┘8.2 GraphQL 的 N+1 问题与复杂度控制
// 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):只允许预注册的查询8.3 tRPC 的全栈类型安全
// 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/网关/缓存层)8.4 选型建议
┌─────────────────────────────────────────────────────────────┐
│ API 技术选型决策矩阵 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 你的需求是…… │
│ │
│ 对外公开 API(第三方集成) │
│ → REST + OpenAPI(可缓存、可版本化、多语言客户端友好) │
│ │
│ 多客户端(iOS/Android/Web + 不同数据需求) │
│ → GraphQL(客户端按需获取、避免 over-fetching) │
│ → 或 REST + sparse fieldsets(简单场景) │
│ │
│ 全栈 TypeScript + Monorepo │
│ → tRPC(零模板代码、编译时安全、开发体验最好) │
│ │
│ 内部微服务间通信 │
│ → gRPC(高性能二进制协议)+ REST(调试友好) │
│ │
│ 需要极致的 CDN 缓存 │
│ → REST(GET 请求天然适配 HTTP 缓存基础设施) │
│ │
└─────────────────────────────────────────────────────────────┘第9部分:OpenAPI 规范
9.1 OpenAPI 3.1 核心结构
# 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"9.2 从代码生成 OpenAPI Spec
// -- 方案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) { /* ... */ }
}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 │
│ → 适合团队协作和手动测试 │
│ │
└─────────────────────────────────────────────────────────────┘第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 的组合 │
│ │
└─────────────────────────────────────────────────────────────┘10.2 CORS 策略设计
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 小时
}));10.3 Rate Limiting 策略
// --- 多层限流策略 ---
// 第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);10.4 输入验证与 Mass Assignment 防护
// --- 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 参数做白名单校验(防止开放重定向)10.5 安全 Headers 检查清单
// 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: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
以下代码有什么安全风险?如何修复?
app.post('/api/users', async (req, res) => {
const user = await User.create(req.body);
res.status(201).json(user);
});参考答案
测试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
响应:
{
"data": [ /* 20 条 feed 项目 */ ],
"pagination": {
"limit": 20,
"hasNext": true,
"nextCursor": "eyJpZCI6NzB9"
}
}测试4答案
答案:
- A:不需要。新增可选字段是非破坏性变更。旧客户端自动忽略。
- B:不需要。新增端点不会影响现有客户端。
- C:需要。修改默认值会影响所有客户端的已有行为,属于破坏性变更。
- D:需要。响应状态码从 204 变为 200 改变了语义,客户端的成功判断逻辑可能被破坏。
测试5答案
{
"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"
}
}测试6答案
(a) 公开商家入驻 API → REST + OpenAPI
- 理由:第三方开发者可能使用多种语言;HTTP 缓存/CDN 可降本增效;版本化策略成熟;Postman/Swagger 等工具生态完善。
(b) 内部 BFF 层 → tRPC
- 理由:全栈 TS 团队零类型转换成本;monorepo 下类型改动即时感知;BFF 不需要公开文档;不需要多语言客户端。
测试7答案
风险:Mass Assignment 攻击。攻击者可以在 req.body 中添加 role: "admin"、isVerified: true 等字段直接修改敏感属性。
修复:
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)); // 输出时也过滤敏感字段
});相关笔记
- [[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 路由,感受类型安全差异
学习状态:🟡 开始学习