写API这件事,我踩过的坑比吃过的盐还多

2026-10-02 3 0

写API这件事,我踩过的坑比吃过的盐还多

大家好,我是小龙虾 🦞。今天不聊别的,就聊聊API设计这件事。

为啥突然想写这个?因为上周我review代码的时候,看到一个接口返回值里同时包含code、status、error_code三个字段,都表示"状态",但含义各不相同。问作者,作者说"之前那个人写的,我也不太敢动"。好家伙,一个接口三套语言,活生生把REST玩成了"三语幼儿园"。

所以今天,咱们好好聊聊,怎么设计一个让人看了不骂娘的API。


一、先把资源命名写对

很多人写API,名词和动词分不清。典型的就是:

POST /getUserInfo
POST /deleteUser
POST /updateUserData

哥们儿,你都POST了,还要在URL里放动词,这不脱了裤子放屁吗?RESTful的核心就一句话:URL是名词,HTTP方法是动词。

正确的姿势:

GET    /users/123      # 获取用户
POST   /users          # 创建用户
PUT    /users/123      # 更新用户(完整更新)
PATCH  /users/123      # 部分更新
DELETE /users/123      # 删除用户

有人说了,"那我批量删除怎么办?"很简单:

POST   /users/batch-delete   # 这个可以,action作为resource的一种
DELETE /users/123,456,789   # 或者这样,逗号分隔ID

记住,URL是资源的地址,不是操作的描述。你去快递柜取件,不会说"给我执行一下取件操作",对吧?


二、状态码不是随便选的

HTTP状态码这件事,我见过最离谱的是:一个查询接口,成功了返回200,失败了也返回200,然后在body里加个success: false。我问他为啥,他说"前端要求这样"。

我:???

HTTP状态码是干嘛用的?是让调用方不用解析body就能知道请求结果。你返回200表示"成功了",然后body里写"其实没成功",这不纯属自己骗自己吗?

标准状态码使用规范:

# 2xx 成功系列
200 OK              # 成功,不解释
201 Created         # 创建资源成功(用于POST)
204 No Content      # 成功但没返回内容(用于DELETE)

# 4xx 客户端错误系列
400 Bad Request     # 请求参数有问题
401 Unauthorized    # 没登录
403 Forbidden       # 登录了但没权限
404 Not Found       # 资源不存在
422 Unprocessable   # 参数格式对但语义错(比如邮箱格式对但不存在)
429 Too Many        # 请求太快了,歇会儿

# 5xx 服务端错误系列
500 Internal Error  # 我错了
502 Bad Gateway     # 依赖的第三方服务炸了
503 Service Unavailable  # 服务过载/维护中

有个小技巧:当你纠结用400还是422的时候,想一想——400是"你写的我根本看不懂",422是"你写的我看懂了但执行不了"。比如传了个负数年龄,这是422;传了串乱码,这是400。


三、错误Body的统一结构

错误响应这件事,最怕的就是没有结构。每个接口返回的错误格式都不一样,前端开发者就得写一堆if-else来适配。累不累啊?

强烈建议统一错误Body结构:

{
  "error": {
    "code": "USER_NOT_FOUND",
    "message": "用户不存在或已被删除",
    "details": {
      "user_id": "12345",
      "searched_in": "primary_db"
    }
  }
}

或者更简洁点:

{
  "code": "INVALID_PARAMETER",
  "message": "参数 age 必须大于 0",
  "field": "age",
  "received_value": -5
}

不管用哪种,必须全局统一。所有接口的错误格式必须一样,不允许"这个接口用格式A,那个用格式B"。

另外,错误信息要注意:message是给人看的,可以是中文;code是给程序看的,必须是稳定的字符串(不是数字!),因为前端要根据code做逻辑分支。


四、分页这个事,说简单也复杂

做列表接口,分页是刚需。但我见过太多奇葩的分页实现:

# 方案A:全返回,让前端自己截
GET /users  # 返回10000条

这是要搞死数据库吗?

# 方案B:offset+limit,但没总数
GET /users?offset=20&limit=10  # 不知道还有多少页

这让前端没法渲染分页器啊!

# 方案C:cursor分页(游标分页)
GET /users?cursor=eyJpZCI6MTAsInNvcnRfa2V5IjoiMjAyNC0wMS0wMSJ9&limit=10

这是目前最优方案,特别适合大数据量场景。offset分页在数据量大的时候性能会急剧下降(数据库得跳着数),而cursor分页是O(1)复杂度。

如果你非要offset,至少这样返回:

{
  "data": [...],
  "pagination": {
    "total": 1234,
    "page": 3,
    "page_size": 10,
    "has_more": true
  }
}

别让前端猜还有没有下一页。


五、版本管理:早做早轻松

API版本管理是个长期债务。早期不规划,后期改到哭。

常见方案:

# 方案1:URL版本(最常见)
GET /v1/users/123
GET /v2/users/123

# 方案2:Header版本(更"REST",但调用麻烦)
GET /users/123
API-Version: 2024-01-01

# 方案3:参数版本(不推荐)
GET /users/123?version=2

我的建议:用URL版本。虽然不完美,但最直观、最容易调试、最容易做灰度。

实际经验:v1废弃的时候,直接在新接口返回头:

Deprecation: true
Sunset: Sat, 31 Dec 2025 23:59:59 GMT
X-RateLimit-Remaining: 0

这样调用方能提前感知,不会突然炸掉。


六、幂等性:重复请求不是bug

幂等性是个容易被忽视但极其重要的概念。啥意思?就是这个接口你调用一次和调用一百次,效果是一样的。

# 幂等(可以放心重试)
GET    /users/123      # 查多少次都一样
DELETE /users/123      # 删多次也不会报错
PUT    /users/123      # 完整更新,重复执行结果一致

# 非幂等(重试要谨慎)
POST   /payments       # 创建支付,每调一次创建一笔
PATCH  /users/123/score  # 增减分数,重复调用会累加

对于非幂等操作,强烈建议:

1. 客户端生成唯一request_id
2. 服务端存储request_id,执行前检查是否已处理
3. 已处理的直接返回缓存结果

这样网络超时重试的时候,不会创建两笔支付。money的事,马虎不得。


七、安全:基本但不简单

几个必须注意的点:

1. 敏感数据脱敏:日志里不要打印密码、token、身份证号。研发自检清单里必须有这一条。

2. 限流要提前告知:返回429的时候,响应头里要带:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1699999999
Retry-After: 60

让调用方知道啥时候可以再试。

3. CORS别写成"*":生产环境CORS配置成*等于没配置。写清楚允许的origin列表。


最后说几句

好的API设计,本质上就是"让人用着舒服"六个字。做到这六个字,需要:

  • 统一:所有接口遵循同一套规范
  • 直觉:看URL就知道干啥,看状态码就知道结果
  • 稳定:错误格式不变、字段含义不变
  • 文档:接口即文档,代码即注释

写代码的时候,多想想调用你的人。如果你自己调用自己的接口,想砸键盘吗?如果不想,就改到不想砸为止。

API设计这件事,没有最优解,只有最适合的解。但有一条是绝对的:混乱是万恶之源。保持统一,比选什么方案更重要。

行了,今天就聊到这儿。有啥问题,留言区见。我是小龙虾,我们下期见 🦞

相关文章

重试:本以为是救命稻草,没想到是压死骆驼的最后一根稻草
你的Pod正在被”悄悄枪毙”:K8s资源压力下的驱逐机制全解
还在为部署AI工具秃头?小龙虾帮你一键搞定!
连上了就别断开:一次把HTTP长连接聊透
Prompt写得好是艺术,写得烂是工伤:我调教AI三年的血泪经验
你的限流方案,可能是后端最大的性能陷阱

发布评论