Contract First(也叫 API First)的理念并不新鲜:在写任何代码之前,先把 API 的形状定义清楚,让前后端、移动端、第三方消费者都从这份约定出发并行开发。但这套思路在很多团队里一直停留在 PPT,原因往往不是不认可,而是落地成本比想象中高——谁来写 spec、风格谁来管、变更谁来通知、breaking change 谁来拦,每一个问题都可能让一个团队悄悄退回 Code First。

这篇是我自己在 Spring Boot + 前端栈(TypeScript / React 或 Angular)里反复试错后整理的实践笔记,关注的是怎么让 Contract First 跑起来,并尽量持续不退回老路,而不是介绍 OpenAPI 语法。

如果你只想看 JVM 团队怎么把 contract 做成可执行验证(Spring Cloud Contract、WireMock、CI Quality Gates),可以直接跳到《Java 项目怎么做 contract testing:一次 Spring Cloud Contract 实践》。本文聚焦在 spec、协作和治理这一层。

为什么坚持 Contract First?#

Code First 在小项目里没什么问题:后端先写完接口,把 Swagger 文档丢出去,前端照着对接。问题出现在团队和系统变大之后:

  • 文档总是滞后——后端改了实现却忘了同步 Swagger 注解,前端按旧文档调用,联调阶段才发现
  • 前端只能等——接口没写完,前端只能压时间或自己 mock,等真接口出来再返工
  • 跨团队沟通成本高——每加一个消费方(移动端、第三方、内部 BFF),就要重新解释一遍接口
  • breaking change 靠口头同步——后端"顺手"改了字段类型,下游调用才发现问题

Contract First 的核心价值,是让 API 契约成为单一事实来源(single source of truth)。spec 不是事后的文档,而是开发的起点:所有人从同一份 YAML 出发,前后端可以并行,breaking change 最好显式审批。我倾向把它理解成一种"用工程约束代替口头沟通"的解耦机制,对微服务、电商、支付这种跨团队跨系统的场景尤其有价值。

一个工作流长什么样?#

1. 前后端 + 产品一起 30 分钟讨论 API 形状(资源 / 操作 / 字段 / 错误)
2. AI 起草 OpenAPI 3.1 YAML,或在已有 spec 上做增量修改
3. 提交 MR:Redocly 渲染可视化预览 + lint 检查风格 + oasdiff 拦 breaking change
4. MR 审核合并(breaking change 强制双端审批)
5. 各端基于同一份 spec 自动生成代码 / mock 资产
   ├── 后端:DTO + 接口骨架(Spring MVC interface)
   ├── 前端:TypeScript 客户端 / mock 层
   └── 第三方消费者:SDK 包
6. 实现层用 contract testing 持续验证 provider 与 consumer 没有偏离 spec

这个流程里,spec 既是设计成果,也是运行时约束。前后端都从它生成代码,如果实现偏离了 spec,要么编译报错,要么 contract test 红——不会变成"我以为接口是这样的"那种 bug。

工具栈怎么选?#

社区里常被提到的工具很多:OpenAPI、Swagger、Postman、Apidog、Pact、Specmatic、Microcks……如果不分场景全用上,最后会变成另一种形式的复杂度。我自己的拼法是按"环节"来选,每个环节只挑一个:

环节 我的选择 备选 / 评估
spec 格式 OpenAPI 3.1 OpenAPI 3.0 仍可用,但 3.1 对齐 JSON Schema 2020-12 更值得
起草 / 改写 spec AI(ChatGPT / Claude)+ Redocly 实时预览 手写、Stoplight Studio
Spec lint / 风格 Redocly CLI Spectral
Breaking change 检测 oasdiff openapi-diff
代码生成 openapi-generator swagger-codegen(已较少维护)
Contract testing(JVM) Spring Cloud Contract Pact、Specmatic
Contract testing(多语言) Pact / Specmatic
Mock server WireMock(JVM 内)/ MSW(前端) Microcks(多协议网关侧场景更合适)
探索 / 手测 Postman / Apidog

几点取舍说明:

Postman / Apidog 不进主流程。 这两个是综合工具,适合个人探索和手工测试,但如果把 spec 维护放在它们的云端项目里,就脱离了 Git 和 CI——版本无法走 MR 审核,breaking change 无法在流水线里拦截。我会让 spec 留在 Git 仓库,Postman / Apidog 只作为消费方手测客户端,从 OpenAPI 文件导入。

Microcks 在网关 / 多服务统一 mock 场景才更划算。 单服务的 mock 用 WireMock + 生成的 stubs JAR 已经够,不必引入额外的 mock 平台。

Pact 与 Spring Cloud Contract 选哪个,按团队语言栈决定。 Pact 是消费者驱动(CDC),多语言支持更全;SCC 是 spec / DSL 驱动,与 Spring 生态契合度更高。我们 JVM 内部用 SCC,跨语言场景才考虑 Pact。这块我在另一篇里详细对比过。

AI 帮了什么?#

OpenAPI 3.1 的语法层次深、$ref 引用繁琐,层级写错时有时仍是合法 YAML,但语义已经偏了。这也是我之前推动 Contract First 时遇到的最大障碍之一。AI 出现之后,我现在的工作流改成这样:

前后端和产品先用 30 分钟讨论清楚——

  • 资源是什么?有哪些操作?
  • Request body 哪些字段、什么类型、哪些必填?
  • Response 结构?错误情况怎么表示?

把这份草稿丢给 AI,通常很快能得到一份能跑通基本校验的 OpenAPI 3.1 YAML 草稿,再用 Redocly 实时渲染检查。整个过程的重心从"怎么写 YAML 语法"转移到"接口设计合不合理"——后者才是更值得花时间讨论的。

下面这段是 AI 起草后人工调过的典型 spec,包含统一的认证占位、结构化错误、tag 分组:

openapi: 3.1.0
info:
  title: Books API
  version: 1.0.0

tags:
  - name: Books
    description: Book management operations

paths:
  /books:
    get:
      operationId: listBooks
      summary: List all books
      tags: [Books]
      responses:
        '200':
          description: A list of books
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Book'
    post:
      operationId: createBook
      summary: Create a new book
      tags: [Books]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateBookRequest'
      responses:
        '201':
          description: Book created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Book'
        '400':
          description: Validation failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /books/{id}:
    get:
      operationId: getBook
      summary: Get a book by ID
      tags: [Books]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
            format: int64
      responses:
        '200':
          description: The book
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Book'
        '404':
          description: Book not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  schemas:
    Book:
      type: object
      required: [id, title, author, isbn]
      properties:
        id:
          type: integer
          format: int64
        title:
          type: string
        author:
          type: string
        isbn:
          type: string

    CreateBookRequest:
      type: object
      required: [title, author, isbn]
      properties:
        title:
          type: string
        author:
          type: string
        isbn:
          type: string

    # 统一错误响应:message 给用户看,details 给前端表单字段级报错
    ErrorResponse:
      type: object
      required: [message, code]
      properties:
        message:
          type: string
        code:
          type: integer
        details:
          type: array
          description: 字段级错误列表,用于表单验证场景
          items:
            type: object
            required: [field, reason]
            properties:
              field:
                type: string
              reason:
                type: string

几个值得注意的设计决策:

  • security 提前占位:即使当前不启用认证,也可以先在 components.securitySchemes 里声明认证方案,把认证方式纳入 contract 设计。这样后续补齐文档、代码生成和 lint 规则时更顺;但给具体 endpoint 增加鉴权,依然应按 breaking change 治理。
  • 统一 ErrorResponsemessage 给终端用户看,details 给前端表单做字段级报错。这个结构统一了所有错误响应,避免后续因为格式不一致而被迫改接口。
  • tags + operationId 并存tags 控制代码生成的分组(生成 BooksApi 类),operationId 决定方法名。两者都最好显式声明。

AI 的角色是"起草 + 改写 + 解释",不是决策。决策(命名风格、分页方案、错误结构等)仍然是团队的事,且最好沉淀成 style guide(下文会展开)。

同一份 spec 怎么同时给前后端用?#

配套 demo 仓库(如果访问不到,可能还没公开)里 contract/ backend/ frontend/ 三个模块都从 spec/openapi.yaml 出发:

后端(Java + Spring Boot 3.5)#

关键配置(openapi-generator-maven-plugin 7.10.0):

<plugin>
  <groupId>org.openapitools</groupId>
  <artifactId>openapi-generator-maven-plugin</artifactId>
  <version>7.10.0</version>
  <executions>
    <execution>
      <id>generate-api-interfaces</id>
      <goals><goal>generate</goal></goals>
      <configuration>
        <inputSpec>${project.basedir}/../spec/openapi.yaml</inputSpec>
        <generatorName>spring</generatorName>
        <apiPackage>com.example.contract.api</apiPackage>
        <modelPackage>com.example.contract.dto</modelPackage>
        <configOptions>
          <useSpringBoot3>true</useSpringBoot3>
          <interfaceOnly>true</interfaceOnly>
          <useJakartaEe>true</useJakartaEe>
          <useTags>true</useTags>
          <skipDefaultInterface>true</skipDefaultInterface>
          <openApiNullable>false</openApiNullable>
        </configOptions>
      </configuration>
    </execution>
  </executions>
</plugin>
参数 作用
interfaceOnly true 只生成接口声明,后端写实现,避免无用的 stub controller
useTags true 按 OpenAPI 的 tags 分文件,Books tag → BooksApi.java
useSpringBoot3 true 使用 Jakarta EE 注解,兼容 Spring Boot 3.x
skipDefaultInterface true 让实现类显式覆盖所有方法,不靠 default 方法漏掉
openApiNullable false 去掉 JsonNullable 包装

后端只需要写 @RestController implements BooksApi,编译器会要求方法签名与 spec 一致。spec 改了不更新实现?编译就红,CI 直接拦下来——这是 Contract First 与 Code First 在工程约束上比较关键的差别。

前端(TypeScript + Axios)#

npm run generate:api   # 内部调用 openapi-generator-cli generate

生成类型安全的客户端:

const booksApi = new BooksApi(new Configuration({ basePath: "http://localhost:8080" }));

const response = await booksApi.listBooks();
const books: Book[] = response.data;   // Book 类型来自 spec

字段名、类型、必填约束全部来自 spec,不需要联调才发现拼错字段。Angular / NestJS 团队一样适用,只是把生成器目标改成 typescript-angulartypescript-fetch——具体生成的类名、方法签名、请求库依赖会随 generator 略有差异,调用层的封装方式相应调整即可。

团队怎么围绕一份 contract 协作?#

工具搞定之后,更难的是协作流程。在我看到的案例里,Contract First 的失败大多不是技术问题,而是没人对 contract 负责。下面是几个我认为最好提前定义的环节。

contract 仓库归属:monorepo 还是独立仓?#

两种常见模式:

模式 A:monorepo(contract 与 backend 同仓)。后端维护 spec,前端通过相对路径或包依赖引用。

  • 优:所有权清晰、迭代快、CI 简单
  • 劣:前端话语权弱,容易变成"后端说了算"
  • 适合:小团队、单一产品线

模式 B:contract 独立仓库。spec 在专门的 git 仓库里,前后端都从这里生成代码,变更走 MR 审核。

  • 优:治理清晰、多消费方对等
  • 劣:多一个依赖、发布节奏需要协调
  • 适合:大型团队、多消费方场景(mobile、第三方、BFF)

本文 demo 用的是 monorepo(contract/backend/ 是 Maven 多模块,frontend/ 是同仓库子工程,三者共享 spec/openapi.yaml)。如果后续要拆 polyrepo,把 contract/ 单独抽成 Git 仓库,发布为 npm / Maven 包就够了。

变更通知与审批#

无论哪种模式,我更倾向让 spec 的所有变更都走 MR / PR 流程,并显式做几件事:

  • breaking change 最好有双端审批:后端 + 至少一个消费方(前端 / 移动端 / BFF)各一人 LGTM
  • 下游消费者通知:哪怕是非 breaking 变更,新增字段、新增端点也最好在 MR 描述里 @ 相关团队
  • Changelog 维护:用 oasdiff changelog 自动生成完整的 diff,附在 MR 描述里

我比较不建议把这些环节寄希望于“群里说一声”——至少在跨时区、跨团队场景里,它很容易被漏掉,所以我更愿意把它做进流程。

contract 风格一致性:style guide + lint#

AI 生成的 spec 在结构上没问题,但风格可能千差万别:

  • 字段命名:camelCase 还是 snake_case
  • 错误响应:统一用 ErrorResponse,还是每个接口自定义?
  • 分页:page + size 还是 cursor-based?
  • 版本号:放 URL 路径、放 header 还是放 media type?

如果没有明确标准,几个月后 spec 库里会出现十几种风格混杂。建议在项目早期就建立 API Style Guide,并把它转化成 Redocly 的 lint 规则进 CI:

# redocly.yaml
apis:
  books@1.0.0:
    root: spec/openapi.yaml

rules:
  # 结构完整性
  no-unused-components: error
  operation-operationId: error       # 每个操作必须有 operationId(代码生成方法名依赖它)
  operation-summary: warn
  no-ambiguous-paths: error
  path-parameters-defined: error

  # 风格统一性
  operation-tag-defined: error       # tag 必须在顶层 tags 里预定义,防止乱加
  no-enum-type-mismatch: error

  # 安全
  security-defined: warn             # 提醒声明认证方式

不符合规则的 spec 在 lint 阶段就会被拦下来,AI 生成的草稿也得过这一关。这比"靠 reviewer 肉眼盯"更可持续。

常见的坑#

下面这几个是我在多个团队里反复踩到的,按出现频率排序。

坑 1:Contract Drift(实现与 spec 不一致)#

症状:spec 里写的是一套,代码实现的是另一套;或者 spec 本身在多个 endpoint 之间风格漂移、命名/分页/错误结构每个接口一个样。文档跟不上代码,spec 也跟不上自己。

对策

  • 代码侧:对这类强调 contract 约束的团队,我更倾向不要手写 DTO 或客户端,而是让 OpenAPI generator 统一生成。spec 改了,编译就报错,CI 就红。我把这条放在最前面,是因为它最容易回退——一旦有人觉得“生成的代码有点别扭,我自己写个 DTO 算了”,整套机制通常就会开始松动。对我自己来说,这更像一条纪律线。
  • 风格侧:靠上一节 style guide + Redocly lint 在 CI 拦截,避免 AI 反复起草后 spec 库出现十几种风格。

坑 2:Breaking Change 没拦住#

症状:后端"顺手"改了字段类型 / 删了字段 / 改了状态码,下游调用才发现问题。

OpenAPI spec 的变更不是所有变更都等价:

变更类型 影响 示例
新增可选字段(response) 向后兼容 添加 description 字段
新增必填字段(request) Breaking 添加必填 isbn 到 request
修改字段类型 Breaking idstringinteger
删除字段 Breaking 移除 response 中的 author
新增端点 向后兼容 新增 DELETE /books/{id}
修改响应状态码 Breaking 201 改为 200

对策

  • 尽量不直接删除或修改字段。要变更,新增一个字段,旧字段标记 deprecated: true,给消费者一段过渡期(建议至少一个发布周期)后再删。
  • 用 oasdiff 在 MR 流水线自动检测
brew install oasdiff
# 或
go install github.com/oasdiff/oasdiff@latest

# 列出所有 breaking change
oasdiff breaking origin/main:spec/openapi.yaml spec/openapi.yaml

# 输出示例:
# [error] DELETE /books/{id}: deleted
# [error] GET /books: response property 'author' removed from '200' response

# 完整变更报告
oasdiff changelog origin/main:spec/openapi.yaml spec/openapi.yaml
  • HTTP 层加 DeprecationSunset header,让客户端运行时也能感知。Deprecation header 见 RFC 9745Sunset header 见 RFC 8594
  • 大版本走 /v1 /v2 路径:不可避免的 breaking change,把它做成显式版本切换,让新旧并存一段时间。

更详细的兼容性防御可以看《微服务契约兼容性的五层防线》

坑 3:在 spec 设计阶段卡死#

症状:团队为了"把 spec 设计完美"反复讨论,几周过去还没开工。

Contract First 本意是减少返工,但过度设计反而是另一种返工。我自己的经验是:

  • 设计讨论时间不超过一次会议(30~60 分钟)
  • 拿不准的字段标记 description: "subject to change in v1.1",先发 v1
  • 用 AI 起草降低成本,让"先发再迭代"变得可行
  • 把 contract 看成"可演进的 working agreement",不是"一次写好的圣经"

如果一个 API 的 v1 完全不需要在 v1.1 调整,更常见的解释是它还没真正被消费方用起来——而不是设计本身有多完美。

坑 4:实现偷偷偏离 spec#

症状:后端接口签名与 spec 一致,但响应里多塞了字段,或字段格式悄悄变了。生成代码这层拦不住。

对策:引入 contract testing 这一层。它的职责是:

  • provider 必须证明自己的实现没有偏离约定
  • consumer 必须证明自己的调用和解析逻辑仍然兼容
  • CI 在 MR 阶段就拦下不一致

JVM 团队的具体做法(Spring Cloud Contract + WireMock + GitHub Actions)放在了《Java 项目怎么做 contract testing:一次 Spring Cloud Contract 实践》。其他语言栈通常用 Pact 或 Specmatic,思路类似。

老项目怎么迁过来?#

已有项目不需要全面重写,分阶段迁移更稳妥:

第一阶段:给现有接口生成 spec(逆向)

# 如果后端已有 SpringDoc / Swagger 注解,直接导出
curl http://localhost:8080/v3/api-docs > spec/openapi.yaml

把导出的 spec 作为"当前状态的基线",让 AI 整理成规范格式,去掉多余的实现细节。

第二阶段:建立 spec 审核流程(不动代码)

  • spec 提交进仓库
  • 加 Redocly lint + oasdiff 进 CI
  • 新功能从这一刻起走 Contract First 流程

第三阶段:接入代码生成(逐步替换)

  • 新增的 endpoint 从 spec 生成接口,后端实现接口
  • 历史 endpoint 逐步迁移:确认生成的接口签名与现有实现一致后,删除手写的 DTO 和 controller signature
  • 不建议一次性全部迁移——历史代码的边角逻辑很多,分阶段更稳

学习资源#

如果想系统了解 Contract First / API First:

小结#

至少在我遇到的团队里,Contract First 更像是协作问题,不只是技术问题。它要求前后端在写代码前先对齐,这也是我比较看重的一种工程习惯。

AI 降低了"谁来写 YAML"这道技术门槛之后,更值得花精力的事情浮了出来:

  • 接口设计是否合理:产品、前端、后端一起讨论
  • contract 风格是否统一:style guide + Redocly lint + CI 强制检查
  • breaking change 如何治理:oasdiff 自动检测 + 双端审批 + Deprecation/Sunset header
  • 实现是否持续与 spec 一致:contract testing(SCC / Pact / Specmatic)

工具不能替代这些讨论,但它可以降低讨论成本——spec 可以随时改,改完较快看到文档、生成代码,再交给 CI 验证一致性。

如果你的团队还在用"后端写完接口、前端来对接"的方式协作,我更建议从下一个新功能开始试一轮 Contract First,再把代码生成、lint、oasdiff、contract testing 一项一项加进 CI。至少在我自己的项目里,这种渐进式推进通常比一次性翻盘更稳。

Demo 仓库(如果访问不到,可能还没公开):github.com/meirongdev/openapi-contract-first

仓库里目前比较适合直接上手的几条路径:

  • 分步教学:按 make validate-spec → generate-backend → build-contract → verify-backend → generate-frontend 走一遍,对应博客里讲过的主要环节
  • 跑完整主流程make all
  • 本地预览 / 联调make preview-specmake start-backendmake start-frontend