架构师是怎么设计一个REST API的?
刚开始写 Go 后端的时候,我认为自己已经掌握了 API 设计。
REST 风格、HTTP 状态码、DTO、分页、版本控制,这些东西看过无数文章,也在项目里实实在在实践过。
接口看起来很干净:
POST /api/v1/orders
GET /api/v1/users
PUT /api/v1/products/{id}
代码结构也很标准:
handler
↓
service
↓
repository
↓
database
测试全部通过,Code Review 也没人提出问题。
直到有一次负责支付系统改造,一个架构师问了几个问题:
如果客户端超时后重试支付请求怎么办?
如果两个用户同时修改同一个订单怎么办?
如果半年后接口字段需要废弃,你如何通知调用方?
我发现自己写的 API 只考虑了一件事:
请求进来,业务执行,返回结果。
但是线上真实世界不是这样的。
真实世界里:
- 网络会丢包
- 请求会重复发送
- 用户会同时修改数据
- 手机网络很慢
- 老客户端永远不会及时升级
- 服务之间存在不可控依赖
一个 API 能正常返回 200,并不代表它设计正确。
真正优秀的 API,需要面对这些“不正常情况”。
这也是普通 API 和生产级 API 的区别。
1. POST 最大的问题:你无法知道它是不是被执行过
很多 Go 开发者写创建接口通常类似这样:
func (h *OrderHandler) CreateOrder(c *gin.Context) {
var req CreateOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{
"error": err.Error(),
})
return
}
order, err := h.service.CreateOrder(req)
if err != nil {
c.JSON(500, err)
return
}
c.JSON(201, order)
}
逻辑没有问题。
但是生产环境存在一个隐藏问题:
客户端可能收到不到你的响应。
比如:
用户点击购买。
请求:
POST /orders
服务器:
创建订单成功
扣款成功
准备返回响应
但是:
网络断开
客户端不知道结果。
于是客户端:
retry
再次发送:
POST /orders
你的服务:
创建第二个订单
扣第二次钱
这不是代码 bug。
这是分布式系统必然出现的问题。
解决方案:幂等键 Idempotency-Key
成熟支付系统都会要求客户端生成唯一请求 ID。
例如:
POST /orders
Headers:
Idempotency-Key:
7f8c9d21-payment-request
服务端保存:
key
|
|
↓
{
request_id:"7f8c9d21",
status:"success",
response:{
order_id:10001
}
}
Go 实现:
func (h *OrderHandler) CreateOrder(c *gin.Context){
key := c.GetHeader("Idempotency-Key")
if key == "" {
c.JSON(400, gin.H{
"error":"missing idempotency key",
})
return
}
cached, err := h.idempotency.Get(key)
if err == nil && cached != nil {
c.JSON(200,cached.Response)
return
}
order,err := h.service.CreateOrder()
if err != nil {
c.JSON(500,err)
return
}
h.idempotency.Save(
key,
order,
24*time.Hour,
)
c.JSON(201,order)
}
核心思想:
相同业务请求,只允许产生一次结果。
很多团队只在支付接口做幂等。实际上:所有有副作用的 POST 都应该考虑。
比如:
- 创建订单
- 发放优惠券
- 发送消息
- 创建退款
- 注册账号
因为网络重试不是异常。
它是系统设计的一部分。
2. PUT 和 PATCH,不只是两个 HTTP 方法
很多 Go 项目里面有这样的接口:
PUT /users/{id}
请求:
{
"email":"new@test.com"
}
开发者想表达:
修改邮箱
但是 REST 语义里:
PUT 的意思是:
完整替换资源。
也就是说:
服务器理解:
用户现在应该长这样
如果原数据:
{
"id":1,
"name":"Tom",
"email":"old@test.com",
"phone":"123456"
}
你的 PUT:
{
"email":"new@test.com"
}
可能导致:
{
"id":null,
"name":null,
"email":"new@test.com",
"phone":null
}
大量线上数据事故,就是这样产生的。
Go 中更合理的方式:PATCH
请求:
PATCH /users/1
Body:
{
"email":"new@test.com"
}
意思:
只修改 email。
Go:
type UserPatch struct {
Email *string `json:"email"`
Phone *string `json:"phone"`
}
为什么使用指针?
因为 Go 需要区分:
没有传:
nil
和:
主动设置为空:
""
这是 PATCH 设计里非常关键的一点。当然如果你用的是orm框架,基本上就没用整个问题,因为orm默认不会更新是nil的字段
3. 数据覆盖问题:你的 API 可能正在偷偷丢数据
这是很多系统最危险的问题。
假设:
数据库:
Product
id:1
price:10
stock:100
两个管理员同时打开页面。
用户 A:
修改价格:
price=20
用户 B:
修改库存:
stock=50
流程:
A 保存:
price=20
stock=100
version=2
B 保存:
price=10
stock=50
version=2
结果:
A 的价格修改消失。
没有错误。
没有日志。
数据库状态正常。
这就是:
Lost Update(丢失更新)
解决方案:乐观锁 + ETag
数据库增加版本:
CREATE TABLE products(
id bigint,
price decimal,
stock int,
version int
)
每次更新:
UPDATE products
SET
price=?,
version=version+1
WHERE id=?
AND version=?
如果影响行数:
0
说明数据已经被别人修改。
HTTP 层:
返回:
ETag:"10"
客户端更新:
PUT /products/1
If-Match:"10"
服务器检查:
当前:
version=11
客户端:
version=10
拒绝:
412 Precondition Failed
告诉客户端:
你修改的数据已经不是最新版本,请重新加载。
很多开发者认为:
数据库事务已经保证安全。
其实不是。
事务解决:
单次操作的一致性
乐观锁解决:
多个用户之间的协作冲突
这是两个完全不同的问题。
4. API 返回太多字段,也是设计问题
很多接口:
GET /users
返回:
{
"id":1,
"name":"Alice",
"email":"",
"address":"",
"avatar":"",
"setting":"",
"created":"",
"updated":"",
...
}
问题:
移动端可能只需要:
{
"id":1,
"name":"Alice"
}
但是服务器:
- 查询全部字段
- 序列化全部字段
- 网络传输全部字段
- 客户端解析全部字段
浪费发生在每一个环节。
fields字段设计
允许:
GET /users?fields=id,name
返回:
[
{
"id":1,
"name":"Alice"
}
]
Go:
func selectFields(
user User,
fields []string,
) map[string]interface{}{
result:=make(map[string]interface{})
for _,field:=range fields{
switch field{
case "id":
result["id"]=user.ID
case "name":
result["name"]=user.Name
case "email":
result["email"]=user.Email
}
}
return result
}
这种设计非常好,尤其是再弱网环境下,同时fields字段是可选的,未传递该参数的存量旧客户端,依旧会收到完整全量返回报文,完全兼容旧系统。
从 CRUD API 到真正的生产级 API
普通 API:
收到请求
↓
执行业务
↓
返回结果
生产级 API:
请求可能重复
↓
数据可能冲突
↓
客户端可能旧版本
↓
网络可能失败
↓
服务可能扩展
所以优秀 API 设计关注的是:
不是:
正常情况下能不能工作
而是:
异常情况下系统还能不能保持正确
Go 后端工程师应该优先补上的 4 个能力
如果你的项目还没有这些能力,可以按照这个顺序改造:
第一阶段:所有关键 POST 增加幂等
优先:
- 支付
- 订单
- 消息发送
- 资金操作
第二阶段:检查所有 PUT
问自己:
这是完整替换,还是部分修改?
如果是后者:
改 PATCH。
第三阶段:热点数据增加乐观锁
特别是:
- 库存
- 余额
- 订单状态
- 配置中心
第四阶段:优化高频接口字段返回
特别关注:
- 移动端接口
- 列表接口
- 大对象接口
真正成熟的 API,不是因为它用了 REST。
也不是因为路径设计漂亮。
而是因为它提前考虑了:重试,并发,演进,失败。
这才是一个 API 能够陪伴系统几年甚至十年的原因。
写 API 最重要的问题,不是“这个接口现在怎么调用”。
而是:
“一年以后,当十万个客户端同时调用它时,它还能不能保持正确。”
架构师是怎么设计一个REST API的?
- 点赞
- 收藏
- 关注作者
评论(0)