如何对比 OpenAPI 接口定义/规范并在 CI 中阻止破坏性变更

接口修改又导致客户端崩溃?本文教你如何在 CI 流水线中引入 OpenAPI 规范对比工具,自动拦截破坏性变更,守护 API 兼容性,让每一次部署都稳操胜券。

用 Apifox,节省研发团队的每一分钟

如何对比 OpenAPI 接口定义/规范并在 CI 中阻止破坏性变更

免费使用 Apifox

相关推荐

最新文章

API

一体化协作平台

API 设计

API 文档

API 调试

自动化测试

API Mock

API Hub

立即体验 Apifox
目录

一个 Pull Request 修改了 openapi.yaml。CI 检查全部通过。接口定义/规范是有效的,Lint 检查也没有问题,两位评审人员批准了它。三天后,移动端客户端开始抛出空指针异常崩溃,因为原本存在的一个响应字段消失了。没人是故意删除它的。有人在重构时重命名了一个属性,而在代码评审中没有任何人发现这一点。

这就是普通验证器无法发现的漏洞。一个接口定义/规范即使格式完全正确,也仍然可能破坏依赖它的每一个消费者。唯一发现的方法是将新的接口定义/规范与它所替换的版本逐个变更地进行对比,并问自己一个问题:这会破坏昨天还能正常工作的客户端吗?这种对比就是 OpenAPI diff,将其作为合并门禁(merge gate)运行是你可以添加到 API 仓库中回报率最高的检查之一。

OpenAPI diff 到底在对比什么

OpenAPI diff 接收两个接口定义/规范(即 base 和 head),并报告它们之间的变化。Base 通常是目标分支上的接口定义/规范(即已上线的版本)。Head 是你的 Pull Request 提议的接口定义/规范。一个好的 diff 工具不会像 git diff 那样仅仅输出文本差异。它理解 OpenAPI 的结构,因此能够区分表面上的编辑和破坏契约的变更。

以下是关键的区别。某些变更是累加的且安全的:

  • 添加一个新的可选请求 parameter
  • 添加一个新的响应字段
  • 添加一个全新的接口
  • 在请求 body 中添加一个新的枚举值

现有的客户端在面对所有这些变更时仍能正常工作。它们发送它们一直在发送的内容,并读取它们一直在读取的内容。而其他变更则是向后不兼容的,这些变更才是最致命的:

  • 删除客户端读取的响应字段
  • 重命名属性(对客户端而言,相当于删除再加上新增)
  • 将之前可选的 parameter 改为必填
  • 收窄类型,例如将 string 改为 integer
  • 删除客户端可能会发送的枚举值
  • 删除一个接口或 HTTP 方法

OpenAPI diff 工具的工作是扫描两个文档中的每一个路径、parameter、数据模型和响应,并将每个变更分类到上述的类别中。这种分类正是其核心意义所在。原始的行级 diff 会将删除的 required 字段埋没在五十行格式重新排版的变动中。而结构化的 diff 则会将其作为破坏性变更暴露出来,并告诉你它位于哪个路径下。

如果你想了解某些变更会破坏契约而其他变更不会的底层心智模型,关于如何大规模进行 API 版本控制和废弃的指南深入介绍了这些兼容性规则。Diff 工具则是你机械化执行这些规则的方式,而不是寄希望于评审人员能记住它们。

oasdiff:开源的主力工具

oasdiff 是大多数团队首选的开源工具。它是一个单一的 Go 二进制文件,运行速度快,且专为解决破坏性变更问题而构建。它支持读取 OpenAPI 3.0 和 3.1 文档,并根据您期望的对比结果提供几个子命令。

您最常用的三个子命令包括:

  • diff:报告两个接口规范之间的完整差异。
  • breaking:仅报告向后不兼容的变更。
  • changelog:生成一份人类可读的列表,列出所有重大变更(无论是否为破坏性变更)。

对于合并门禁,breaking 是最关键的子命令。将其指向您的基线接口规范(base spec)和分支接口规范(head spec):

oasdiff breaking base-openapi.yaml head-openapi.yaml --fail-on ERR

base-openapi.yaml 是来自目标分支的接口规范,而 head-openapi.yaml 是 pull request 中的接口规范。breaking 子命令仅打印不兼容的变更。--fail-on ERR 参数将此命令转化为一个门禁:当它检测到被归类为 ERR 级别的变更时,会使命令以非零状态码退出。非零退出是 CI 系统识别为失败的通用信号。

这种严重性模型非常值得了解。oasdiff 将破坏性变更划分为不同级别,其中 ERR 是严重级别,表示会损坏客户端的变更。WARN 涵盖了可能会损坏某些客户端的变更(取决于这些客户端的具体实现方式),而 INFO 仅是提供信息。您可以自行决定在哪里划定界限。--fail-on ERR 仅阻止确定会发生损坏的变更。--fail-on WARN 则更为严格,同时也会捕获那些可能发生损坏的变更。

当您需要一份可读性强的变更摘要用于 changelog 或 PR 评论,而不是简单的通过/失败状态时,使用 changelog 子命令可以获得更友好的输出:

oasdiff changelog base-openapi.yaml head-openapi.yaml

oasdiff 还有一些非常实用的细节设计。它支持在 path 参数重命名的情况下进行接口匹配,因此当路径的其他部分完全相同时,它不会将 {userId} 变为 {id} 标记为“删除后新增”。它可以在对比前合并 allOf 数据模型,避免继承关系产生干扰噪音。此外,它支持输出纯文本以外的多种格式:通过输出参数可以使用 HTML、JSON、YAML 和 Markdown,这使得将结果对接到 CI 注释或生成的 changelog 中变得非常简单。作为一个只需五分钟就能接入流水线、且对破坏性变更的判定足够严谨的工具,它几乎是无可匹敌的。

openapi-diff:JVM 替代方案

如果您的技术栈已经基于 JVM,那么 OpenAPITools/openapi-diff 是一个切实可行的第二选择,同样值得了解。它是一款基于 Java(Java 8 及以上)的工具,可以对比两个 OpenAPI 3.x 接口规范,并将差异渲染为 HTML、Markdown、AsciiDoc、JSON 或控制台文本。您可以通过构建好的 jar 包运行它,也可以通过 Maven、Homebrew 或 Docker 镜像来运行,因此它可以轻松契合各种构建环境。

其对比深入到 parameter、响应、接口和 HTTP 方法,并且划定了每个人都关心的界线:保持向后兼容的修改与破坏向后兼容的修改。其 CLI 非常直观:

openapi-diff old-openapi.yaml new-openapi.yaml --fail-on-incompatible

--fail-on-incompatible flag 仅在修改破坏向后兼容性时才会返回非零退出码,这正是你想要的卡点行为。如果你希望在发生任何修改时都报错,可以使用更严格的 --fail-on-changed;而当你需要一个简单的单词结果以便于编写脚本时,可以使用 --state 模式,它只会输出 no_changescompatibleincompatible

它的亮点在于渲染后的输出。HTML 和 Markdown 报告非常整洁且详细,这使得当你需要一个人类真正可读的 diff 产物,而不仅仅是一个 CI 退出码时,openapi-diff 成为一个极佳的选择。折中之处在于它对 JVM 的依赖,以及比 Go 二进制文件更重的启动开销。如果你的团队已经在使用 Java,那么这个成本为零,该工具可以直接无缝接入。如果不是,oasdiff 则是一个更轻量级的选择。两者都能很好地解决破坏性变更的问题;选择与你当前维护的运行时相匹配的工具即可。

将 diff 接入 CI 作为合并卡点

手动运行的 diff 无法防范任何问题,因为一旦你忘记运行,破坏性变更就会被发布。卡点必须存在于流水线中,并在每个修改了规范的 pull request 上触发。

CI 中的一个难点在于,你需要同时拥有两个版本的规范:目标分支的 base 版本和来自 PR 的 head 版本。检出 PR 给你提供了 head 版本。你可以直接从 git 历史记录中提取 base 版本,而无需进行第二次检出:

name: openapi-diff
on:
  pull_request:
    paths:
      - "openapi.yaml"

jobs:
  breaking-changes:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Get base spec
        run: git show origin/${{ github.base_ref }}:openapi.yaml > base-openapi.yaml
      - name: Install oasdiff
        run: |
          curl -fsSL https://raw.githubusercontent.com/oasdiff/oasdiff/main/install.sh | sh
      - name: Diff for breaking changes
        run: oasdiff breaking base-openapi.yaml openapi.yaml --fail-on ERR

这里有几个关键的细节。fetch-depth: 0 会拉取完整的历史记录,以便 git show 能够访问到基线分支。git show origin/<base>:openapi.yaml 这行代码读取了目标分支上已存在的规范并将其写入文件,无需额外的克隆操作。paths 过滤器意味着该任务仅在规范实际发生变化时才会运行,因此无关的 PR 不会为此买单。最后一步就是卡点:如果 oasdiff breaking 发现了 ERR 级别的变更,它将以非零状态码退出,任务随之变红(失败),并且在任何人点击合并之前,PR 都会显示检查失败。

开发者可以在代码仍在 review 时,准确地看到是哪一个修改破坏了兼容性,发生在哪个路径上。这就是它的全部价值所在。兼容性中断在成本最低的时刻被捕获,而不是暴露在用户的崩溃报告中。

当然,并非所有的破坏性变更都是错误。有时你是在发布一个经过深思熟虑的大版本,这种破坏是故意为之。推荐的做法是默认进行拦截,对特例则要求显式覆盖:例如在 PR 上添加标签、在 info.version 中升级版本,或者执行一个单独且已获批准的工作流。这样一来,兼容性中断始终是某人故意为之的决定,而绝非意外漏掉的失误。API 版本策略指南详细介绍了何时破坏性变更值得升级一个新的主版本,以及何时应该避免这种变更。

Diff 无法弥补的差距

这就是上述所有工具的局限性,而且这是一个非常关键的局限性。Diff 只是对比两个文件。它只能告诉你新文档是否与旧文档向后兼容,却无法说明你正在运行的服务是否真的与其中任何一个文档相匹配。

这是另一种失效形式,也是在生产环境中最令人头疼的问题。规范承诺会提供 created_at 字段,但在三个迭代前,具体实现就已经悄悄停止返回该字段了。规范声称某个接口返回 200,但在没人测试过的某种情况下,线上服务却返回了 500。由于两个版本的规范一致,因此 diff 检查是完全通过的。然而,契约与代码之间并不一致。静态 diff 根本无法知晓这一点,因为它从未与 API 进行过实际交互。

弥补这一差距意味着要对照契约测试运行中的 API,而不仅仅是对照契约自身进行 diff。你需要根据规范生成测试,在运行中的服务上执行它们,并断言实际响应与文档中定义的数据结构相匹配。这就是契约测试,它可以在你编写的文档与你实际发布的程序之间捕获不一致。

使用 Apifox 和 Apifox CLI 填补这一空白

Apifox 正是为此闭环而设计的,这使它成为 diff 步骤的天然拍档,而不是它的替代品。你可以将 OpenAPI 规范导入或同步到 Apifox 项目中,Apifox 就可以直接根据规范生成测试场景,其中的断言派生自数据模型。这些测试会检查实际响应是否与文档中定义的类型、必填字段和状态码相匹配。你可以通过可视化方式构建和维护这些场景,而无需手动编写一套平行的测试脚本,避免了每次契约变更时脚本与实际情况脱节的问题。

由于 Apifox 将设计、mock 和测试保留在同一个工作区中,因此规范始终是所有这些环节的唯一事实源。你可以下载 Apifox 并导入已有的规范,在自己的 API 上尝试这个闭环。如果你还在纠结如何跨版本管理好该规范,那么关于使用 Git 进行 OpenAPI 规范版本控制的教程将与此工作流完美配合。

Apifox CLI 是在流水线中以无头(headless)模式运行这些测试场景的工具。它是一个 npm 包:

npm install -g apifox-cli

你可以通过 ID 运行测试场景,将其指向要验证的环境,并生成对 CI 友好的报告:

apifox run \
  --access-token $APIFOX_ACCESS_TOKEN \
  -t <scenarioId> \
  -e <environmentId> \
  -r junit,cli \
  --out-dir ./apifox-reports

访问令牌用于对运行进行身份验证,并保存在 CI 的机密(secret)中,绝不能放入已提交的文件。-t 参数用于选择测试场景,-e 用于选择环境,而 -r junit,cli 会为你的 CI 仪表盘输出机器可读的 JUnit XML,同时为构建日志提供易读的终端输出。你无需去猜这些 ID:直接从 Apifox 中测试场景的 CI/CD 标签页复制完整的命令即可,其中已经填入了实际的测试场景和环境 ID。如果你想了解所有可用选项,完整的 CLI 指南中记录了每个参数的详细说明,或者也可以随时运行 apifox run --help 来打印它们。

这种门禁机制的原理与 diff 相同。当断言失败时(因为实时响应不再符合契约),apifox run 会以非零状态码退出。CI 会读取该退出码,将该步骤标记为失败,并阻止合并。无需额外配置。只要运行步骤还在流水线中,契约回归就会像破坏性变更的 diff 一样中断流水线。

完整的合并前流程

将这两个部分结合起来,你就可以得到一个能够捕获这两种破坏性变更的流水线。diff 通过读取接口规范来捕获可能导致客户端崩溃的变更。而契约测试则通过调用正在运行的 API,来捕获不再遵守接口规范的服务。将它们作为独立的任务运行:

jobs:
  breaking-changes:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - run: git show origin/${{ github.base_ref }}:openapi.yaml > base-openapi.yaml
      - run: curl -fsSL https://raw.githubusercontent.com/oasdiff/oasdiff/main/install.sh | sh
      - run: oasdiff breaking base-openapi.yaml openapi.yaml --fail-on ERR

  contract-conformance:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
      - run: npm install -g apidog-cli
      - name: Run contract tests
        run: |
          apidog run \
            --access-token "$APIDOG_ACCESS_TOKEN" \
            -t 605067 \
            -e 1629989 \
            -r junit,cli \
            --out-dir ./apidog-reports
        env:
          APIDOG_ACCESS_TOKEN: ${{ secrets.APIDOG_ACCESS_TOKEN }}
      - name: Upload report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: apidog-report
          path: ./apidog-reports

这两个任务并行运行。diff 任务只读取文件,除了 git 之外不需要其他任何东西,因此在几秒钟内即可完成。一致性校验(conformance)任务则需要一个可访问的环境,因此它通常针对已部署的 staging 构建版本运行。上传操作上的 if: always() 确保了即使测试失败也会持续生成报告,而这恰恰是你最需要查看报告的时候。如果任一任务失败(变红),PR 就会被阻断。有关在实际流水线中运行 CLI 的更多信息,Apifox CLI GitHub Actions 指南和更广泛的 CI/CD 流水线指南深入介绍了其具体配置。

开发必备:API 全流程管理神器 Apifox

介绍完上文的内容,我想额外介绍一个对开发者同样重要的效率工具 —— Apifox。作为一个集 API 文档、调试、设计、测试、Mock、自动化测试于一体的工具,Apifox 是目前提升研发效率的首选。

如果你正在开发项目,不妨试试其极其友好的界面设计,它完全兼容 Postman 和 Swagger 数据格式,导入数据非常方便,,即使是新手也能很快上手,点击这里即可注册使用

Apifox

值得一提的是,除了个人和常规团队使用,针对有高安全合规要求、或需要在内网环境协作的企业,Apifox 还提供了深度定制的私有化部署方案

获取专属报价与部署方案

icon 详细的私有化部署系统架构与安全白皮书
icon 针对您公司规模的专属报价单
icon 免费的 1v1 专属产品演示 (Demo) 机会
获取部署方案
* 提交后,我们的客户经理将在 1 个工作日内与您联系
林俊锋 企业微信
@Apifox 专属顾问
扫码备注: 私有化 + 公司名