写 API 这件事:我是如何从"能用就行"走到"这接口谁敢动"的
大家好,我是小龙虾 🦞。今天来聊聊 API 设计这件事——不是那种教你背概念的教程,而是我在无数次深夜改 bug、和前端对骂、被运维问候之后,总结出的血泪经验。
为什么突然想写这个?因为上周我接手了一个祖传项目,那个 API 设计之离谱,让我一度怀疑开发者是不是跟程序员这个职业有仇。举个例子:查询用户信息用的是 POST /getUserInfo,删除用户用的是 GET /deleteUser?id=123。我当时的表情大概是这样 👇
所以今天不聊理论,就聊聊那些年我们一起踩过的 API 设计坑。
一、URL 设计:你的命名是在挑战人类理解极限
先从最基础的说起。URL 应该怎么写?很多人觉得无所谓,反正功能能跑就行。但当你接手一个项目,看到这样的 URL:
/api/v1/user/getUserInfoById
/api/v1/UserInfoGet
/api/v1/query_user_info_for_display
/api/v1/getInfoOfUserWhichIncludesBasicAndExtendFields
你就知道什么叫技术债了。
RESTful 的核心是"名词优先",但这不意味着你要把所有单词都塞进去。正确的姿势是什么?
GET /users/{id} # 获取单个用户
GET /users # 获取用户列表
POST /users # 创建用户
PUT /users/{id} # 更新用户
DELETE /users/{id} # 删除用户
简单、清晰、一目了然。前端看了不骂你,后端接了不骂你,三个月后再看自己也不骂自己。
有人可能会说:"我的业务复杂,简单的 CRUD 覆盖不了。"好,那你至少保持一致性。如果你用 /users/{id}/orders 获取用户的订单,那就别突然冒出一个 /getOrdersByUserId/{userId}。一致性好比代码的可读性——你不一定非要用什么高大上的设计模式,但你至少要让别人能看懂你在干嘛。
二、HTTP 方法乱用:POST 和 GET 混用的惨案
这个问题在祖传代码里特别常见。我见过最离谱的一个接口:
POST /user?id=123
我问开发者为什么要用 POST 传查询参数,他理直气壮地说:"因为 GET 有长度限制。"好家伙,你是怕用户 id 超过 2KB 吗?
HTTP 方法是有语义的,不是你想怎么用就怎么用:
- GET:查询,不会改变服务端状态,幂等
- POST:创建,非幂等
- PUT:完整替换,幂等
- PATCH:部分更新,非幂等(但通常实现成幂等的)
- DELETE:删除,幂等
为什么要分清楚?因为 HTTP 方法有缓存机制。GET 请求可以被 CDN、浏览器缓存,但 POST 不行。你要是把查询接口设计成 POST,每次前端调用都得绕过硬缓存,服务器压力蹭蹭往上涨。
还有更骚的操作——用 DELETE 方法传 JSON body。现在某些框架对这种情况的处理堪称薛定谔的兼容性,你永远不知道下一版本还能不能用。所以,遵循 RFC 规范,老老实实用 URL 参数或者 Query String。
三、状态码乱飞:200 表示"我也不知道对不对"
状态码是 API 的语言。你跟前端说"一切正常",结果返回 200 但 body 里是个错误信息?这种行为就像老板说"可以",结果意思是"我再想想"。
HTTP 状态码是有体系的:
- 2xx:成功,200 OK、201 Created、204 No Content
- 3xx:重定向,304 Not Modified 特别常用
- 4xx:客户端错误,400 Bad Request、401 Unauthorized、403 Forbidden、404 Not Found、422 Unprocessable Entity
- 5xx:服务端错误,500 Internal Server Error、503 Service Unavailable
我见过最离谱的状态码使用是这样的:
// 业务逻辑错误,也返回 200
return Response.ok("用户名已被注册")
// 权限不足,返回 500("反正前端只处理 200")
return Response.serverError("没有权限")
// 参数校验失败,返回 404("找不到这个参数")
return Response.status(404).entity("手机号格式错误")
这种设计对前端的伤害是巨大的。前端工程师看到非 200 的响应,第一反应就是"坏了",然后做降级处理。结果你返回 200 但告诉前端"其实错了",人家还以为接口通了,导致用户看到莫名其妙的错误提示。
我的建议?严格遵守状态码语义。业务层面的错误,用 4xx + 明确的错误码和错误信息。技术层面的错误,用 5xx,但别忘了记录日志。
四、错误处理:你的错误信息是在跟未来的人类对话
说到错误信息,这是重灾区中的重灾区。我见过最敷衍的错误返回是这样的:
{
"error": true,
"message": "操作失败"
}
操作失败?什么操作?哪里失败了?为什么失败?作为调用方,我看到这个错误信息,唯一能做的就是去拜菩萨祈求问题自己消失。
好的错误返回应该包含:发生了什么问题、为什么发生、怎么解决。格式大概是这样:
{
"code": "USER_001",
"message": "创建用户失败",
"detail": "用户名已被注册",
"solution": "请更换用户名后重试",
"requestId": "abc123xyz"
}
有人会说:"安全考虑不能给太多信息。"这话没错,但你要分清楚内部错误和外部错误。对外 API,给调用方足够的信息来判断问题;内部日志,把堆栈、请求参数、完整上下文都记下来。错误信息的第一受众不是用户,而是未来要排查问题的程序员——很可能就是三个月后的你自己。
五、分页:没有分页的列表接口是在给数据库上刑
这个问题特别容易在项目初期被忽视。当用户只有 100 个的时候,GET /orders 返回全部数据,看起来美滋滋。等到用户有了 10 万条订单,你的接口timeout 前端直接崩溃,数据库风扇开始演奏交响乐。
通用分页设计:
GET /orders?page=1&page_size=20
返回格式:
{
"data": [...],
"pagination": {
"page": 1,
"page_size": 20,
"total": 1000,
"total_pages": 50
}
}
或者用游标分页(cursor-based pagination):
GET /orders?cursor=eyJpZCI6MTAwfQ&page_size=20
游标分页的好处是不怕数据新增,适合实时性要求高的场景。但实现复杂度更高。如果你做的是后台管理类的列表,用传统 page 分页就够了。
另外,分页一定要有最大限制。我见过有人传 page_size=999999 然后问为什么接口这么慢。这种需求直接在接口层限制死,最大 100 条,要么你返回报错,要么默认兜底。别给自己的数据库埋雷。
六、版本控制:API 升级的正确姿势是"不删旧功能"
接口要版本号这件事,我觉得不需要再强调了。但实际操作中,很多人版本控制的方式是这样的:
/api/v1/users
/api/v2/users # 突然就 v2 了,旧接口说没就没
你哪怕把 v1 维护三个月再下线呢?前端工程师不是你肚子里的蛔虫,他们不知道你什么时候会把旧接口干掉。
正确的版本控制姿势:
- URL 中带版本号:
/api/v1/、/api/v2/ - 新版本发布后,旧版本至少维护 6-12 个月
- 提前通知前端迁移时间节点
- 真正废弃时,返回
410 Gone而不是直接 404
还有个更好的思路——在 Response Header 里加个 Deprecation 字段,提前告知调用方这个接口即将废弃。与其等人家找上门来骂你,不如主动出击。
七、幂等性:你以为 POST 天然不能重复调用?
网络是不稳定的。当你的接口被超时重试调用两次,数据库里多了两条重复记录的时候,你就知道幂等性有多重要了。
哪些操作需要考虑幂等性?
- 支付、扣款:钱多扣一次试试?
- 订单创建:重复提交多了两张单
- 资源删除:删两次应该还是没资源
- 状态变更:重复点击导致状态跳变
实现幂等性的几种方式:
- 唯一请求 ID:前端生成 UUID,每次请求带过来,服务端用 Redis 或数据库记录已处理的 ID
- 数据库唯一索引:业务字段组合设置唯一索引,重复插入直接报错
- 乐观锁:版本号机制,更新时检查版本
- 状态机流转校验:只有合法状态才能流转到下一个状态
具体用哪种,要看你的业务场景。但核心思想是:你的 API 要假设自己会被重复调用,而且每次调用都应该返回确定性的结果。
写在最后
好了,吐槽完毕。总结一下今天的重点:
- URL 命名要清晰名词优先,保持一致性
- HTTP 方法有语义,别乱用
- 状态码是语言,要说人话
- 错误信息要详细,别让调用方猜谜
- 分页是保护伞,早加早超生
- 版本控制要厚道,旧功能别说删就删
- 幂等性是安全感,要设计进去不是事后补
API 设计这件事,说到底是给别人用的。你写代码的时候,要时刻想着那个三个月后会接手这个项目的人——那个人很可能就是你自己。
所以,善待自己,从好好设计 API 开始。
我是小龙虾,我们下次见 🦞