RESTful API 设计翻车现场:我踩过的那些坑,你们千万别踩
干了这么多年后端,写过的 API 没有一千也有八百了。从最初的「能跑就行」到现在的「强迫症式设计」,中间隔着无数个深夜 Debug 和线上事故。今天不整那些虚的,就聊聊我在 API 设计上犯过的蠢错误,以及怎么避开这些坑。
如果你正在设计 API,或者觉得自己写的 API 挺美的,建议看完——说不定你正在犯同样的错。
坑一:把 HTTP 状态码当摆设
这是最常见的问题。我见过太多接口返回 200 然后在 body 里塞个 {code: 500, msg: "服务器挂了"}。兄弟,你这是掩耳盗铃啊!
HTTP 状态码是 HTTP 协议给开发者的礼物,它让调用方可以在不了解响应体的情况下判断请求结果。你的代码可能是这样的:
// 错误示例
app.get("/user/:id", async (req, res) => {
const user = await db.findUser(req.params.id);
if (!user) {
return res.status(200).json({
code: 404,
message: "用户不存在"
});
}
res.json(user);
});
// 正确示范
app.get("/user/:id", async (req, res) => {
const user = await db.findUser(req.params.id);
if (!user) {
return res.status(404).json({
message: "用户不存在"
});
}
res.json(user);
});
前者的问题是:调用方看到 200,以为一切正常,结果解析 body 发现 code 是 404,还得再写一堆错误处理逻辑。后者呢?调用方一个 .catch() 就能搞定所有错误情况。
记住:2xx 是成功,4xx 是客户端问题,5xx 是服务端问题。别跟 HTTP 协议对着干。
坑二:RESTful 动词乱用
很多人知道 RESTful 要用 GET、POST、PUT、DELETE,但实际写出来的东西跟 RESTful 半毛钱关系没有。比如这样的:
// 披着 RESTful 皮的非 RESTful API
POST /api/getUser // 获取用户
POST /api/deleteUser // 删除用户
POST /api/updateUser // 更新用户
这不叫 RESTful,这叫「把 HTTP 当传输协议用的 RPC」。真正的 RESTful 应该是:
GET /users // 获取用户列表
GET /users/:id // 获取单个用户
POST /users // 创建用户
PUT /users/:id // 更新用户
DELETE /users/:id // 删除用户
名词用复数,动词靠 HTTP 方法。简单、清晰、一目了然。调用方看到 URL 就知道在干啥,都不用看文档(当然,理想情况下)。
坑三:分页参数随心所欲
「页码从 0 开始还是从 1 开始?」「每页大小叫 pageSize 还是 limit 还是 page_size?」这种问题能让你和前端吵一整天。
我的建议是:用游标分页,别用页码分页。为什么?
页码分页的问题在于:如果在翻页过程中数据被插入或删除,你的数据会重复或缺失。用户体验就是:我明明没点「下一页」,怎么看到的数据跟上一页有重复?
// 传统的页码分页(有问题的)
GET /articles?page=2&pageSize=20
// 游标分页(推荐方案)
GET /articles?cursor=eyJpZCI6MTAwfQ&limit=20
// 返回: { data: [...], nextCursor: "eyJpZCI6MTIwfQ", hasMore: true }
游标分页的好处是:不管数据怎么变,分页结果始终是连续的。当然,如果你列表页有「跳转到第 X 页」的需求,那只能用页码分页——但那种需求真的常见吗?
坑四:不做版本管理
「我这 API 肯定不会变!」——说这话的人,三天后就会哭着改字段。
API 版本管理是必须项。常见的做法有三种:
- URL 路径版本:
/api/v1/users、/api/v2/users - Header 版本:
Accept: application/vnd.example.v2+json - Query 参数版本:
/api/users?version=2(不推荐,违反幂等性)
我建议用第一种,URL 清晰、调试方便、Nginx 转发也容易。虽然看起来丑,但实用才是第一位的。
版本升级的原则:v1 能用就别动,v2 是全新的,v1 迟早要下线。别搞出一个 v1.5、v1.6 出来,没人记得住哪些字段在哪个版本存在。
坑五:返回数据「太贴心」
有些接口喜欢返回这种结构:
{
"success": true,
"message": "操作成功",
"data": {
"id": 1,
"name": "张三"
}
}
如果 success 是 true,message 就是废话;如果 success 是 false,data 就是废话。这种「双向冗余」的设计,本质上是对 HTTP 状态码的不信任。
更好的做法:
// 成功:HTTP 200,直接返回数据
{ "id": 1, "name": "张三" }
// 失败:HTTP 4xx/5xx,返回错误信息
{ "message": "用户不存在", "code": "USER_NOT_FOUND" }
一个请求要么成功要么失败,用 HTTP 状态码区分就够了。success 字段?删了吧。
坑六:忽视安全——CORS 和认证
很多新手写 API 时为了「调试方便」,会这样配置 CORS:
app.use(cors({
origin: "*" // 生产环境千万别这样写!
}));
我知道你想快速验证功能,但线上环境这样搞,等着被 CSRF 攻击吧。
正确的做法:明确允许的 origin 列表,或者使用 token 验证。说到 token,又是一个大坑——有人把 token 放 URL 里(?token=xxx),有人明文传输密码,有人 JWT 不设过期时间……
安全无小事,每个字段都值得你认真对待。Authentication 和 Authorization 是两个概念,前者是证明你是谁,后者是证明你能干什么。别搞混了。
坑七:接口文档靠「嘴」
「接口文档?我脑子记着呢!」——这种人一般在第三版需求后就忘了第一版长什么样了。
强烈建议用 OpenAPI (Swagger) 规范来定义 API。它能自动生成文档、提供调试界面、甚至能生成客户端代码。
openapi: 3.0.0
info:
title: 用户 API
version: 1.0.0
paths:
/users/{id}:
get:
summary: 获取用户信息
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
"200":
description: 成功
content:
application/json:
schema:
$ref: "#/components/schemas/User"
写文档一时爽,一直写一直爽。等你需要对接第三方、或者新人接手项目时,你会感谢当初写文档的自己。
坑八:忽视性能——N+1 查询
这个问题在做关联查询时特别常见。比如要获取用户列表及其订单:
// 错误示例:N+1 查询
app.get("/users", async (req, res) => {
const users = await db.query("SELECT * FROM users");
// 每个用户都要再查一次订单
const usersWithOrders = await Promise.all(
users.map(user => {
const orders = await db.query(
"SELECT * FROM orders WHERE user_id = ?",
[user.id]
);
return { ...user, orders };
})
);
res.json(usersWithOrders);
});
100 个用户?101 次数据库查询。数据库连接被打满,用户等得花儿都谢了。
// 正确做法:JOIN 或者批量查询
app.get("/users", async (req, res) => {
const users = await db.query("SELECT * FROM users");
const userIds = users.map(u => u.id);
// 一次查询获取所有相关订单
const orders = await db.query(
"SELECT * FROM orders WHERE user_id IN (?)",
[userIds]
);
// 内存中组装数据
const orderMap = orders.groupBy(o => o.user_id);
const usersWithOrders = users.map(user => ({
...user,
orders: orderMap[user.id] || []
}));
res.json(usersWithOrders);
});
2 次查询 vs 101 次查询,性能差距自己体会。
坑九:错误信息「太技术」
见过这种错误返回吗?
{
"error": "NullPointerException at com.example.service.UserService.getUser(UserService.java:45)"
}
这种错误信息给谁看?给用户看?用户一脸懵。给自己看?堆栈信息应该记日志,不应该返回给调用方。
正确的错误响应应该是:
{
"error": {
"code": "USER_NOT_FOUND",
"message": "找不到这个用户,可能是 ID 输错了?",
"reference": "https://api.example.com/docs/errors#USER_NOT_FOUND"
}
}
用户看得懂,开发者能定位问题,还提供了文档链接——这才叫专业的错误处理。
坑十:API 变更不留记录
很多团队的 API 变更记录就是「这次改了个字段」。等线上出问题,一查才发现三个月前有人改了个字段名,导致部分调用方数据异常。
建议用 Changelog 记录每个版本的变更:
## v2.3.0
### 新增
- `/orders` 支持按状态筛选:`?status=pending`
### 废弃
- `/users/:id/friends` 将于 v3.0 移除,请改用 `/users/:id/contacts`
### 修复
- 修复了分页时数据不连续的 BUG
### 变更
- `User.avatar` 字段类型从 string 改为 object(包含 url, size, format)
这样调用方能清楚知道哪些地方需要适配,升级成本可评估。不用 changelog 的团队,API 迟早烂掉。
写在最后
API 设计这事,说难不难,说简单也不简单。难的地方不在于用什么技术,而在于怎么在「功能完整」「性能优秀」「易于维护」「用户体验好」这几个维度里找到平衡。
以上十坑,是我这些年踩过的真实教训。有些是自己作死,有些是赶工期妥协,但不管是哪种,回头看都是「早知道就好了」的时刻。
如果你正在设计新 API,对照着检查一下,说不定能少走几年弯路。如果你发现正在犯其中的某个错——恭喜你,这篇文章来得正是时候。
API 设计是一场修行,且写且珍惜。各位道友,共勉。