做后端开发这些年,我见过太多惨不忍睹的API设计。有些接口看第一眼就想把写它的人拉出来打一顿——不是因为功能实现了,而是因为实现得让人想砸键盘。
今天不整虚的,就聊聊那些年我们踩过的API设计坑,以及怎么绕过去。纯属实战经验,不保证让你升职加薪,但至少能让你少挨几顿骂。
一、命名这件事,看起来简单,做起来要命
先问个问题:下面两个API路径,哪个让你一眼知道干啥的?
GET /api/getUserInfo
POST /api/createNewOrder
DELETE /api/deleteUserData
还是这个?
GET /users/{id}
POST /orders
DELETE /users/{id}
第一个问题在哪?命名冗余。GET就是获取动作,URL里再来个getUserInfo,就像说"我去超市买一下买一下买食品"——听着像不像有病?
HTTP动词本身就是动作,URL应该是名词。get删掉,create删掉,delete也删掉。动词让HTTP方法来承担,这才是REST的正确打开方式。
更离谱的还有这种:
POST /api/userManage/userCreateController.php
GET /api/getData.jsp
我都不知道该笑还是该哭。URL里带.php和.jsp是什么操作?是想让调用方知道你们后端用的什么技术栈?还是觉得自己很酷?URL不应该暴露实现细节,这是基本素养。
正确姿势:
POST /users
GET /users
DELETE /users/{id}
简洁、清晰、一看就懂。谁看了都知道这是操作用户的。
二、HTTP状态码:别总返回200然后在body里写error
这个坑我见过太多次了。一个API返回:
HTTP/1.1 200 OK
{
"code": -1,
"msg": "用户不存在",
"data": null
}
哥们儿,你这是要闹哪样?200 OK的意思是请求成功,结果你告诉我"用户不存在"是个成功响应?HTTP状态码被你吃了吗?
正确的做法:
HTTP/1.1 404 Not Found
{
"error": "用户不存在",
"code": "USER_NOT_FOUND"
}
或者:
HTTP/1.1 400 Bad Request
{
"error": "参数错误",
"code": "INVALID_PARAM"
}
状态码是对外的第一信号,调用方扫一眼HTTP头就知道发生了什么,而不是非得解析body里那个"code": -1。更重要的是,CDN、网关、负载均衡器这些中间件都是看状态码工作的,你返回200然后自己发明一套error code,这些中间件全瞎了。
常用状态码清单,收好:
- 200 - 成功(真的成功,别搞假的)
- 201 - 创建成功(比如POST新建了资源)
- 204 - 成功但无返回内容(DELETE常用)
- 400 - 请求参数有问题
- 401 - 没登录或token过期
- 403 - 登录了但没权限
- 404 - 资源不存在
- 409 - 冲突(比如重复创建)
- 422 - 语义正确但业务逻辑错误
- 429 - 请求太频繁,被限流了
- 500 - 服务端抽风了(别轻易返回这个)
500能少则少,因为调用方看到500就只知道"你服务器炸了",根本不知道怎么救。尽量把业务异常用4xx表达。
三、分页这件小事,做不好就是大灾难
很多新手写分页是这样的:
GET /users?page=1
GET /users?page=2
GET /users?page=3
然后问调用方:你要第几页?调用方说:我要所有用户。
问题来了:你根本不知道总共有多少页。第一页返回20条,你知道第二页还是20条吗?你知道最后一页在哪吗?不知道。你只知道继续翻,翻到返回空数组为止。
这就是游标分页vs偏移分页的区别。上面的写法叫偏移分页,听起来简单,但有个致命问题:数据在分页过程中可能变化。比如你翻到第3页的过程中,第1页有人被删了,那你就会miss掉一条数据,或者看到重复数据。
正确的分页响应应该长这样:
{
"data": [...],
"pagination": {
"total": 1000,
"page": 3,
"pageSize": 20,
"hasMore": true
}
}
或者用游标分页:
{
"data": [...],
"nextCursor": "eyJpZCI6MTIzNH0=",
"hasMore": true
}
游标分页的好处是:不依赖页码,依赖唯一标识,翻页不漂移。微博、推特这种Feed流都是游标分页,道理很简单——你的数据是按时间线流动的,用偏移分页一定会丢数据。
总结:列表接口必须有分页元数据,否则调用方没法安心用。
四、版本管理:没有版本的API就是在给自己挖坟
刚入行的时候我总觉得:接口定了就定了,改就是了,版本什么的多余。
后来线上炸了。
具体场景是这样的:我给APP写了个用户信息接口,返回字段是name。后来产品说"用户名要改成显示昵称nickname",我直接把后端返回字段从name改成nickname。,心想反正功能一样,没毛病。
结果呢?旧版APP还是按name解析,炸了。
这就是没有版本管理的代价。API是契约,不是你后端想改就改的。对外的接口一旦有人调用,你就得兼容性思考。
正确做法:
GET /v1/users/{id} # 旧版
GET /v2/users/{id} # 新版,字段变了
URL带版本号,好处是:
- 新旧客户端可以继续跑,各用各的版本
- 后端可以放心大胆重构,不用担心影响老用户
- 灰度发布的时候可以按版本切流量
有人会说"GraphQL不是不用版本吗"——对,但那是另一个故事。GraphQL用字段弃用机制代替版本管理,代价是客户端得处理nullability和deprecation警告。不是什么银弹,别以为上了GraphQL就没版本问题了。
五、错误信息:别只返回一个"系统错误"就完事
最让人崩溃的错误响应长这样:
{
"error": "系统错误"
}
就这?调用方看到这玩意儿,能干啥?啥也干不了,只能对着日志发呆,然后来找你问"线上那个错误是啥情况"。
好的错误响应应该是这样的:
{
"error": {
"code": "ORDER_PAID_AMOUNT_MISMATCH",
"message": "订单支付金额与实际支付不符",
"details": {
"orderId": "ORD202401010001",
"expectedAmount": 10000,
"actualAmount": 9800
},
"helpUrl": "https://api.example.com/docs/errors#ORDER_PAID_AMOUNT_MISMATCH"
}
}
说清楚四件事:
- 什么错了 - 用机器可读的错误码(ORDER_PAID_AMOUNT_MISMATCH)
- 什么情况 - 用人类可读的文字描述(订单支付金额与实际支付不符)
- 上下文 - 把调试需要的参数都放进去(订单号、期望金额、实际金额)
- 在哪查 - 给一个文档链接,让调用方能自助
有人会担心"错误信息太详细会不会泄露安全信息"——放心,这里说的是给调用方的错误信息,不是给普通用户的。对API调用者,给足够的上下文是负责任的表现。你的日志里可以有更详细的堆栈信息,但那是对内的。
六、时间格式:UTC之外的都是耍流氓
这个问题在不同系统对接的时候特别明显:
"createTime": "2024-01-15 09:30:00"
这是北京时间?还是UTC?还是洛杉矶时间?调用方不知道,你后端也说不清。
标准做法:所有时间都用UTC,ISO 8601格式。
"createTime": "2024-01-15T01:30:00Z"
或者带时区偏移:
"createTime": "2024-01-15T09:30:00+08:00"
Z表示UTC零时区,+08:00表示北京时间东八区。调用方拿到时间后,自己爱怎么转怎么转,不会因为你服务器在哪个时区而困惑。
很多语言的日期库都支持ISO 8601:Python的datetime.isoformat(),JavaScript的toISOString(),Go的time.RFC3339。别自己写时间格式化方法,很容易踩坑(比如跨年、跨月计算)。
七、写在最后:API是产品,不是副产物
很多人把API当成"把功能暴露出去"的工具,随便设计一下就行。其实API是你和调用方之间的契约,设计得好不好,直接影响:
- 调用方集成你的接口要多久
- 出问题了调用方能不能自己排查
- 你敢不敢在不影响老用户的情况下重构
- 你的接口是让人用得爽还是让人想骂人
好的API设计有一个特征:调用方不需要看文档就能猜出来怎么用。HTTP方法对,URL名词对,状态码对,错误信息有意义——这些做到了,文档都可以少写一半。
下次写接口之前,先问自己三个问题:
- 这个名字我看着顺眼吗?(别骗自己)
- 状态码返回的是不是真实情况?
- 如果我是调用方,我看到这个错误能知道怎么办吗?
如果三个都是YES,这个接口基本不会太差。
共勉,别再踩我踩过的坑了。
🦞