HTTP 协议详解教程 / 第 18 章:API 设计最佳实践
第 18 章:API 设计最佳实践
良好的 API 设计是后端服务成功的关键。本章总结 HTTP API 设计的核心原则和最佳实践。
18.1 RESTful 设计原则
REST 核心约束
| 约束 | 说明 |
|---|
| 客户端-服务器 | 关注点分离 |
| 无状态 | 每个请求包含所有必要信息 |
| 可缓存 | 响应明确标识是否可缓存 |
| 统一接口 | 标准化的资源操作方式 |
| 分层系统 | 客户端无需知道是否直连服务器 |
| 按需代码(可选) | 服务器可返回可执行代码 |
资源命名规范
# ✅ 正确:使用名词复数
GET /api/v1/users
GET /api/v1/users/123
POST /api/v1/users
PUT /api/v1/users/123
DELETE /api/v1/users/123
# ✅ 正确:使用嵌套表示关系
GET /api/v1/users/123/orders
GET /api/v1/users/123/orders/456
# ❌ 错误:使用动词
GET /api/v1/getUsers
POST /api/v1/createUser
POST /api/v1/deleteUser/123
# ✅ 特殊情况:操作无法映射到 CRUD
POST /api/v1/orders/123/cancel
POST /api/v1/users/123/activate
POST /api/v1/reports/generate
HTTP 方法映射
| 操作 | HTTP 方法 | URL 模式 | 状态码 |
|---|
| 获取列表 | GET | /users | 200 |
| 获取单个 | GET | /users/{id} | 200 / 404 |
| 创建 | POST | /users | 201 |
| 完整替换 | PUT | /users/{id} | 200 / 201 |
| 部分更新 | PATCH | /users/{id} | 200 |
| 删除 | DELETE | /users/{id} | 204 |
18.2 版本控制
版本策略对比
| 方案 | 示例 | 优点 | 缺点 |
|---|
| URL 路径 | /api/v1/users | 简单直观 | URL 不够"纯粹" |
| 查询参数 | /api/users?version=1 | 可选 | 不够清晰 |
| 请求头 | Accept: application/vnd.api.v1+json | URL 纯粹 | 调试不便 |
| 自定义头 | X-API-Version: 1 | 灵活 | 不标准 |
推荐:URL 路径版本
# URL 路径版本(最常用)
GET /api/v1/users
GET /api/v2/users
# Nginx 路由
location /api/v1/ {
proxy_pass http://backend-v1;
}
location /api/v2/ {
proxy_pass http://backend-v2;
}
版本迁移策略
// 版本兼容层
app.get('/api/v1/users', (req, res) => {
const users = getUsersFromDB();
// v1 格式
res.json({
users: users.map(u => ({
id: u.id,
name: u.name,
email: u.email
}))
});
});
app.get('/api/v2/users', (req, res) => {
const users = getUsersFromDB();
// v2 格式(新增字段)
res.json({
data: users.map(u => ({
id: u.id,
full_name: u.name, // 字段重命名
email: u.email,
avatar_url: u.avatar, // 新增字段
created_at: u.createdAt
})),
meta: {
total: users.length,
page: 1,
per_page: 20
}
});
});
18.3 分页设计
分页方案
# 方案 1:偏移量分页(最常用)
GET /api/v1/users?page=2&limit=20
GET /api/v1/users?offset=20&limit=20
# 方案 2:游标分页(大数据集推荐)
GET /api/v1/users?cursor=eyJpZCI6MTAwfQ&limit=20
# 方案 3:页码分页
GET /api/v1/users?page=2&per_page=20
分页响应格式
{
"data": [
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"}
],
"pagination": {
"total": 150,
"page": 2,
"per_page": 20,
"total_pages": 8,
"has_next": true,
"has_prev": true
},
"links": {
"self": "/api/v1/users?page=2&limit=20",
"first": "/api/v1/users?page=1&limit=20",
"prev": "/api/v1/users?page=1&limit=20",
"next": "/api/v1/users?page=3&limit=20",
"last": "/api/v1/users?page=8&limit=20"
}
}
游标分页实现
// 游标分页(适合大数据集、实时数据)
app.get('/api/v1/users', async (req, res) => {
const { cursor, limit = 20 } = req.query;
const parsedLimit = Math.min(parseInt(limit), 100);
let query = { deletedAt: null };
if (cursor) {
const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString());
query.id = { $gt: decoded.id };
}
const users = await db.users.find(query)
.sort({ id: 1 })
.limit(parsedLimit + 1); // 多查一条判断是否有下一页
const hasMore = users.length > parsedLimit;
const data = hasMore ? users.slice(0, parsedLimit) : users;
const lastItem = data[data.length - 1];
const nextCursor = hasMore
? Buffer.from(JSON.stringify({ id: lastItem.id })).toString('base64')
: null;
res.json({
data,
cursor: nextCursor,
has_more: hasMore
});
});
18.4 错误处理
标准错误响应格式
{
"error": {
"code": "VALIDATION_ERROR",
"message": "请求参数验证失败",
"details": [
{
"field": "email",
"code": "INVALID_FORMAT",
"message": "邮箱格式不正确"
},
{
"field": "age",
"code": "OUT_OF_RANGE",
"message": "年龄必须在 0-150 之间",
"min": 0,
"max": 150
}
],
"request_id": "req-550e8400",
"documentation_url": "https://api.example.com/docs/errors#VALIDATION_ERROR"
}
}
错误码设计
// 错误码枚举
const ErrorCodes = {
// 通用错误
INTERNAL_ERROR: 'INTERNAL_ERROR',
NOT_FOUND: 'NOT_FOUND',
METHOD_NOT_ALLOWED: 'METHOD_NOT_ALLOWED',
// 认证授权
UNAUTHORIZED: 'UNAUTHORIZED',
FORBIDDEN: 'FORBIDDEN',
TOKEN_EXPIRED: 'TOKEN_EXPIRED',
INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
// 参数验证
VALIDATION_ERROR: 'VALIDATION_ERROR',
MISSING_PARAMETER: 'MISSING_PARAMETER',
INVALID_PARAMETER: 'INVALID_PARAMETER',
// 业务逻辑
USER_NOT_FOUND: 'USER_NOT_FOUND',
EMAIL_ALREADY_EXISTS: 'EMAIL_ALREADY_EXISTS',
ORDER_ALREADY_PAID: 'ORDER_ALREADY_PAID',
INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE',
// 限流
RATE_LIMITED: 'RATE_LIMITED',
QUOTA_EXCEEDED: 'QUOTA_EXCEEDED'
};
统一错误处理中间件
class AppError extends Error {
constructor(statusCode, code, message, details = null) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.details = details;
}
static badRequest(code, message, details) {
return new AppError(400, code, message, details);
}
static unauthorized(message = '请先登录') {
return new AppError(401, 'UNAUTHORIZED', message);
}
static forbidden(message = '权限不足') {
return new AppError(403, 'FORBIDDEN', message);
}
static notFound(message = '资源不存在') {
return new AppError(404, 'NOT_FOUND', message);
}
static conflict(code, message) {
return new AppError(409, code, message);
}
static tooManyRequests(retryAfter) {
return new AppError(429, 'RATE_LIMITED', '请求过于频繁', { retry_after: retryAfter });
}
}
// 全局错误处理中间件
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500;
const isProduction = process.env.NODE_ENV === 'production';
const response = {
error: {
code: err.code || 'INTERNAL_ERROR',
message: statusCode === 500 && isProduction
? '服务器内部错误'
: err.message,
request_id: req.headers['x-request-id']
}
};
if (err.details) {
response.error.details = err.details;
}
if (statusCode === 500) {
console.error('Internal Error:', err);
}
res.status(statusCode).json(response);
});
// 使用
app.get('/api/users/:id', async (req, res, next) => {
try {
const user = await db.users.findById(req.params.id);
if (!user) {
throw AppError.notFound('用户不存在');
}
res.json({ data: user });
} catch (err) {
next(err);
}
});
18.5 限流(Rate Limiting)
限流策略
| 策略 | 说明 | 适用场景 |
|---|
| 固定窗口 | 每分钟 N 次 | 简单限流 |
| 滑动窗口 | 滑动时间窗口 | 更平滑的限流 |
| 令牌桶 | 恒定速率补充令牌 | 允许突发流量 |
| 漏桶 | 恒定速率处理请求 | 平滑输出 |
限流响应
HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1715328000
Content-Type: application/json
{
"error": {
"code": "RATE_LIMITED",
"message": "请求过于频繁,请 60 秒后重试",
"retry_after": 60
}
}
Redis 限流实现
const Redis = require('ioredis');
const redis = new Redis();
// 滑动窗口限流
async function rateLimit(key, limit, windowSeconds) {
const now = Date.now();
const windowStart = now - windowSeconds * 1000;
// 移除窗口外的请求记录
await redis.zremrangebyscore(key, 0, windowStart);
// 获取当前窗口内的请求数
const count = await redis.zcard(key);
if (count >= limit) {
// 计算重置时间
const oldest = await redis.zrange(key, 0, 0, 'WITHSCORES');
const resetTime = Math.ceil((parseInt(oldest[1]) + windowSeconds * 1000 - now) / 1000);
return {
allowed: false,
remaining: 0,
resetIn: resetTime
};
}
// 记录本次请求
await redis.zadd(key, now, `${now}-${Math.random()}`);
await redis.expire(key, windowSeconds);
return {
allowed: true,
remaining: limit - count - 1,
resetIn: windowSeconds
};
}
// 限流中间件
function rateLimitMiddleware(limit, windowSeconds) {
return async (req, res, next) => {
const key = `ratelimit:${req.ip}:${req.path}`;
const result = await rateLimit(key, limit, windowSeconds);
res.set('X-RateLimit-Limit', limit);
res.set('X-RateLimit-Remaining', result.remaining);
if (!result.allowed) {
res.set('Retry-After', result.resetIn);
return res.status(429).json({
error: {
code: 'RATE_LIMITED',
message: `请求过于频繁,请 ${result.resetIn} 秒后重试`,
retry_after: result.resetIn
}
});
}
next();
};
}
// 使用
app.use('/api/', rateLimitMiddleware(100, 60)); // 每分钟 100 次
app.use('/api/auth/', rateLimitMiddleware(10, 60)); // 登录接口更严格
18.6 幂等性设计
为什么需要幂等性
POST /api/orders
客户端发送请求 → 网络超时 → 客户端不知道是否成功
客户端重试请求 → 可能创建重复订单!
解决:使用幂等键(Idempotency Key)
幂等性实现
const crypto = require('crypto');
// 幂等性中间件
function idempotent(ttlSeconds = 86400) {
return async (req, res, next) => {
const idempotencyKey = req.headers['idempotency-key'];
if (!idempotencyKey) {
return res.status(400).json({
error: { code: 'MISSING_IDEMPOTENCY_KEY', message: '请提供 Idempotency-Key 头' }
});
}
// 检查是否已处理过
const cached = await redis.get(`idempotent:${idempotencyKey}`);
if (cached) {
const { statusCode, body } = JSON.parse(cached);
return res.status(statusCode).json(body);
}
// 拦截响应,缓存结果
const originalJson = res.json.bind(res);
res.json = function(body) {
redis.setex(
`idempotent:${idempotencyKey}`,
ttlSeconds,
JSON.stringify({ statusCode: res.statusCode, body })
);
return originalJson(body);
};
next();
};
}
// 使用
app.post('/api/orders', idempotent(86400), async (req, res) => {
const order = await createOrder(req.body);
res.status(201).json({ data: order });
});
客户端使用
import { v4 as uuidv4 } from 'uuid';
// 每次请求生成唯一的幂等键
const idempotencyKey = uuidv4();
const response = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey
},
body: JSON.stringify({ product_id: 42, quantity: 1 })
});
// 超时后重试,使用相同的幂等键
// 服务器会返回缓存的结果,不会创建重复订单
18.7 API 文档
OpenAPI 规范(Swagger)
openapi: 3.0.3
info:
title: 用户管理 API
version: 1.0.0
description: 用户管理服务 API 文档
paths:
/api/v1/users:
get:
summary: 获取用户列表
tags: [用户]
parameters:
- name: page
in: query
schema:
type: integer
default: 1
- name: limit
in: query
schema:
type: integer
default: 20
maximum: 100
responses:
'200':
description: 成功
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/User'
pagination:
$ref: '#/components/schemas/Pagination'
post:
summary: 创建用户
tags: [用户]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name, email]
properties:
name:
type: string
example: "Alice"
email:
type: string
format: email
example: "[email protected]"
responses:
'201':
description: 创建成功
'400':
description: 参数错误
'409':
description: 邮箱已存在
components:
schemas:
User:
type: object
properties:
id:
type: integer
name:
type: string
email:
type: string
format: email
created_at:
type: string
format: date-time
Pagination:
type: object
properties:
total:
type: integer
page:
type: integer
per_page:
type: integer
18.8 HATEOAS
{
"data": {
"id": 123,
"name": "Alice",
"email": "[email protected]"
},
"links": {
"self": "/api/v1/users/123",
"orders": "/api/v1/users/123/orders",
"update": {
"href": "/api/v1/users/123",
"method": "PATCH"
},
"delete": {
"href": "/api/v1/users/123",
"method": "DELETE"
}
}
}
18.9 业务场景:完整 API 设计示例
// 电商 API 设计
const express = require('express');
const app = express();
// 商品
GET /api/v1/products // 商品列表(带分页、过滤、排序)
GET /api/v1/products/:id // 商品详情
POST /api/v1/products // 创建商品(管理员)
PATCH /api/v1/products/:id // 更新商品
DELETE /api/v1/products/:id // 删除商品
// 订单
GET /api/v1/orders // 我的订单列表
GET /api/v1/orders/:id // 订单详情
POST /api/v1/orders // 创建订单(幂等)
POST /api/v1/orders/:id/cancel // 取消订单
POST /api/v1/orders/:id/pay // 支付订单(幂等)
// 用户
GET /api/v1/users/me // 当前用户信息
PATCH /api/v1/users/me // 更新个人信息
POST /api/v1/auth/login // 登录
POST /api/v1/auth/logout // 退出
POST /api/v1/auth/refresh // 刷新 Token
⚠️ 注意事项
- 始终使用 HTTPS:API 必须使用加密连接
- 正确的状态码:使用语义正确的 HTTP 状态码
- 版本控制:API 必须有版本管理策略
- 错误信息:提供有用的错误信息和错误码
- 限流:所有 API 都应该有限流保护
- 幂等性:写操作应实现幂等性
- 分页:列表接口必须支持分页
- 文档:使用 OpenAPI 规范编写文档
🔗 扩展阅读
完结:恭喜你完成了 HTTP 协议详解教程的全部 18 章!回顾 _index.md 查看完整目录。