本节目标:把 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/java | Controller、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 讲过的契约测试,是让消费者把「我期望你怎么应答」固化成测试。把它和本节串起来,闭环是这样:
- 契约 YAML 定义形状。
- 生成器产出服务端接口,以及客户端 SDK——把 spring 生成器的
library设为spring-http-interface,就得到一套@HttpExchange接口式客户端(正是 Spring Boot 4.0 新增的 HTTP Service Clients 能力)。 - 消费者在契约测试里用生成的客户端调用服务端。
- 服务端用 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-plugin7.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 版本演进与兼容性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。