REST API 设计最佳实践:URL 结构、版本控制与错误处理
设计良好的 REST API 用起来令人愉悦,而设计糟糕的则成为维护噩梦。以下最佳实践源自 Stripe、GitHub、Twilio 等受开发者喜爱的 API 所使用的模式。
URL 设计原则

使用名词,而非动词
❌ 错误 — URL 中包含动词
GET /getUsers
POST /createUser
PUT /updateUser/123
GET /deleteUser/123
✅ 正确 — 使用名词,HTTP 方法作为动词
GET /users (列出用户)
POST /users (创建用户)
GET /users/123 (获取用户 123)
PUT /users/123 (替换用户 123)
PATCH /users/123 (部分更新用户 123)
DELETE /users/123 (删除用户 123)
集合使用复数名词
/users 而不是 /user
/products 而不是 /product
/orders 而不是 /order
/categories 而不是 /category
嵌套资源表示关系
/users/123/orders 属于用户 123 的订单
/users/123/orders/456 用户 123 的订单 456
/posts/789/comments 帖子 789 的评论
/posts/789/comments/101 帖子 789 的评论 101
但保持嵌套浅层(最多 2-3 层):
❌ /users/123/orders/456/items/789/reviews 太深!
✅ /order-items/789/reviews 展平它
使用查询参数进行过滤、排序、分页
GET /products?category=electronics&inStock=true
GET /users?sort=createdAt&order=desc
GET /orders?status=pending&page=2&limit=20
GET /products?minPrice=10&maxPrice=100&search=wireless
HTTP 方法 — 正确使用它们
| 方法 | 用途 | 幂等? | 请求体? |
|---|---|---|---|
| GET | 检索数据 | 是 | 否 |
| POST | 创建资源 | 否 | 是 |
| PUT | 替换资源(完整) | 是 | 是 |
| PATCH | 更新资源(部分) | 否 | 是 |
| DELETE | 删除资源 | 是 | 有时 |
PUT 与 PATCH 的区别
// PUT — 替换整个资源(必须发送所有字段)
PUT /users/123
{
"name": "Alice Johnson",
"email": "alice@example.com",
"role": "admin",
"phone": "+1-555-0100"
}
// PATCH — 仅更新指定字段
PATCH /users/123
{
"phone": "+1-555-9999"
}
API 版本控制

选项 1:URL 路径(推荐用于公共 API)
GET /v1/users
GET /v2/users (包含破坏性变更的新版本)
// Express 版本控制
app.use('/v1', v1Router);
app.use('/v2', v2Router);
// v1Router
v1Router.get('/users', getUsersV1);
// v2Router — v2 可能有不同的响应格式
v2Router.get('/users', getUsersV2);
选项 2:Accept 头部(更符合 REST 风格)
GET /users
Accept: application/vnd.myapi.v2+json
选项 3:自定义头部
GET /users
API-Version: 2
建议:URL 版本控制最明确,且最容易在浏览器中测试。公共 API 推荐使用。
响应格式一致性
// ✅ 单个资源
GET /users/123
{
"data": {
"id": "123",
"type": "user",
"attributes": {
"name": "Alice Johnson",
"email": "alice@example.com",
"createdAt": "2026-01-15T10:30:00Z"
}
}
}
// ✅ 集合
GET /users?page=1&limit=20
{
"data": [
{ "id": "123", "name": "Alice Johnson", "email": "alice@example.com" },
{ "id": "124", "name": "Bob Smith", "email": "bob@example.com" }
],
"meta": {
"page": 1,
"limit": 20,
"total": 847,
"totalPages": 43
},
"links": {
"self": "/users?page=1&limit=20",
"next": "/users?page=2&limit=20",
"last": "/users?page=43&limit=20"
}
}
错误处理
一致的错误格式
// ✅ Stripe 风格的错误响应
{
"error": {
"type": "validation_error",
"message": "Invalid request parameters",
"code": "VALIDATION_FAILED",
"details": [
{
"field": "email",
"message": "Must be a valid email address",
"code": "INVALID_FORMAT"
},
{
"field": "age",
"message": "Must be at least 18",
"code": "MIN_VALUE"
}
],
"requestId": "req_abc123"
}
}
HTTP 状态码
// Express 错误处理示例
export function errorHandler(err, req, res, next) {
// 4xx — 客户端错误
if (err instanceof ValidationError) {
return res.status(422).json({
error: { type: 'validation_error', message: err.message, details: err.details }
});
}
if (err instanceof NotFoundError) {
return res.status(404).json({
error: { type: 'not_found', message: err.message, code: 'RESOURCE_NOT_FOUND' }
});
}
if (err instanceof UnauthorizedError) {
return res.status(401).json({
error: { type: 'authentication_error', message: 'Invalid or expired token' }
});
}
if (err instanceof ForbiddenError) {
return res.status(403).json({
error: { type: 'authorization_error', message: 'Insufficient permissions' }
});
}
if (err.code === '23505') { // PostgreSQL 唯一约束冲突
return res.status(409).json({
error: { type: 'conflict', message: 'Resource already exists' }
});
}
// 5xx — 服务器错误
console.error('Unhandled error:', err);
res.status(500).json({
error: {
type: 'server_error',
message: 'An unexpected error occurred',
requestId: req.id, // 用于调试
}
});
}

认证
Bearer Token (JWT) 模式
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
// 在 Express 中:
const token = req.headers.authorization?.replace('Bearer ', '');
API Key 模式(用于服务间通信)
X-API-Key: sk_live_abc123...
// 或者作为查询参数(安全性较低,敏感操作避免使用):
GET /data?api_key=sk_live_abc123
过滤与搜索
// 按精确值过滤
GET /products?category=electronics
// 按范围过滤
GET /products?minPrice=10&maxPrice=500
GET /orders?startDate=2026-01-01&endDate=2026-03-31
// 全文搜索
GET /articles?q=javascript+async+await
// 多个值(逗号分隔或重复参数)
GET /products?category=electronics,clothing
GET /products?category=electronics&category=clothing
// 嵌套字段过滤
GET /users?address.city=SanFrancisco
使用 OpenAPI/Swagger 的 API 文档
# openapi.yaml
openapi: 3.0.3
info:
title: My API
version: 1.0.0
paths:
/users:
get:
summary: 列出用户
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'
常见 API 设计错误
| 错误 | 修正 |
|---|---|
GET /deleteUser/123 |
DELETE /users/123 |
| 错误时返回 200 | 使用正确的 4xx/5xx |
| 没有分页 | 添加 page/limit 参数 |
| 字段名不一致(camelCase vs snake_case) | 选择一种并坚持使用 |
| 没有版本控制 | 从一开始就添加 /v1/ 前缀 |
| 在响应中返回密码 | 永远不要包含敏感字段 |
| 本意是 PATCH 却用了 PUT | 理解两者的区别 |
发布前检查清单
- URL 使用名词而非动词
- 整个 API 使用一致的 camelCase(或 snake_case)
- 所有列表端点支持分页
- 一致的错误响应格式
- URL 中包含 API 版本(/v1/)
- 认证方式已文档化
- 响应中包含速率限制头部
- 已生成 OpenAPI/Swagger 文档
→ 使用 URL 解析器 工具解析和检查 URL。