AI 改接口最怕悄悄不兼容:用 oasdiff 在合并前查出 breaking change(附 CI 配置)

0 阅读5分钟

@TOC


让 AI「把订单接口整理得规范一点」,它改出来的东西往往确实更规范:参数加上了必填校验,金额改成字符串避免精度问题,顺手删掉一个「看起来没人用」的字段。

服务端的单元测试全部通过。上线之后,老版本的 App 开始报错。

问题不在 AI 代码写错了,而在它不知道有人在用这个接口。接口一旦对外,改动是否兼容,比代码写得漂不漂亮重要得多。这件事靠人盯 diff 很容易漏,最好交给工具,在合并前自动检查。

这篇用 oasdiff 实测一遍:先看它能查出什么、查不出什么,再把它接进 CI,最后给一段约束 AI 改接口的 Prompt。


一、哪些改动会让调用方出错

常见的不兼容改动

改动为什么会出错
可选参数改成必填老客户端没传这个参数,请求直接被拒
请求体新增必填字段老客户端不知道这个字段,也就不会传
响应字段改类型客户端按原来的类型解析,要么解析失败,要么算错
删除响应字段读这个字段的客户端拿不到值
删除接口、改路径或方法调用直接 404 或 405
请求参数的枚举值变少老客户端传的值不再合法

反过来,新增可选参数、新增响应字段、新增接口,一般是安全的。例外是对未知字段直接报错的严格客户端,这个要看调用方的实现。

AI 为什么特别容易踩

AI 改代码时,看到的是服务端这一侧:handler、结构体、测试。它看不到调用方,也不知道哪些字段有人在读。「让代码更规范」这个目标本身,就会驱动它做出上面表里的改动。


二、准备新旧两份契约

旧契约

接口契约用 OpenAPI 描述,放在仓库里,比如 api/openapi.yaml。下面是一个精简的订单接口:

openapi: 3.0.3
info:
  title: Order API
  version: 1.0.0
paths:
  /orders:
    get:
      parameters:
        - name: status
          in: query
          required: false
          schema:
            type: string
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Order"
    post:
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount]
              properties:
                amount:
                  type: integer
      responses:
        "201":
          description: created
components:
  schemas:
    Order:
      type: object
      required: [id, amount]
      properties:
        id:
          type: integer
        amount:
          type: integer
        remark:
          type: string

AI 的四处「优化」

模拟一次 AI 的「规范化」改动,一共四处:

  1. 查询参数 status 从可选改成必填
  2. 创建订单的请求体新增必填字段 currency
  3. 响应里的 amount 从整数改成字符串
  4. 删掉响应里的 remark 字段

对应的 diff:

@@ -8,7 +8,7 @@
       parameters:
         - name: status
           in: query
-          required: false
+          required: true
           schema:
             type: string
       responses:
@@ -27,10 +27,12 @@
           application/json:
             schema:
               type: object
-              required: [amount]
+              required: [amount, currency]
               properties:
                 amount:
                   type: integer
+                currency:
+                  type: string
       responses:
         "201":
           description: created
@@ -43,6 +45,4 @@
         id:
           type: integer
         amount:
-          type: integer
-        remark:
           type: string

最后一段 diff 看着有点绕:删掉的是 amount 原来的 type: integer 和整个 remark,留下的 type: string 现在归 amount。这正是人工 review 容易看走眼的地方。


三、用 oasdiff 对比

安装

# Go 1.26 及以上
go install github.com/oasdiff/oasdiff@latest

我本机是 Go 1.25,go install 会直接提示需要 1.26。Go 版本不够的话,去 oasdiff 项目的 GitHub Releases 下载预编译包更省事,macOS、Linux、Windows 都有。我用的是 v1.32.1。

查不兼容改动

oasdiff breaking old.yaml new.yaml

输出是 3 条 error:

级别规则说明
errorrequest-parameter-became-requiredGET /orders 的查询参数 status 变成了必填
errorresponse-property-type-changedGET /orders 响应里 items/amount 的类型从 integer 变成了 string
errornew-required-request-propertyPOST /orders 新增了必填的请求字段 currency

四处改动查出了三处,而且每一条都写清楚了是哪个接口、哪个字段、从什么变成了什么。

第四处去哪了

删掉 remark 这一处,breaking 没有报。换成 changelog 看全部改动:

oasdiff changelog old.yaml new.yaml

这次一共 5 条,除了上面 3 条 error,还多了 2 条 info:

  • response-optional-property-removed:从 200 响应里删掉了可选字段 items/remark
  • api-version-not-bumped:有不兼容改动,但版本号还是 1.0.0

oasdiff 的逻辑是:remark 在契约里本来就是可选的,客户端理应能处理它不存在的情况,所以只算 info。道理没错,但如果你的某个客户端就是在读 remark,删掉它照样会出问题。

所以工具的默认规则,不等于你的调用方实际怎么用。删字段这类改动,就算工具没拦,也要去确认调用方有没有在用:查客户端代码,或者看看各版本客户端的请求里有没有带上这个字段。

前端或 SDK 和服务端在同一个仓库时,我会先在 WES Code 里问一句「项目里哪些地方读了 remark 这个字段」,有人在用就不删,改成标 deprecated。

第二条 info 也很实用:发现了不兼容改动,却没改版本号。这种提醒人工 review 时基本不会有人想到。


四、接进 CI:不兼容就不让合并

核心就两条命令

# 取出主干上的旧契约
git show origin/main:api/openapi.yaml > /tmp/base.yaml

# 有 error 级别的不兼容改动时,退出码为 1
oasdiff breaking /tmp/base.yaml api/openapi.yaml --fail-on ERR

--fail-on ERR 是关键:默认情况下,就算查出了不兼容改动,oasdiff 的退出码也是 0,CI 不会失败;加上这个参数,才会返回 1。想更严格,可以改成 --fail-on WARN。

我在本地建了个 git 仓库模拟过:main 上提交旧契约,feature 分支上换成新契约,跑这两条命令,退出码是 1,3 条 error 都列出来了。

GitHub Actions 示例

name: api-compat
on: pull_request
jobs:
  oasdiff:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0 # 要拿到目标分支的历史,才能 git show 旧契约
      - uses: actions/setup-go@v5
        with:
          go-version: "1.26"
      - name: Check breaking changes
        run: |
          go install github.com/oasdiff/oasdiff@latest
          git show origin/${{ github.base_ref }}:api/openapi.yaml > /tmp/base.yaml
          "$(go env GOPATH)/bin/oasdiff" breaking /tmp/base.yaml api/openapi.yaml \
            --fail-on ERR --format githubactions

--format githubactions 会把每条结果输出成 GitHub 的注释格式,直接显示在 PR 页面上。GitLab、Jenkins 也是同样的思路:取出目标分支的契约,对比,失败就拦。


五、让 AI 改接口时带上约束

可复制 Prompt

你在修改一个已经有调用方在用的 HTTP 接口,契约文件是 api/openapi.yaml。

硬性要求:
1. 不得引入不兼容改动:不能把可选参数改成必填,不能删除或改名字段,不能改字段类型,不能新增必填的请求字段
2. 确实需要不兼容改动时,先停下来告诉我原因,并给出兼容方案:新增字段并保留旧字段、给旧字段标 deprecated,或者新增 v2 接口
3. 先改契约,再改实现
4. 改完运行 oasdiff breaking <旧契约> api/openapi.yaml,把输出原样贴给我

第 4 条最管用:让它自己跑检查,把结果贴回来,比事后人工 review 可靠。

我在 WES Code 里改接口时用的就是这段约束:改完契约,让它在终端里跑一遍 oasdiff,有 error 就按第 2 条改成兼容的写法,再跑,直到通过。


六、不兼容改动非做不可时

新增,不删除。 需要新的字段或类型,就新增一个字段,旧的保留,并在契约里给旧字段标上 deprecated: true,等调用方迁移完再删。

必填参数给默认值。 与其把参数改成必填,不如在服务端给一个合理的默认值,老客户端不传也能正常工作。

版本化。 改动大到没法兼容时,新开 /v2/orders,和旧接口并存一段时间。

先通知,再下线。 列出受影响的调用方,约好兼容期,到期后再删旧字段或旧接口。


七、它查不出来的东西

契约和实现不一致。 oasdiff 只看契约文件。代码改了、yaml 没改,它就看不到。要么从代码自动生成契约,要么加一层契约测试,保证两边一致。

语义变化。 字段名和类型都没变,但含义变了,比如金额从「元」改成了「分」。这种改动工具查不出来,只能靠 review 和测试。

默认只算 info 或 warn 的改动。 比如上面删掉可选字段。这类改动要结合调用方的实际情况,决定要不要拦。


小结

  1. AI 改接口时看不到调用方,「规范化」改动很容易变成不兼容改动
  2. 接口契约放进仓库,改接口先改契约
  3. 合并前用 oasdiff breaking --fail-on ERR 自动检查,不兼容就不让合并
  4. 工具没拦的删字段、语义变化,要结合调用方确认

文中的契约对比和检查流程,是我在 WES Code 里改接口时的做法,官网是 weisyn.com。你们团队是怎么管接口兼容性的,靠 review、契约测试还是工具,欢迎评论区聊聊。

觉得有用的朋友,欢迎点赞、收藏、关注,后面会继续分享 AI 编程的实战经验。