《Spring Boot 实战》7.2 契约先行与代码生成

把 OpenAPI 文件从代码的产物变成代码的输入:用 openapi-generator 7.26 生成接口与 DTO,用 interfaceOnly 与生成目录划清生成与手写的边界,给出避免重生成覆盖业务逻辑的三条纪律,并在 CI 里用 validate、spectral、漂移检查与 oasdiff 四道关固定契约。

本节目标:把 OpenAPI 文件从「代码的产物」变成「代码的输入」——用 openapi-generator 生成接口与 DTO,划清生成代码与手写代码的边界,避免重生成覆盖业务逻辑,并在 CI 里把契约校验固定下来。
适用版本:Spring Boot 4.1.x(Java 21)

7.2 契约先行与代码生成

7.1 走的是「代码先行」:先写 @RestController,springdoc 反过来导出 OpenAPI。它上手快,但有个隐患——文档永远落后于代码,因为它是推导出来的,没有人在写代码前先想清楚接口长什么样。

本节换一种顺序:先写 OpenAPI 文件,再由它生成接口与 DTO。契约成了唯一事实源,服务端和客户端都从同一份文件出发。

7.2.1 两种顺序的取舍

维度代码先行(7.1)契约先行(本节)
起点@RestController 代码book-loan-api.yaml
契约可信度代码改完忘记重新导出就失真契约是源头,天然一致
前后端并行后端先写完才能联调契约定稿即可各写各的
生成客户端事后从文档生成,易漂移天然同源
上手成本低需要一套生成配置与纪律
适合单团队、接口常变、内部服务多团队、有外部调用方、需要 SDK

判断标准很直接:只要存在「服务端之外的第二方」需要这份契约(前端、移动端、外部合作方),契约先行的收益就明显。只有一个团队、接口还在快速试错,代码先行的摩擦更小。

book-loan 有两类调用方,从本节起切到契约先行。

7.2.2 契约文件放哪

契约要像代码一样进 git、走评审。book-loan 里它放在独立的 book-loan-api 模块,因为该模块本身就是要被复用的「对外契约」模块:

book-loan/
├── book-loan-api/
│   ├── src/main/resources/openapi/book-loan-api.yaml   # 唯一事实源
│   └── pom.xml
├── book-loan-core/
└── book-loan-web/

契约文件的一个片段:

openapi: 3.1.0
info:
  title: Book Loan API
  version: 1.0.0
paths:
  /api/books/{isbn}:
    get:
      tags: [book]
      operationId: getBook
      summary: 按 ISBN 查询图书
      parameters:
        - name: isbn
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: 查询成功
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BookResponse" }
        "404":
          description: 图书不存在
components:
  schemas:
    BookResponse:
      type: object
      required: [isbn, title, availableCopies]
      properties:
        isbn: { type: string, example: "9787115428028" }
        title: { type: string, example: "深入理解计算机系统" }
        availableCopies: { type: integer, format: int32, example: 3 }

operationId 决定生成的方法名,required 决定字段是否必填,tags 决定接口归到哪个生成类——这些命名一旦被客户端引用就不能随便改,所以在评审契约时就要盯住。

7.2.3 用 openapi-generator 生成接口与 DTO

生成用 openapi-generator 的 Maven 插件。本文写作时最新版本是 7.26.0。关键配置如下:

<plugin>
  <groupId>org.openapitools</groupId>
  <artifactId>openapi-generator-maven-plugin</artifactId>
  <version>7.26.0</version>
  <executions>
    <execution>
      <goals>
        <goal>generate</goal>
      </goals>
      <configuration>
        <inputSpec>${project.basedir}/src/main/resources/openapi/book-loan-api.yaml</inputSpec>
        <generatorName>spring</generatorName>
        <output>${project.build.directory}/generated-sources/openapi</output>
        <apiPackage>com.example.bookloan.api</apiPackage>
        <modelPackage>com.example.bookloan.api.model</modelPackage>
        <generateSupportingFiles>false</generateSupportingFiles>
        <configOptions>
          <interfaceOnly>true</interfaceOnly>
          <useSpringBoot4>true</useSpringBoot4>
          <useJackson3>true</useJackson3>
          <useTags>true</useTags>
          <useBeanValidation>true</useBeanValidation>
          <openApiNullable>false</openApiNullable>
          <documentationProvider>springdoc</documentationProvider>
        </configOptions>
      </configuration>
    </execution>
  </executions>
</plugin>

几个配置项各自解决一个问题:

配置项作用为什么这么设
interfaceOnly=true只生成接口与模型,不生成控制器实现生成物里没有业务代码可被覆盖
useSpringBoot4=true按 Spring Boot 4 生成依赖与导入与 4.x 口径对齐
useJackson3=true生成 Jackson 3(tools.jackson)相关代码仅当 useSpringBoot4=true 时可用
useTags=true按 tags 聚合接口到一个类避免所有方法挤在一个 DefaultApi
useBeanValidation=true按 required 与约束生成校验注解校验规则也从契约来
openApiNullable=false不为可空字段套 JsonNullable 包装DTO 保持普通类型,减少样板
documentationProvider=springdoc在生成代码里带上 @Operation/@Schema生成物本身就是 7.1 那套文档的来源

生成的接口长这样(省略注解细节):

@Generated(value = "org.openapitools.codegen.languages.SpringCodegen",
           date = "2026-09-26T10:00:00+08:00[Asia/Shanghai]")
@Validated
@Tag(name = "book", description = "图书相关接口")
public interface BookApi {

    @Operation(summary = "按 ISBN 查询图书")
    @GetMapping(value = "/api/books/{isbn}", produces = { "application/json" })
    ResponseEntity<BookResponse> getBook(@PathVariable("isbn") String isbn);
}

7.2.4 生成代码与手写代码的边界

这是契约先行最容易翻车的地方。边界只有一条原则:生成目录只读,手写目录只写。

插件把产物放在 target/generated-sources/openapi(上面的 output 显式指到这里),它在 target 下,构建时清空、不提交 git。业务实现写在 src/main/java,实现生成的接口:

@RestController
public class BookApiController implements BookApi {

    private final BookService bookService;

    public BookApiController(BookService bookService) {
        this.bookService = bookService;
    }

    @Override
    public ResponseEntity<BookResponse> getBook(String isbn) {
        return ResponseEntity.ok(bookService.findByIsbn(isbn));
    }
}

BookApiController 上不写 @RequestMapping——路径、方法、参数全在接口上。Spring MVC 会用 AnnotatedElementUtils.findMergedAnnotation 从实现的接口上取到这些映射注解,所以实现类只要 @RestController + @Override 即可。

放哪内容能否手改
target/generated-sources/openapi接口、DTO、枚举不能,重生成即丢
src/main/javaController、Service、Repository随便改,生成不碰
src/main/resources/openapi契约 YAML改这里,不直接改生成物

要把生成的 DTO 加行为怎么办? 不要改 DTO,也不要继承它。用组合:在手写层写一个转换方法或 Mapper,把 DTO 映射成领域对象。DTO 是契约的形状,领域对象是业务的形状,两者本就不该是同一个类。

7.2.5 避免重生成覆盖业务逻辑

只要生成目录在 target 下、实现类在 src/main/java,覆盖就永远不会发生——这是 interfaceOnly=true 加「生成到 target」的组合价值。如果团队出于「方便阅读生成代码」选择把产物提交进 src/main/java,就必须接受三条纪律:

  • 生成目录加标记,文件头已有 @Generated,可在评审时用脚本识别。
  • CI 里做漂移检查:重新生成后跑 git diff --exit-code,有差异说明有人手改了生成物或忘了重新生成。
  • 生成器版本锁死:openapi-generator-maven-plugin 的版本写进父 POM,升级要单独一次提交,否则不同人本机生成的结果会互相打架。

另一个高频问题:生成器升级后方法签名变化,实现类编译不过。处理方式和依赖升级一样——把它当成一次显式的迁移,而不是混在功能提交里。

7.2.6 CI 里的契约校验

契约是唯一事实源,那它自己也要被校验。一条完整的流水线至少四道关:

# 1. 语法与语义校验:spec 本身是否合法
npx @openapitools/openapi-generator-cli validate -i book-loan-api.yaml

# 2. 风格检查:命名、描述、示例是否齐全(spectral 规则集)
npx @stoplight/spectral-cli lint book-loan-api.yaml

# 3. 漂移检查:重新生成后是否有未提交的差异
mvn -q generate-sources
git diff --exit-code

# 4. 破坏性变更检测:与主干对比,是否删字段、改类型
oasdiff breaking origin/main:book-loan-api.yaml book-loan-api.yaml

第 3 道关有个前提:生成物得在版本控制里,git diff 才有意义。如果按 7.2.4 把生成物放在 target 下(不提交),第 3 道关要改成「在干净工作区重新生成并编译」,用编译失败来兜住实现与契约的脱节。

第 4 道关最容易被忽略。契约的破坏性变更不一定会让服务端编译失败——删掉一个响应字段,服务端照样编译通过,只有客户端在运行时会读不到值。所以必须用工具显式对比,而不是靠人眼。具体哪些算破坏性变更,7.3 会给一张判定清单。

7.2.7 与契约测试的衔接

契约先行解决的是「接口形状一致」,契约测试解决的是「行为符合约定」。两者互补。

4.3 讲过的契约测试,是让消费者把「我期望你怎么应答」固化成测试。把它和本节串起来,闭环是这样:

  1. 契约 YAML 定义形状。
  2. 生成器产出服务端接口,以及客户端 SDK——把 spring 生成器的 library 设为 spring-http-interface,就得到一套 @HttpExchange 接口式客户端(正是 Spring Boot 4.0 新增的 HTTP Service Clients 能力)。
  3. 消费者在契约测试里用生成的客户端调用服务端。
  4. 服务端用 4.3 的 Testcontainers 加 MockMvc 验证真实应答与契约一致。

这样,契约、生成代码、契约测试三者同源。任何一方偏离,CI 里总有一道关会红。

7.2.8 常见坑

坑一:把业务逻辑写进生成的 Controller。 如果用默认配置(interfaceOnly=false),生成器会产出一个带 @RestController 的骨架类,很多人直接在里面写逻辑,下次重生成全丢。坚持 interfaceOnly=true 从源头避免。

坑二:useJackson3=true 单独用。 该选项只在 useSpringBoot4=true 时允许;单独打开会导致生成代码引用不存在的依赖。两者要成对出现。

坑三:契约里的 operationId 随手起名。 它直接变成客户端的方法名。上线后再改等于破坏客户端 API。定契约时就要按「动词 + 资源」起好,并纳入评审。

坑四:只在本地生成,CI 不校验。 本地生成成功不代表 CI 里能生成——生成器版本、Java 版本、字符集都可能不同。把 generate 绑定到 generate-sources 生命周期,让 CI 每次构建都重新生成。

小结

  • 代码先行(7.1)上手快但契约易失真;只要有服务端之外的第二方消费接口,就该切到契约先行。
  • 契约 YAML 放在独立的 book-loan-api 模块,进 git、走评审;operationId、required、tags 都会影响生成物,改之前要当成 API 变更。
  • 生成用 openapi-generator-maven-plugin 7.26.0;Spring Boot 4 要成对设置 useSpringBoot4=true 与 useJackson3=true。
  • 边界原则是「生成目录只读、手写目录只写」:interfaceOnly=true 且生成到 target 下,实现类写在 src/main/java,覆盖就永远不会发生。
  • 生成的 DTO 不要加行为,用组合或 Mapper 映射到领域对象。
  • CI 至少四道关:validate、spectral lint、漂移检查、破坏性变更检测(oasdiff)。
  • 契约先行与 4.3 的契约测试互补:契约定形状、测试验行为,消费者用生成的 SDK 调服务端,三方同源。

契约能生成代码、能进 CI 之后,最后一个问题是:契约本身怎么随时间演进? 接口总要加字段、改语义、下线旧版本。7.3 会给出兼容性判定清单、废弃流程,以及 Spring Boot 4 内置的 API Versioning 怎么用。

阅读导航:上一节:7.1 OpenAPI 与 SpringDoc · 下一节:7.3 版本演进与兼容性 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计