用 OpenAPI 生成长期可用的 Java 客户端
在 Spring Boot 项目里,OpenAPI 很容易被当成 Swagger UI 的配套文档。接口能打开,页面能看,似乎就够了。
但只要你开始用
OpenAPI Generator 生成客户端,事情就变了。它不关心你在 Controller 里怎么想,也不会替你判断哪个错误响应会出现、哪个泛型应该叫什么名字、某个 query 参数是不是从 1 开始。它只读 OpenAPI 文件。这里真正要看的,是生成出来的客户端能不能长期用。Swagger UI 只是顺手检查。
我一般按下面这个顺序处理:先固定接口入口,再补响应和参数,然后整理 DTO,最后真的生成一次 Java client 看结果。很多问题光看文档页面看不出来,生成一次就很明显。
先固定接口入口
对客户端生成器来说,一个接口从
operationId 开始,而不是从 Java 方法名开始。Java 方法名可以随着重构变化,Controller 也可以拆开。但只要这个接口已经给外部客户端用,
operationId 就应该尽量稳定。它会变成生成客户端里的方法名。这里省事,后面改 SDK 的时候就会付账。用 OpenAPI 生成长期可用的 Java 客户端 (java)
@Operation(
operationId = "getUserById",
summary = "查询用户详情",
description = "根据用户 ID 查询单个用户的详细信息。",
tags = {"User"},
security = {@SecurityRequirement(name = "BearerAuth")}
)
@GetMapping("/users/{id}")
public UserDetailResponse getUserById(...)这几个字段别混在一起用:
字段 | 写什么 |
|---|---|
operationId | 客户端方法名,唯一且不要轻易改 |
summary | 一句话说明接口做什么 |
description | 补充边界、业务规则和注意事项 |
tags | 接口分组,通常会影响生成后的 API 类 |
deprecated | 标记废弃接口 |
security | 引用认证方案 |
我会先处理
operationId 和 tags,再看响应体和参数。否则后面 schema 写得再细,生成出来的方法名和分组还是会乱。响应不要只写成功场景
很多 Controller 只靠 Java 返回值也能让 Swagger UI 显示 200 响应。但客户端还要处理失败。
如果错误响应不写进 OpenAPI,生成器就不知道失败时会拿到什么结构。最后客户端只能处理原始响应,或者退回到一堆不稳定的异常分支。我的做法是:成功响应写业务模型,失败响应统一引用
ErrorResponse。用 OpenAPI 生成长期可用的 Java 客户端 (java)
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "查询成功",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = UserDetailResponse.class)
)
),
@ApiResponse(
responseCode = "400",
description = "请求参数错误",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ErrorResponse.class)
)
),
@ApiResponse(
responseCode = "404",
description = "用户不存在",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ErrorResponse.class)
)
)
})204 No Content 要单独处理。它没有响应体,就明确写空 content:用 OpenAPI 生成长期可用的 Java 客户端 (java)
@ApiResponse(
responseCode = "204",
description = "删除成功",
content = @Content()
)写完以后看一眼导出的
/v3/api-docs。有时 springdoc 会受返回类型、全局配置或注解组合影响,重新推断出 application/json 响应体。最终还是以导出的 OpenAPI 文件为准。参数要少靠推导
参数最容易出现“人知道,规格不知道”的问题。人看到
@PathVariable、@RequestParam 大概能猜出来,但生成器只看 OpenAPI 里的 parameter。路径参数至少要写清楚名字、来源和示例:
用 OpenAPI 生成长期可用的 Java 客户端 (java)
public UserDetailResponse getUserById(
@Parameter(
name = "id",
in = ParameterIn.PATH,
description = "用户 ID",
required = true,
example = "123"
)
@PathVariable("id")
Long id
) { ... }查询参数建议把默认值和边界也写进
schema。@RequestParam(defaultValue = "1") 和 @Min(1) 通常能被 springdoc 推导出来,但既然目标是生成客户端,我更愿意把这些信息直接写明。用 OpenAPI 生成长期可用的 Java 客户端 (java)
public PageResponseUserSummary listUsers(
@Parameter(
name = "page",
in = ParameterIn.QUERY,
description = "页码,从 1 开始",
example = "1",
schema = @Schema(defaultValue = "1", minimum = "1")
)
@RequestParam(name = "page", defaultValue = "1")
@Min(1)
Integer page,
@Parameter(
name = "size",
in = ParameterIn.QUERY,
description = "分页大小",
example = "20",
schema = @Schema(defaultValue = "20", minimum = "1", maximum = "100")
)
@RequestParam(name = "size", defaultValue = "20")
@Min(1)
@Max(100)
Integer size
) { ... }Header 也是一样。认证头走 security scheme,业务 header 或追踪 header 则直接写在接口上:
用 OpenAPI 生成长期可用的 Java 客户端 (java)
public ResponseEntity<Void> demo(
@Parameter(
in = ParameterIn.HEADER,
name = "X-Request-Id",
description = "请求唯一标识,便于链路追踪",
required = false,
example = "req-20240101-0001"
)
@RequestHeader(name = "X-Request-Id", required = false)
String requestId
) { ... }请求体不要只写 Spring 的
@RequestBody。Spring 注解负责运行时绑定,Swagger 注解负责写 OpenAPI。这里最好把 content 和 schema 都补上。用 OpenAPI 生成长期可用的 Java 客户端 (java)
public ResponseEntity<UserDetailResponse> createUser(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "创建用户请求体",
required = true,
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = CreateUserRequest.class)
)
)
@Valid
@org.springframework.web.bind.annotation.RequestBody
CreateUserRequest request
) { ... }这和响应体的写法是一回事:媒体类型是什么,schema 是哪个类,直接写出来。
DTO 比 Controller 更影响客户端质量
Controller 注解决定接口入口,DTO 决定生成出来的模型好不好用。
类上写清楚模型名和用途,字段上写清楚含义、示例和约束。约束不要只写在
@Schema 里,运行时校验也要跟上。用 OpenAPI 生成长期可用的 Java 客户端 (java)
@Schema(name = "CreateUserRequest", description = "创建用户请求")
public class CreateUserRequest {
@Schema(description = "用户名", example = "alice", maxLength = 32)
@NotBlank
@Size(max = 32)
private String username;
@Schema(description = "邮箱地址", example = "[email protected]")
@Email
private String email;
@Schema(description = "用户年龄", example = "18", minimum = "0", maximum = "150")
@Min(0)
@Max(150)
private Integer age;
@ArraySchema(
arraySchema = @Schema(description = "用户标签列表"),
schema = @Schema(description = "单个标签", example = "vip")
)
private List<String> tags;
@Schema(description = "用户状态", example = "ENABLED")
private UserStatus status;
}List 字段要分清两层:arraySchema 描述集合本身,schema 描述集合里的元素。示例也要放在正确层级。否则生成出来的模型可能还对,但示例和文档会很别扭。分页响应也别直接把匿名泛型丢给生成器。常用分页结果最好有明确 DTO。
用 OpenAPI 生成长期可用的 Java 客户端 (java)
@Schema(name = "PageResponseUserSummary", description = "用户列表分页响应")
public class PageResponseUserSummary {
@Schema(description = "当前页码", example = "1")
private Integer page;
@Schema(description = "每页大小", example = "20")
private Integer size;
@Schema(description = "总记录数", example = "100")
private Long total;
@ArraySchema(
arraySchema = @Schema(description = "用户列表"),
schema = @Schema(implementation = UserSummary.class)
)
private List<UserSummary> records;
}枚举也要按真实 JSON 来写。如果
@JsonValue 输出的是 code 或数字,OpenAPI 里的示例和说明就不要再写 Java 枚举名。用 OpenAPI 生成长期可用的 Java 客户端 (java)
@Schema(description = "用户状态枚举", example = "ENABLED")
public enum UserStatus {
ENABLED,
DISABLED,
LOCKED
}错误响应也值得单独建模。不要只在全局异常处理器里返回它,还要在每个失败响应的
@ApiResponse 里引用它。用 OpenAPI 生成长期可用的 Java 客户端 (java)
@Schema(name = "ErrorResponse", description = "统一错误响应")
public class ErrorResponse {
@Schema(description = "错误码", example = "USER_NOT_FOUND")
private String code;
@Schema(description = "错误信息", example = "用户不存在")
private String message;
@Schema(description = "请求 ID,便于排查问题", example = "req-20240101-0001")
private String requestId;
}Bearer 认证别写成 apiKey
HTTP Bearer 认证的安全方案很短:
用 OpenAPI 生成长期可用的 Java 客户端 (java)
@SecurityScheme(
name = "BearerAuth",
type = SecuritySchemeType.HTTP,
scheme = "bearer",
bearerFormat = "JWT"
)
@Configuration
public class OpenApiSecurityConfig {
}这里不需要
in = SecuritySchemeIn.HEADER。in: header 是 apiKey 安全方案里的字段,不是 HTTP Bearer 的核心字段。如果生成客户端需要默认服务地址,再补一个全局
OpenAPI bean:用 OpenAPI 生成长期可用的 Java 客户端 (java)
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("Hive Application API")
.version("v1")
.description("Hive 后台服务接口"))
.servers(List.of(
new Server().url("https://api.example.com").description("生产环境"),
new Server().url("https://staging-api.example.com").description("预发布环境")
));
}很多生成器会把第一个
server 当作默认 base URL。这个值不要随手写,最好和你准备给客户端使用的环境一致。真的生成一次 Java client
OpenAPI JSON 写得再漂亮,也不如生成一次客户端来得直接。
macOS 可以用 Homebrew 装 OpenAPI Generator:
用 OpenAPI 生成长期可用的 Java 客户端 (shell)
brew install openapi-generator先看 Java generator 当前支持什么配置:
用 OpenAPI 生成长期可用的 Java 客户端 (shell)
openapi-generator config-help -g java这个命令比网上复制来的旧命令可靠。不同 generator 的选项不一样,同一个 generator 不同版本也会变。比如 Java 21 项目通常仍然用
dateLibrary: "java8" 这组配置,因为它指的是 JSR-310 的 java.time 类型,不是“只能在 Java 8 使用”。正式项目里,我更倾向于把生成选项放进
config.json:用 OpenAPI 生成长期可用的 Java 客户端 (json)
{
"library": "restclient",
"groupId": "com.ryan.openapi.harbor",
"artifactId": "harbor-client",
"artifactVersion": "1.0.0",
"apiPackage": "com.ryan.openapi.harbor.client.api",
"modelPackage": "com.ryan.openapi.harbor.client.model",
"invokerPackage": "com.ryan.openapi.harbor.client.invoker",
"dateLibrary": "java8",
"hideGenerationTimestamp": true,
"generateBuilders": true,
"licenseName": "MIT",
"licenseUrl": "https://opensource.org/license/mit"
}restclient 使用 Spring RestClient,适合 Spring Framework 6.1+ / Spring Boot 3.2+。旧项目先看依赖版本,不合适就改用 resttemplate、webclient 或 okhttp-gson。下面用 Harbor 的公开 Swagger 2.0 文件做例子。OpenAPI Generator 可以处理 Swagger 2.0 和 OpenAPI 3.x;自己的 Spring Boot 项目里,直接把
-i 换成 /v3/api-docs 导出的 OpenAPI 3 文件。用 OpenAPI 生成长期可用的 Java 客户端 (shell)
openapi-generator generate \
-i https://raw.githubusercontent.com/goharbor/harbor/refs/heads/main/api/v2.0/swagger.yaml \
-g java \
-o ./generated/harbor-client \
-c config.json参数不多:
参数 | 含义 |
|---|---|
-i / --input-spec | OpenAPI JSON/YAML 文件路径或 URL |
-g / --generator-name | 目标生成器,这里是 java |
-o / --output | 生成代码的输出目录 |
-c / --config | JSON 或 YAML 配置文件 |
临时试选项时,可以把少量配置写到命令行:
用 OpenAPI 生成长期可用的 Java 客户端 (shell)
openapi-generator generate \
-i https://raw.githubusercontent.com/goharbor/harbor/refs/heads/main/api/v2.0/swagger.yaml \
-g java \
--library restclient \
-o ./generated/harbor-client \
--api-package com.ryan.openapi.harbor.client.api \
--model-package com.ryan.openapi.harbor.client.model \
--invoker-package com.ryan.openapi.harbor.client.invoker \
--additional-properties=hideGenerationTimestamp=true,generateBuilders=true但只要这件事要重复做,就别把命令写成一长串。命令行保留输入文件、目标语言、输出目录和配置文件路径就够了,生成策略放在配置文件里。
生成以后看什么
命令成功只是第一步。我还会打开生成目录快速扫一遍:
operationId变成的方法名是否可读。- API 类是否按
tags分到了预期位置。 pom.xml里的groupId、artifactId、版本是否正确。- API、model、invoker 是否落在预期 package 下。
- 错误响应是否有稳定类型,别最后散成原始响应。
- 分页、列表、枚举有没有奇怪的匿名模型名。
204响应有没有被生成响应体。- 生成代码是否真的使用了预期 HTTP library,比如
restclient。
如果这里出现
inline_object、object、MapStringObject 这类模型名,通常先回头看 OpenAPI 规格。大概率还有地方没说清楚。几个容易踩的坑
- 省略
operationId。路径或 Java 方法名一变,客户端方法名也跟着变。 - 只写 200 响应。错误处理最后会散在客户端各处。
- 把分页泛型直接暴露给 OpenAPI。生成器很容易给你造出难读的模型名。
@RequestBody只写 Spring 注解,不写 OpenAPI 的 requestBody content。- 字段约束只写在描述里。文档看懂了,运行时和客户端不一定知道。
- 枚举文档和 Jackson 序列化不一致。生成时是一种值,运行时收到另一种值。
- 直接生成到当前目录。
-o ./很快会把项目根目录弄乱,放到generated/<client-name>更好。 - 旧 Spring 项目直接选
restclient。先看 Spring Framework / Spring Boot 版本。