一个真实的故事
上周五晚上11点,我被一个陌生电话吵醒。生产环境出问题了,某个第三方集成商反馈他们的系统完全调不通我们的API。
我揉着眼睛打开文档,翻到错误码那一页——1001。文档写着:系统内部错误,请联系管理员。
我人傻了。1001是什么?谁写的这个文档?为什么没有具体的错误信息?
最后查出来,是他们的IP白名单没配对。就这么一件小事,折腾了三个小时。
你的API,正在用同样的方式杀人。
问题一:错误响应是一坨屎
我见过最常见的错误响应是这样的:
HTTP/1.1 500 Internal Server Error
{
"message": "error",
"code": 500
}
???什么错误?哪个环节?请求ID有没有?堆栈信息呢?连个毛都没有。
或者这个经典款式:
{
"error": "Invalid parameter",
"error_description": "The request could not be understood by the server"
}
哪个参数无效?期望什么格式?实际收到了什么?完全靠猜。
正确的错误响应应该长这样:
HTTP/1.1 422 Unprocessable Entity
{
"error": {
"code": "VALIDATION_FAILED",
"message": "请求参数校验失败",
"request_id": "req_7f3a9c2d",
"details": [
{
"field": "email",
"code": "INVALID_FORMAT",
"message": "邮箱格式不正确",
"received": "user#example.com"
},
{
"field": "age",
"code": "OUT_OF_RANGE",
"message": "年龄必须在0-150之间",
"received": -5
}
]
}
}
看到区别了吗?谁、什么、为什么、怎么办,一目了然。收到这个错误的开发者,不用找你,直接自己修。
问题二:HTTP状态码是摆设
我见过太多项目,所有错误都返回200,然后body里塞个success: false。
这是什么操作?HTTP状态码是给你看的吗?是给中间件、网关、SDK、客户端框架看的!它们不认识你的body,只认识状态码。
标准状态码使用规范:
- 400 Bad Request — 请求语法错误,客户端自己有问题
- 401 Unauthorized — 没认证,去登录
- 403 Forbidden — 认证了但没权限,别挣扎了
- 404 Not Found — 资源不存在,别试了
- 409 Conflict — 状态冲突,比如重复创建
- 422 Unprocessable Entity — 格式对但语义错,参数校验失败
- 429 Too Many Requests — 限流了,等会再试
- 500 Internal Server Error — 我们的问题,已记录,会修
- 503 Service Unavailable — 暂时挂了,不是你的错
别TM所有错误都返回400或者500然后让客户端去解析body猜是什么意思。
问题三:没有版本控制,放任API腐烂
很多团队的API是这样的:上线一个版本,从来不改,因为改了要通知所有集成方,太麻烦了。
然后API就慢慢腐掉了:
- 字段越来越多,没人敢删
- 逻辑越来越乱,没人敢理
- 新来的人不敢动,老的人已经离职
API版本管理三板斧:
1. URL版本(最直观)
GET /api/v1/users
GET /api/v2/users
优点:浏览器直接访问,日志清晰,CDN缓存友好。缺点:版本多的时候维护成本高。
2. Header版本(更RESTful)
GET /api/users
Accept: application/vnd.myapi.v2+json
优点:URL干净,语义更准确。缺点:调试不方便,日志分析麻烦。
我的建议:URL版本够了,别为了装逼用Header版本。实际项目中,URL版本可读性好、调试成本低、缓存友好。Header版本听起来更标准,但给你的团队增加的实际成本远大于收益。
3. 演化式版本(最务实)
Link: <https://api.example.com/users>; rel="successor-version"
{
"api_version": "2024.01",
"deprecated": false,
"sunset_date": "2025-06-01"
}
加字段可以,减字段不行,改字段行为不行。这三条是API演化的铁律。一旦发布,打死不改。想改?发新版本。
问题四:分页是重灾区
我见过十几种分页实现,没有一个是一样的:
// 方案A:offset+limit
GET /users?offset=20&limit=10
// 方案B:page+page_size
GET /users?page=3&page_size=10
// 方案C:cursor游标
GET /users?cursor=eyJpZCI6MjB9&limit=10
// 方案D:since_id+count
GET /users?since_id=1000&count=10
更离谱的是返回格式也五花八门:
// A的返回
{ "data": [...], "total": 1000, "page": 3 }
// B的返回
{ "items": [...], "pagination": { "total": 1000, "page": 3 } }
// C的返回
{ "data": [...], "next_cursor": "eyJpZCI6MzB9", "has_more": true }
我的立场:能用游标分页就用游标分页。
为什么?offset分页在数据量大的时候性能会断崖式下跌——你让数据库跳过头10000条记录只取10条,数据库想骂人。游标分页基于主键,O(1)复杂度,不管翻到哪页性能都稳定。
而且游标分页对实时数据更友好:你在第一页看到一条新数据,第二页翻回来,它不会神奇消失。offset分页在数据有新增或删除时,会出现数据错位或者重复,很烦。
问题五:忽略Hypermedia,API没有导航
RESTful API最被人诟病的一点是:客户端需要提前知道所有可能的URL,完全没有导航能力。
其实规范早就给了答案——HATEOAS(Hypermedia as the Engine of Application State)。虽然完整实现很重,但你可以取其精华:
{
"id": 1001,
"name": "张三",
"email": "zhangsan@example.com",
"_links": {
"self": "/users/1001",
"orders": "/users/1001/orders",
"payment_methods": "/users/1001/payment-methods"
}
}
这有什么用?客户端只需要知道入口URL,后面的导航全部从返回内容里推导。后端改URL结构,前端几乎不用动——因为前端不写死URL,只读_links。
这才是真正的松耦合。
一个好的API响应,应该长这样
总结一下,给你看一个"及格线"以上的API响应:
HTTP/1.1 200 OK
Content-Type: application/json
X-Request-ID: req_7f3a9c2d
{
"data": {
"id": 1001,
"name": "张三",
"email": "zhangsan@example.com",
"created_at": "2024-03-15T10:30:00Z",
"_links": {
"self": "/users/1001",
"orders": "/users/1001/orders"
}
},
"meta": {
"request_id": "req_7f3a9c2d",
"api_version": "2024.01"
}
}
错误时:
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{
"error": {
"code": "VALIDATION_FAILED",
"message": "请求参数校验失败",
"request_id": "req_7f3a9c2d",
"details": [
{
"field": "email",
"code": "INVALID_FORMAT",
"message": "邮箱格式不正确",
"received": "user#example.com",
"expected": "user@example.com"
}
]
},
"meta": {
"request_id": "req_7f3a9c2d",
"api_version": "2024.01"
}
}
写在最后
API设计这件事,很多团队觉得"能用就行",把资源全压在业务功能上,API烂一点无所谓。
大错特错。
API是你给开发者的界面。开发者体验差,集成成本就高;集成成本高,合作意愿就低;合作意愿低,你的平台价值就缩水。
好的API设计不花钱,只是花心思。那些校验规则、错误信息、状态码、分页策略、版本管理——设计的时候多花一小时,实现的时候多省十小时调试。
下次你设计API的时候,想象一个开发者在凌晨两点接到报警电话,需要根据你的错误信息快速定位问题。
你会给他足够的弹药吗?