RESTful API 设计
API 是前后端协作的契约。一份设计良好的 RESTful API:URL 一眼看懂资源,HTTP 方法表达操作语义,状态码传达结果,错误信息告诉客户端怎么修。本文从资源建模讲到文档与安全,给出可直接照搬的规范。
一、REST 概念
REST(Representational State Transfer)是一套基于 HTTP 的架构风格,核心思想:
| 原则 | 说明 |
|---|---|
| 资源(Resource) | 一切皆为资源,用 URL 定位,如 /users、/orders |
| 方法语义 | HTTP 方法表达对资源的操作 |
| 无状态 | 服务端不保存客户端状态,状态放在请求中 |
| 状态码 | HTTP 状态码传达处理结果 |
| 表示(Representation) | 客户端与服务端交换资源的 JSON/XML 表示 |
URL 是名词,方法才是动词——GET /users 是"获取用户集合",而不是 /getUsers。
二、资源 URL 设计规范
| 规范 | 好写法 | 坏写法 |
|---|---|---|
| 复数名词 | /users、/articles | /user、/article |
| 层级关系 | /users/1/posts | /posts?userId=1(也可以,但层级语义更清晰) |
| 小写 + 连字符 | /user-profiles | /userProfiles、/user_profiles |
| 用 ID 不用索引 | /users/42 | /users/1st-user |
| 动词交给方法 | POST /orders(下单) | /createOrder |
text
# 推荐的资源 URL 设计
GET /api/v1/users # 用户列表
POST /api/v1/users # 创建用户
GET /api/v1/users/42 # 用户详情
PATCH /api/v1/users/42 # 部分更新
DELETE /api/v1/users/42 # 删除用户
GET /api/v1/users/42/posts # 该用户的文章列表
GET /api/v1/posts?tag=js&sort=createdAt:desc # 筛选 + 排序Filtering(筛选):查询条件放查询参数,常见字段:search(关键词)、tag、status、dateFrom/dateTo(范围)。
三、HTTP 方法语义
| 方法 | 语义 | 是否幂等 | 请求体 | 典型场景 |
|---|---|---|---|---|
GET | 读取资源 | 是 | 无 | 查询列表、详情 |
POST | 创建资源 / 触发动作 | 否 | 有 | 创建订单、上传文件 |
PUT | 整体替换资源 | 是 | 有 | 全量更新 |
PATCH | 部分更新资源 | 否 | 有 | 改个别字段 |
DELETE | 删除资源 | 是 | 通常无 | 删除记录 |
javascript
// Express 中的典型映射
router.get("/users", listUsers); // 列表
router.get("/users/:id", getUser); // 详情
router.post("/users", createUser); // 创建
router.put("/users/:id", replaceUser); // 整体替换
router.patch("/users/:id", patchUser); // 局部更新
router.delete("/users/:id", deleteUser); // 删除幂等:同一请求执行一次与执行多次效果相同。GET/PUT/DELETE 天然幂等;POST 不幂等(重复点击会重复创建),这也是前端"防重复提交"要针对 POST 做的原因。
四、状态码使用
| 分类 | 含义 | 常用状态码 |
|---|---|---|
| 2xx | 成功 | 200 OK、201 Created、204 No Content |
| 3xx | 重定向 | 301 永久、302 临时、304 Not Modified |
| 4xx | 客户端错误 | 400 参数错、401 未认证、403 无权限、404 不存在、409 冲突、422 校验失败、429 限流 |
| 5xx | 服务端错误 | 500 内部错误、502 网关错、503 服务不可用、504 超时 |
javascript
// 常见错误状态的返回
res.status(201).json({ id: 1 }); // 创建成功
res.status(400).json({ error: "缺少必填字段 name" }); // 参数错误
res.status(401).json({ error: "未登录" }); // 未认证
res.status(403).json({ error: "无权限操作" }); // 已认证但被拒绝
res.status(404).json({ error: "资源不存在" }); // 找不到
res.status(409).json({ error: "邮箱已被注册" }); // 冲突
res.status(429).json({ error: "请求过于频繁" }); // 限流
res.status(500).json({ error: "服务器内部错误" }); // 兜底| 易混状态码 | 区分 |
|---|---|
| 400 vs 422 | 400 语法/格式错误;422 语义校验失败(如年龄不能为负) |
| 401 vs 403 | 401 未登录(不知道你是谁);403 已登录但没权限 |
| 404 vs 403 | 为避免探测,不存在的资源可统一返回 404 |
五、请求与响应设计
5.1 查询参数:分页、排序、筛选
text
GET /api/v1/articles?page=2&pageSize=10&sort=-createdAt&tag=js&status=published| 参数 | 说明 | 示例 |
|---|---|---|
page / pageSize | 页码分页 | page=2&pageSize=10 |
offset / limit | 偏移分页 | offset=20&limit=10 |
cursor | 游标分页(海量数据推荐) | cursor=eyJpZCI6MTAwfQ |
sort | 排序,- 前缀表降序 | sort=-createdAt |
| 业务字段 | 筛选条件 | tag=js&status=published |
javascript
// 分页 + 排序的解析与响应(Express 示例)
const page = Math.max(parseInt(req.query.page) || 1, 1);
const pageSize = Math.min(parseInt(req.query.pageSize) || 10, 100);
const { rows, total } = await Article.findAndCountAll({
where: buildFilter(req.query), // 白名单字段构建筛选
order: [["createdAt", "DESC"]],
offset: (page - 1) * pageSize,
limit: pageSize,
});
res.json({
data: rows,
pagination: {
page, pageSize, total,
totalPages: Math.ceil(total / pageSize),
hasMore: page * pageSize < total,
},
});筛选字段白名单:只允许固定字段参与 where,防止客户端传任意字段注入查询。
5.2 响应结构
统一响应结构,前端解析逻辑简单:
javascript
// 成功
res.json({ data: { id: 1, name: "张三" } });
// 列表
res.json({ data: [ ... ], pagination: { ... } });
// 失败(见下节)
res.status(400).json({ error: { code: "INVALID_PARAM", message: "参数不合法", details: [...] } });六、错误响应格式
好的错误信息要包含机器可读的 code 和人可读的 message:
javascript
class ApiError extends Error {
constructor(status, code, message, details) {
super(message);
this.status = status;
this.code = code;
this.details = details;
}
}
// 统一错误处理中间件
app.use((err, req, res, next) => {
if (err instanceof ApiError) {
return res.status(err.status).json({ error: { code: err.code, message: err.message, details: err.details } });
}
console.error("未处理错误:", err);
res.status(500).json({ error: { code: "INTERNAL_ERROR", message: "服务器内部错误" } });
});| 设计要点 | 说明 |
|---|---|
| 固定结构 | error.code + error.message + 可选 error.details |
| 错误码语义化 | USER_NOT_FOUND、DUPLICATE_EMAIL、INVALID_PARAM |
| 不泄露内部细节 | 5xx 不返回堆栈、SQL、路径等敏感信息 |
| 校验错误给 details | 列出具体哪个字段错了 |
七、API 版本管理
| 方案 | 示例 | 优点 | 缺点 |
|---|---|---|---|
| URL 路径版本 | /api/v1/users、/api/v2/users | 直观、缓存友好、便于共存 | URL 变长 |
| Header 版本 | Accept: application/vnd.myapi.v2+json | URL 干净 | 调试不直观,缓存粒度差 |
| 查询参数版本 | /api/users?version=2 | 实现简单 | 易被忽略、污染缓存 key |
javascript
// URL 路径版本(最常用)
app.use("/api/v1", v1Router);
app.use("/api/v2", v2Router);实践建议:小团队从 v1 起步,破坏性变更时升 v2 并保留 v1 一段时间(设置弃用日期),新老版本共存迁移。
八、幂等性设计
| 场景 | 方案 |
|---|---|
| POST 重复创建 | 客户端生成 Idempotency-Key 头,服务端按 key 去重 |
| 支付/下单 | 业务号唯一约束(order_no 唯一索引),重复提交返回同一结果 |
| 消息消费 | 消费端记录已处理的消息 ID(Redis set / 数据库去重) |
| 定时任务 | 分布式锁保证同一时刻只有一个实例执行 |
javascript
// 幂等创建:订单号唯一
router.post("/orders", async (req, res) => {
const { orderNo, items } = req.body;
try {
const order = await db.orders.create({ orderNo, items });
res.status(201).json(order);
} catch (error) {
if (error.code === "ER_DUP_ENTRY") {
// 重复提交:查询已有订单并原样返回
const existed = await db.orders.findByOrderNo(orderNo);
return res.json(existed);
}
throw error;
}
});九、Swagger / OpenAPI 文档
OpenAPI 用描述文件定义接口,Swagger UI 生成可交互文档,代码即文档,联调省心:
bash
npm install swagger-ui-express
npm install -D @swc/cli swagger-jsdoc # 或使用装饰器方式javascript
const swaggerUi = require("swagger-ui-express");
const swaggerJsdoc = require("swagger-jsdoc");
const options = {
definition: {
openapi: "3.0.0",
info: { title: "商城 API", version: "1.0.0" },
},
apis: ["./src/routes/*.js"], // 扫描路由文件的 JSDoc 注释
};
const swaggerSpec = swaggerJsdoc(options);
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec));javascript
/**
* @swagger
* /api/v1/users/{id}:
* get:
* summary: 获取用户详情
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200:
* description: 用户信息
* content:
* application/json:
* schema: { $ref: "#/components/schemas/User" }
*/
router.get("/users/:id", getUser);访问 /api-docs 即可看到可点击执行的文档页面。客户端可再用 openapi-generator 自动生成类型安全的请求代码。
十、安全实践
| 措施 | 实现 |
|---|---|
| 鉴权 | 统一认证中间件,保护资源路由 |
| 限流 | 按 IP / 用户维度限流(express-rate-limit) |
| 输入校验 | 声明式 schema 校验(zod / joi) |
| 参数白名单 | 查询与 body 只接受约定字段 |
| 敏感信息脱敏 | 响应中剔除 passwordHash 等字段 |
| 安全响应头 | helmet 中间件 |
javascript
const { z } = require("zod");
// 声明式校验:类型错误、缺字段在入口拦截
const createUserSchema = z.object({
name: z.string().min(1).max(30),
email: z.string().email(),
age: z.number().int().min(0).max(150).optional(),
});
router.post("/users", (req, res, next) => {
const parsed = createUserSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(422).json({
error: { code: "INVALID_PARAM", message: "参数校验失败", details: parsed.error.issues },
});
}
// parsed.data 是类型安全、清洗过的数据
next();
}, createUser);API 设计自检清单:URL 用复数名词;方法语义正确;状态码准确(尤其 401/403);分页排序筛选齐全;错误结构统一;版本化;幂等性有保障;文档可访问;敏感数据不泄漏。