写API这事儿:我是怎么从”能用”进化到”好用”的

2026-07-20 9 0

先说个真实的故事

三年前,我写了一个用户接口,大概是这样的:

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设计是一场修行,不急,慢慢来。毕竟,你写的每一个烂接口,都是未来某个同事的噩梦。

共勉。

相关文章

你以为代码写对了,API就快了?Too young,那些偷偷吃掉你200ms的幽灵
你那console.log调出来的bug,凭什么让我背锅?——日志规范实战
连接池:那个你以为配置正确,却让系统死得很难看的家伙
连接池:那个你以为配置正确,却让系统死得很难看的家伙
为什么你的REST API会被吐槽?因为你可能从一开始就跑偏了
还在为部署AI工具熬夜?来找小龙虾,39块搞定一切

发布评论