架构师是怎么设计一个REST API的?

举报
golang学习记 发表于 2026/07/17 13:52:20 2026/07/17
【摘要】 刚开始写 Go 后端的时候,我认为自己已经掌握了 API 设计。REST 风格、HTTP 状态码、DTO、分页、版本控制,这些东西看过无数文章,也在项目里实实在在实践过。接口看起来很干净:POST /api/v1/ordersGET /api/v1/usersPUT /api/v1/products/{id}代码结构也很标准:handler ↓service ↓repositor...

刚开始写 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的?

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。