先说个真实的故事
三年前,我写了一个用户接口,大概是这样的:
GET /getUser?id=123
GET /queryUsers?type=vip&status=active&page=1&limit=20
POST /user/create
POST /user/update
POST /user/delete
产品经理看了半天,问了我一句:"你这个接口,怎么看着像是在跟数据库直接对话?"
我当时还挺不服气——能用不就行了吗?功能又没缺。
后来线上出了bug,一个用户能看到另一个用户的订单,我熬夜排查了两天,最后发现是因为我直接在前端拼接SQL,某个特殊字符绕过了校验。
那一刻我悟了:API设计烂,迟早要还的。
RESTful?我看是"RESTful"
现在面试必问RESTful,但真正能把REST设计好的,十个里面可能只有两个。我见过最离谱的一个接口是这样的:
POST /api/getUserInfoByIdAndNameAndEmail
POST /api/deleteUserById
POST /api/updateUserNameById
我问他为什么不用GET/DELETE/PUT,他说"这样更清晰"。
清晰个鬼。这不是API,这是用HTTP方法的花式炫技。
RESTful的核心就一句话:用正确的HTTP方法干正确的事。
GET /users - 获取用户列表
GET /users/123 - 获取ID为123的用户
POST /users - 创建新用户
PUT /users/123 - 完整更新用户
PATCH /users/123 - 部分更新用户
DELETE /users/123 - 删除用户
资源用名词,方法用动词,就这么简单。那些动词往URL里塞的,基本都是还没被坑够。
状态码:别再只返回200和500了
我见过太多接口,成功返回200,失败也返回200,然后在body里塞个code: "ERROR"。
产品经理问我:"这个接口为什么调用成功但数据是空的?"我说"因为业务上它是失败的,但HTTP状态码我们写的是200"。
他看我的眼神,比看初恋分手还复杂。
HTTP状态码是干嘛的?就是让调用方一眼分辨成功、失败、还是客户端问题。请把下面这些用起来:
200 OK - 请求成功,别犹豫
201 Created - 创建资源成功,POST后用这个
204 No Content - 删除成功,不用返回body了
400 Bad Request - 客户端参数有问题,别怀疑是服务端的事
401 Unauthorized - 没登录或者token过期了
403 Forbidden - 登录了但没权限,别装死
404 Not Found - 资源不存在,不是你的问题是我的问题
422 Unprocessable Entity - 参数格式对了但语义不对
500 Internal Server Error - 真的出问题了,不是前端的锅
有人说422有点多余,但有时候区分400和422很有用。比如你传了个邮箱格式完全正确,但这个邮箱已经被注册了——这是语义错误,不是格式错误。
分页:没有分页的列表接口都是耍流氓
早期的我写过这样的接口:
GET /getAllOrders // 返回所有订单,10000条
测试说慢,我加了索引。还说慢,我加了缓存。还说慢,产品经理说"你先回来我们谈谈人生"。
后来才知道,没有分页的列表接口,生产环境迟早出事。用户量大了,一页返回十万条,前端渲染卡死,后端内存爆炸,数据库直接升天。
标准的分页参数是这样的:
GET /orders?page=1&per_page=20
返回的时候,一定记得带这些字段:
{
"data": [...],
"pagination": {
"page": 1,
"per_page": 20,
"total": 1542,
"total_pages": 78,
"has_next": true,
"has_prev": false
}
}
有人喜欢用offset+limit,这个也没问题,但在大数据量下性能不如cursor分页。不过说真的,绝大部分场景page+per_page够用了,别过度设计。
错误处理:给调用方一条活路
错误响应这块,我踩过的坑比吃过的盐还多。早期我的错误返回是这样的:
{"error": "操作失败"}
调用方:"哪里失败了?"
我:"不知道。"
调用方:"..."
我:"..."
后来我学乖了,错误响应必须包含这些信息:
{
"error": {
"code": "USER_NOT_FOUND", // 业务错误码,调用方好判断
"message": "用户不存在", // 人类可读的错误描述
"detail": "请求的用户ID: 12345", // 额外上下文,方便排查
"request_id": "req_abc123" // 关联日志的追踪ID,这个最重要
}
}
request_id这玩意儿,平时觉得多余,线上出问题的时候,你会发现它是救命稻草。没有它,你只能在海量日志里玩大海捞针。
版本管理:别让旧接口死不瞑目
接口上线三个月,产品经理说"这个接口要改字段",开发说"有二十多个地方在用,改了要出事",最后决定"先加个新接口,老接口留着"。
一年后,这个系统有了4个版本:
/api/v1/users
/api/v2/users
/api/v3/users
/api/users // 这个是v1还是v4?没人记得了
每次发布新版本都是一场考古行动。
我的建议是:URL版本化,简单粗暴但有效。
/api/v1/users - 2024年1月前的旧接口,最低支持版本
/api/v2/users - 2024年6月重构后的版本,当前主力
/api/v3/users - 2025年3月加了新字段的版本
每个版本有明确的生命周期,EOL前6个月发通知,EOL后给3个月缓冲期,然后正式下线。这才叫有始有终。
安全:死在CSRF手里的项目比死在需求变更手里的还多
不好意思,这个夸张了。但API安全真的怎么强调都不为过。
基本要求:
1. 所有接口走HTTPS,别给明文传输留活路
2. 认证用JWT或者OAuth2,别再用用户名密码直接扔URL里了
3. 敏感操作二次验证,别让接口裸奔
4. 限流必须加,裸奔的接口分分钟被人爬光
5. 输入校验永远不要信任客户端,服务端必须再校验一次
关于第5点,我那个被特殊字符绕过的教训,还不够深刻吗?永远假设所有输入都是恶意的,只有这样才能活得更久。
文档:最好的文档是代码本身,但大多数人代码不够好
很多人写接口不写文档,理由是"代码即文档"。这话没错,但你确定你的代码够清晰?
GET /users/123/orders?status=paid&from=2024-01-01&to=2024-12-31&page=1&per_page=20&sort=created_at:desc&fields=id,amount,created_at
这种接口,参数十几二十个,不写文档你让调用方怎么猜?
我现在的标准是:每个接口必须有示例,包含请求和响应。如果用Swagger/OpenAPI,示例要能直接复制粘贴跑通。
写在最后
写API这件事,看起来简单,做好很难。它不像写业务逻辑那样有明确的完成标准,API是给别人用的,好不好用只有调用方知道。
我现在的习惯是:每写一个接口,先问自己几个问题:
- 别人第一眼能看懂这个接口是干嘛的吗?
- 出问题了,错误信息够不够定位问题?
- 10年后这个接口还能跑吗?(这个可能夸张了,但至少明年还能用吧)
- 我自己愿意当这个接口的调用方吗?
如果答案都是肯定的,那这个接口至少不会太差。
API设计是一场修行,不急,慢慢来。毕竟,你写的每一个烂接口,都是未来某个同事的噩梦。
共勉。