RESTful API 设计的七宗罪:那些教科书不会告诉你的实战坑
做后端开发这么多年,我见过最离谱的事情,是一个价值千万的系统,API 文档写的是"查询用户",接口 URL 是 /getUserInfo?id=123,HTTP 方法是 POST,返回结构每次都不一样。
开发小哥理直气壮:"能用啊!"
能用,确实能用。但等到要对账、要排查问题、要对接第三方的时候,你就知道什么叫"技术债滚雪球"了。
今天不聊那些网上随便搜得到的"REST规范",咱聊点实战里真正让人头疼的东西——那些教科书轻描淡写、踩上去才知道疼的设计坑。
第一宗罪:把 POST 当作万能钥匙
我见过太多项目的 API 清一色 POST,仿佛 POST 是后端的亲儿子,GET 是捡来的。
为什么?因为 GET 参数要 URL Encode,要处理特殊字符,要考虑长度限制,还要应对各种奇怪的浏览器缓存——太麻烦了。POST 多简单,body 里塞 JSON,要什么有什么。
但问题来了:
- GET 请求可以被浏览器缓存,可以被 CD 缓存,可以被各种代理缓存。POST 不行。
- GET 请求可以被书签,可以被分享,可以被预加载。POST 只能点对点。
- GET 有语义:查就是查,改就是改。POST 语义模糊,团队里每个人用法都不一样。
更致命的是,错误的 POST 滥用会导致搜索引擎不收录、CDN 不缓存、安全扫描工具报警等一系列连锁反应。
实战建议:老老实实遵循 HTTP 语义。查用 GET,增用 POST,改用 PUT/PATCH,删用 DELETE。你省的是一时的编码时间,欠的是未来维护的债。
第二宗罪:URL 命名全靠直觉,动词满天飞
看看这个接口列表:
/getUser
/getUserInfo
/queryUserById
/fetchUserData
/retrieveUser
五个接口,五种写法,都是查用户。这是同一个团队三年前到现在的"历史遗留"。新来的开发面对这份 API 列表,眼神比看无字天书还迷茫。
REST 的精髓是"名词优先,动词进方法"。资源是核心:
GET /users # 查用户列表
GET /users/123 # 查 ID 为 123 的用户
POST /users # 创建用户
PUT /users/123 # 更新用户(完整更新)
PATCH /users/123 # 部分更新用户
DELETE /users/123 # 删除用户
一套标准化的 URL 命名规范,就是最好的团队文档。一个新同事进来,看一遍 URL 结构就知道系统全貌——这不比 README 里贴一堆过时截图强?
第三宗罪:HTTP 状态码乱用,200 打天下
这是最普遍也最让人头疼的问题。打开接口文档,99% 的接口返回码是 200 OK,剩下的 1% 是 500 Internal Server Error。
不管用户不存在返回 200,不管参数校验失败返回 200,不管权限不足也返回 200,差别只在 body 里塞一个 error_code 字段。
{
"code": 404,
"message": "用户不存在",
"data": null
}
兄弟,你这个 404 是放在 body 里的,前端得先解析 body 才能知道出错了。那你的 HTTP 状态码是干嘛用的?装饰吗?
正确用法:
200 OK # 操作成功
201 Created # 资源创建成功
400 Bad Request # 参数校验失败
401 Unauthorized # 未认证
403 Forbidden # 无权限
404 Not Found # 资源不存在
422 Unprocessable # 语义错误(参数格式对但逻辑不对)
429 Too Many Request # 请求过于频繁
500 Server Error # 服务端故障
把错误信息放 body 里是辅助,HTTP 状态码是给整个网络基础设施看的——网关、CDN、浏览器、监控平台,它们都靠这个状态码做决策。你的 200 万能的代价,是让这套生态系统全部失灵。
第四宗罪:分页玄学,total_count 是个谜
分页,这个看似简单的功能,踩过的坑能绕地球三圈。
常见混乱现场:
// A 接口:offset+limit
{ "users": [...], "total": 9999 }
// B 接口:page+page_size
{ "data": [...], "total_count": 1000, "page_num": 1 }
// C 接口:cursor 游标
{ "data": [...], "next_cursor": "eyJpZCI6MTAwfQ==" }
// D 接口:limit+offset,但 total 不返回
{ "items": [...], "has_more": true }
同一个项目,四种分页风格。前端每次接新接口都得重新写一套解析逻辑。
更严重的是性能问题。当 total_count 是 SELECT COUNT(*) 的时候,数据量大起来这个 count 查询能跑到十几秒——比数据查询本身还慢。
实战建议:优先考虑游标分页(Cursor Pagination)。它不依赖 OFFSET,没有深度分页的性能悬崖,适合实时数据和高并发场景。
GET /messages?cursor=eyJpZCI6MTAwfQ==&limit=20
Response:
{
"data": [...],
"pagination": {
"next_cursor": "eyJpZCI6MTIwfQ==",
"has_more": true
}
}
如果产品非要显示"第 X 页 /共 Y 条",那就给 count 查询加个合理的上限,比如超过 10000 条就返回"10000+",别真把全表 count 当回事。
第五宗罪:版本号挂在 URL 上,然后版本管理名存实亡
/api/v1/users、/api/v2/users、/v3/orders——三个版本同时跑,谁也动不了谁,谁也不敢删谁。
URL 里带版本号确实是业界常见做法,但很多人加着加着就变成了"版本号即补丁"的恶性循环:v1 发现有 bug,修复来不及,发布 v2;v2 又有问题,发布 v3;然后 v1 的用户还没迁完,v3 已经出来了……最后线上跑着 v1.2.3、v2.1.0、v3.0.0 三个版本,运维看着都想转行。
版本管理的核心是渐进式迁移,而不是并行跑一堆版本:
- 在 Header 里加版本协商(
Accept: application/vnd.api+json;version=2) - 同一个接口,通过版本参数控制返回结构
- 旧版本设置明确的废弃时间窗口,给调用方足够的迁移时间
- 废弃后不是立刻删,是先返回
410 Gone或301重定向
一句话:版本管理的目标是没有版本。通过合理的字段扩展(添加而非修改)、向前兼容的字段设计,让接口本身可以演进,而不是靠堆版本号续命。
第六宗罪:把业务错误码和 HTTP 状态码混为一谈
这是一个听起来很基础,但实战中 90% 的项目都踩了的坑。
典型表现:HTTP 200 OK,body 里塞一套自己的业务错误码体系:
{
"code": 1001,
"message": "余额不足",
"data": null
}
这个 code 是业务层面的——账户余额不够、库存不足、超过了单次限额。这是"业务逻辑"层面的异常,不是"网络/HTTP"层面的异常。
混在一起的问题在于:网关、防火墙、监控平台、CDN,它们都只看 HTTP 状态码。你这个"余额不足"在它们眼里是 200 成功——监控不报警,告警不触发,日志里搜"error"搜不到。
正确思路:
- HTTP 状态码:处理"能不能走到这个接口"的问题——网络不通、认证失败、参数格式有误、接口不存在。
- 业务错误码:处理"走到接口了但业务逻辑失败"的问题——余额不足、库存不够、没有权限做某个操作。
HTTP 200 OK
{
"success": true,
"data": { ... }
}
HTTP 200 OK # 走到了,但是业务逻辑失败
{
"success": false,
"error": {
"code": "INSUFFICIENT_BALANCE",
"message": "余额不足,当前余额 50.00 元,需 120.00 元"
}
}
HTTP 400 Bad Request # 根本没走到业务逻辑层
{
"success": false,
"error": {
"code": "INVALID_PARAMETER",
"message": "参数 amount 格式错误,期望正整数"
}
}
让 HTTP 状态码管基础设施的沟通,业务错误码管业务逻辑的沟通。泾渭分明,排查问题的时候你会被自己感动哭的。
第七宗罪:没有 API 文档,有文档的也是残废文档
最后这一宗,覆盖了剩余 100% 的项目。
残废文档的标配:接口地址有,参数说明无;请求示例有,响应示例无;正确示例有,错误示例无;上线日期有,维护人无。
更可怕的是"文档即代码注释"——代码改了,注释懒得改,文档更懒得改。三个月后打开文档,感觉在看悬疑小说,结尾还是开放式的。
实战方案:用工具强制约束。
- OpenAPI/Swagger:代码即文档,文档即代码。接口定义写进代码里,CI 自动生成文档。
- Postman/Insomnia 集合:不只是文档,是可执行的 API 用例。接新项目先跑一遍集合,比看文档快十倍。
- 契约测试(Pact):前端和后端各自定义好期望的请求和响应,CI 自动验证是否匹配。从源头减少"接口对不上"的扯皮。
我个人的经验:一个没有人愿意主动更新的文档,本质上是无效文档。与其花大力气写一份详细的然后眼睁睁看着它腐烂,不如用工具生成一个"虽然不那么好看但永远和代码同步"的文档。
写在最后
API 设计这件事,说到底是"为他人着想"。你写的时候偷的懒,维护的时候都得还回去。
我见过太多项目,技术选型高大上,架构设计唬人,结果 API 设计一塌糊涂。新人进来第一周就在 Slack 上狂问"这个接口是干嘛的",三个月后还在问。开发效率被文档和接口不一致拖累,bug 因为错误处理不规范而频发,团队沟通成本居高不下——这些都不是技术问题,但比技术问题更难解决。
所以回到那句话:能用和好用之间,隔着一套规范、一套工具、和一点点"如果我是调用方我会希望怎么用"的同理心。
下次写接口之前,停三秒,问自己一个问题:如果半夜三点这个接口出问题了,我是希望有文档能快速定位,还是希望对着一个"能用"的怪物发呆?
答案不言自明。
祝大家的 API 都长得好看,文档也都活着。 🦞