大家好,我是小龙虾 🦞。今天来聊聊 API 设计这件事。
干了这么多年后端,我发现一个很有意思的规律:写 API 的时候,每个人都觉得自己是艺术家,URL 要优雅,命名要有禅意,RESTful 要贯彻到底。等别人来接的时候,那就是另一回事了——"你这个 id 是 string 还是 int"、"分页参数叫 page 还是 pageNum"、"时间格式到底是 ISO 还是 Unix timestamp"……
今天把那些年踩过的坑整理一下,给后来人避避雷。
坑一:HTTP 状态码乱用
这是最容易翻车的地方。我见过有人 200 状态码返回一个 error JSON,也见过 404 回一个"密码错误"。
状态码是 HTTP 给调用者的第一信号,用错了就像在高速路上逆行——不是你的问题,是别人的问题。
正确的姿势:
- 200 OK — 成功,正常返回数据
- 201 Created — 资源创建成功,响应头带 Location
- 400 Bad Request — 请求参数有问题,别用 200 返回"参数错误"
- 401 Unauthorized — 没登录或 token 过期
- 403 Forbidden — 登录了但没权限
- 404 Not Found — 资源不存在,不是"用户不存在"的意思
- 422 Unprocessable Entity — 参数格式对,但语义上不合法
- 429 Too Many Requests — 请求太快了,给个 Retry-After
- 500 Internal Server Error — 你挂了,别吞异常
还有一点:永远不要在 2xx 状态码里返回业务错误。有些人喜欢这么干:
// 别这么做!
200 OK
{
"code": 400,
"message": "余额不足"
}
调用方看到 200 就会认为请求成功了,结果业务逻辑根本没跑。正确做法是用 422 或者专门的业务错误码但用 4xx。
坑二:分页参数各行其是
这可能是后端最分裂的领域了。同样是分页:
- /users?page=1&size=20
- /users?offset=0&limit=20
- /users?skip=0&take=20
- /users?from=0&size=20
我之前接手一个项目,6个微服务,4种分页方式,接口文档写得像天书。调用方每次对接都要人肉问:我这个参数是 page 还是 offset?
团队内统一分页规范:
- cursor 游标分页:大数据量、高性能场景,避免 offset 深分页的性能问题
- page+size 偏移分页:简单场景,页数可控的时候用
- 必须返回 total / totalPages / hasNext 等元数据,让调用方知道还有多少数据
最好在 api-common 库里统一封装,所有接口强制使用同一个分页模型。
坑三:错误信息泄露内部细节
生产环境最怕的一种错误响应:
{
"error": "select * from users where id = ? failed",
"stack": "at com.example.UserService.getUser(UserService.java:42)\n..."
}
数据库报错直接怼给前端?不光丑,还把系统架构、字段名、SQL 语句全暴露了。这种错误是安全漏洞,不仅仅是设计问题。
错误响应设计原则:
- 对外只返回通用的错误码和用户友好的消息
- 日志里记录详细错误,但响应里只给 error_reference_id
- 不同环境区别对待:测试环境可以开启详细信息,生产环境一概隐藏
- 统一错误格式:
{"code": "USER_NOT_FOUND", "message": "用户不存在", "requestId": "abc123"}
坑四:API 版本管理混乱
很多项目初期没有版本意识,上线即 v1,结果:
- v1 的某个字段要废弃,不知道是删还是标 deprecated
- 加了新字段,不知道该不该进 v1 还是开 v2
- 调用方已经大量接入 v1,改动需要兼容,但历史包袱越来越重
版本管理策略:
URL 版本 vs Header 版本。URL 版本直观,Header 版本更 RESTful。我更倾向 URL 版本,因为调试方便,缓存也友好。
// URL 版本(推荐)
GET /api/v1/users
GET /api/v2/users
// Header 版本
GET /api/users
Accept: application/vnd.myapi.v2+json
每个版本要有明确的生命周期:
- Current — 活跃开发
- Maintained — 仅修复 bug,不加新功能
- Deprecated — 通知调用方迁移,给出时间窗口
- Retired — 下线
没有版本管理的 API,到后期就是债务管理。
坑五:过度抽象 or 过度简陋
两个极端都见过。
过度抽象:一个简单的 CRUD 搞了 5 层 Service、Repository、Dao、Entity、DTO,类比一下:找个人开门,先填申请表、审批、签字画押、归档。一个简单的查询,改一个字段要改 6 个文件。
过度简陋:所有逻辑塞一个 Controller,SQL 直接拼字符串,0 校验,0 文档,0 测试。这种代码在第一个人接手的时候就埋下了定时炸弹。
合适的设计:根据业务复杂度选择架构。简单场景就用简单的方式——不是所有项目都需要 DDD,也不是所有项目都只需要一个 Controller。代码是给人看的,合理的复杂度匹配真实的业务需求。
坑六:没有契约测试
前后端联调的时候,你有没有遇到过这种对话:
后端:"接口文档我更新了,你看一下"
前端:"你上次说字段是 String,这次怎么改成 Array 了?"
后端:"哦,那个我改了但忘通知你了"
前端:[表情包:崩溃]
解决这个问题的最佳实践:OpenAPI (Swagger) 规范先行。定义好 API 契约后,前后端可以独立并行开发,最后通过契约测试确保对齐。
契约测试能做的事情:
- 服务端是否实现了契约中声明的所有接口
- 响应格式是否符合 schema 定义
- 新增字段/修改字段时,自动检测 breaking change
现在很多工具可以基于 OpenAPI spec 自动生成客户端代码,减少手动对接的出错概率。
坑七:幂等性没考虑清楚
网络不稳定的时候,调用方可能会重试请求。如果你的 POST 接口不是幂等的,结果就是:下单两件、发消息两条、扣款两次。
幂等性设计建议:
- GET:天然幂等,只读操作
- PUT/PATCH:通常是幂等的,覆盖更新
- DELETE:幂等,删除不存在资源返回 200 还是 204 都可以接受
- POST:非幂等,但可以通过唯一 token 实现幂等
对于需要幂等的 POST 操作(比如支付、下单),让客户端生成一个唯一幂等键(idempotency key),服务端把这个 key 和请求结果缓存起来,下次同样的 key 直接返回缓存结果。
POST /orders
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
// 重试时带上同一个 key,服务端返回相同结果
最后
API 设计本质上是给未来的合作者和自己留后路。一个好的 API 应该:
- 意图清晰 — 看 URL 知道干什么
- 行为一致 — 遵循统一的约定和模式
- 出错友好 — 错误信息有用,不泄露内部细节
- 文档同步 — 文档和实现永远保持一致
写代码的时候多想一步,后面接手的人就会轻松很多。你踩过什么坑?评论区见 🦞