主题
界面
主题
S3-REST 定了接口的设计规范,《Spring MVC 请求映射与参数绑定》 把它写成了代码。本节解决最后一个协作问题:
前端怎么知道这些接口长什么样?
没有文档: 前端问后端 → 后端截图/口述 → 前端猜着写 → 联调时发现全错 → 又改一遍
有文档: 前端打开 Swagger UI → 自己试 → 直接写对本节要建立的核心认知:
好的接口文档不是"写"出来的,是"生成"出来的——因为手写的文档一定会过期。
完成本节后,你应该能够:
@Tag / @Operation / @Parameter / @ApiResponse);@RestController 与三种参数绑定吗?(《Spring MVC 请求映射与参数绑定》)"手写接口文档"的三个致命问题:
| # | 问题 | 后果 |
|---|---|---|
| 1 | 一定会过期 | 代码改了文档没改——过期的文档比没有文档更糟 ✗(它会主动误导人) |
| 2 | 没人愿意写 | 开发觉得"代码都写了,还要写一遍文档" ✗ |
| 3 | 不能试 | Word/Markdown 里的接口,前端没法直接调——只能读,不能验证 |
真实场景:
后端:"我把接口文档更新到 Confluence 了"
前端:打开一看 —— 最后一次编辑是 3 个月前 ✗
前端:自己读代码猜参数字段吧
联调:字段名一个都对不上 → 又花半天"代码即文档"解决了全部三个问题:
| 问题 | 代码即文档的解法 |
|---|---|
| 会过期 | 文档从代码生成——代码改了,文档自动跟着变 ✓ |
| 没人写 | 只写"注解"(一两行),不写另一份文档 ✓ |
| 不能试 | Swagger UI 提供"试试看"按钮——能直接发请求 ✓ |
你的代码(@RestController + 注解 + 类型信息)
│ springdoc 扫描并生成
▼
OpenAPI 文档(一份 JSON) ← 标准格式,任何工具都能读
│ Swagger UI 渲染
▼
可视化页面(能看、能试)| 名词 | 是什么 | 分工 |
|---|---|---|
| OpenAPI | 一套描述 API 的规范(原 Swagger 规范) | 数据格式(JSON/YAML) |
| Swagger UI | 一个网页工具 | 把 OpenAPI 文档渲染成可交互页面 |
| springdoc | Spring Boot 的集成库 | 从代码生成 OpenAPI 文档 ✓ |
一个常见混淆:Swagger 现在既指"规范"也指"工具家族"——但规范已改名为 OpenAPI ✓ 日常沟通里说"看下 Swagger"一般指"看那个可视化页面" ✓
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.7.0</version>
</dependency>引完依赖就自动生效 ✓(又一次自动配置的力量,《Spring Boot 自动配置与项目结构》)
两个默认端点:
| 端点 | 内容 |
|---|---|
/v3/api-docs | OpenAPI 文档(JSON) —— 给工具读 |
/swagger-ui/index.html | Swagger UI 页面 —— 给人看 |
本项目实测:
GET /v3/api-docs → 200,返回 JSON
GET /swagger-ui/index.html → 200 ✓文档内容(实测输出,节选):
{"openapi":"3.0.1",
"info":{"title":"OpenAPI definition","version":"v0"},
"servers":[{"url":"http://localhost:18080","description":"Generated server url"}],
"tags":[{"name":"订单接口",
"description":"订单的创建、查询与删除;错误响应统一为 code + message + traceId + details"}],
"paths":{"/api/orders":{"get":{"tags":["订单接口"],
"summary":"分页查询订单",
"description":"size 上限 100;status 为可选筛选条件",
"operationId":"list","parameters":[{"name...注意三件事:
| 观察 | 说明 |
|---|---|
paths 是自动生成的 | 从 @GetMapping / @PostMapping 推导 |
tags 里的描述来自 @Tag | 人话描述是"加注解"补的 ✓ |
parameters 也是自动的 | 从 @RequestParam / @PathVariable 的类型推导 |
⚠️ info.title 是默认的 OpenAPI definition ✗ —— 生产项目要改:
springdoc:
api-docs:
path: /v3/api-docs
swagger-ui:
path: /swagger-ui.html # 自定义入口
tags-sorter: alpha
operations-sorter: method
info:
title: 订单服务 API
description: 订单的创建、查询与删除
version: v1.2.0
contact:
name: 后端团队自动生成的文档有个问题:它只有"结构",没有"意图" ✗
自动生成:GET /api/orders/{orderNo} → 参数 orderNo: string前端看到这个会想问:
| 前端的问题 | 自动文档能回答吗 |
|---|---|
orderNo 长什么格式? | ❌ |
| 查不到会返回什么? | ❌(只有 200 的响应结构) |
| 这个接口做什么的? | ❌ |
size 能传多大? | ❌ |
补上四个注解,文档就"说人话"了:
@Tag(name = "订单接口", description = "订单的创建、查询与删除;错误响应统一为 code + message + traceId + details")
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@Operation(summary = "创建订单",
description = "校验通过返回 201 + Location 头;校验失败返回 422 与逐字段 details")
@ApiResponse(responseCode = "201", description = "创建成功")
@ApiResponse(responseCode = "422", description = "参数校验失败(VALIDATION_FAILED)")
@PostMapping
public ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest request) { ... }
@Operation(summary = "按订单号查询", description = "不存在时返回 404 与 RESOURCE_NOT_FOUND")
@GetMapping("/{orderNo}")
public OrderResponse find(
@Parameter(description = "订单号,形如 ORD-1", example = "ORD-1") @PathVariable String orderNo) { ... }
@Operation(summary = "分页查询订单", description = "size 上限 100;status 为可选筛选条件")
@GetMapping
public PageResponse<OrderResponse> list(...) { ... }
}四个注解的分工:
| 注解 | 用在哪 | 补什么信息 |
|---|---|---|
@Tag | 类上 | 这组接口属于哪个模块(UI 上会分组) |
@Operation | 方法上 | summary(一句话)+ description(补充说明) |
@Parameter | 参数上 | 参数的含义、示例值 |
@ApiResponse | 方法上 | 可能返回的状态码与含义 |
实测效果(文档里的内容):
"tags":[{"name":"订单接口","description":"订单的创建、查询与删除;错误响应统一为 code + message + traceId + details"}],
"/api/orders":{"get":{"tags":["订单接口"],
"summary":"分页查询订单",
"description":"size 上限 100;status 为可选筛选条件", ...}}注解写得对不对,直接看渲染出来的页面 ✓
⚠️ 三个实践要点:
| 要点 | 说明 |
|---|---|
| 注解不要重复描述类型信息 | 字段名、类型、必填已经自动生成了——别在 description 里再抄一遍 ✗ |
@Operation 的 summary 一句话说清"做什么" | "创建订单" ✓ 而不是"这个接口用来创建订单..." ✗ |
@ApiResponse 要覆盖错误码 ★ | 前端最需要知道的是"什么情况下会失败" ✓ |
一张对比表("最少" vs "有用"):
| 内容 | 自动生成 | 需要手写 | 前端最关心 |
|---|---|---|---|
| 路径与方法 | ✅ | — | 需要 |
| 字段名与类型 | ✅ | — | 需要 |
| 必填 / 可选 | ✅(required = false 等) | — | 需要 |
| 校验规则(长度、范围) | ⚠️ 部分(@Size 等会带出来) | 补充 | 很需要 ✓ |
| 示例值 | ❌ | @Parameter(example=...) | 很需要 ✓ |
| 错误码与含义 | ❌ | @ApiResponse | 很需要 ✓ |
| 业务语义(如"status 的取值含义") | ❌ | description | 很需要 ✓ |
| 调用顺序 / 依赖关系 | ❌ | description 或单独文档 | 需要 |
一条判断标准:
"前端看完这份文档,能不能不问任何人就把接口调对?"
不能 → 说明该补的注解还没补。
本项目文档里的一句话示例(值得学的写法):
@Tag(name = "订单接口",
description = "订单的创建、查询与删除;错误响应统一为 code + message + traceId + details")这一句同时说明了三件事:
| 信息 | 前端能据此做什么 |
|---|---|
| 接口范围 | 知道这一组是订单相关 ✓ |
| 错误响应的统一结构 | 写一套错误处理逻辑就够了 ✓ |
| 有 traceId | 报错时能给客服一个编号 ✓ |
没有 Swagger UI 时:
① 后端开发完接口
② 写文档(或口头讲)
③ 前端按文档写(文档可能已过期 ✗)
④ 联调:字段名对不上、状态码理解不一致
⑤ 反复沟通 → 效率低有了 Swagger UI 后:
① 后端开发完接口(带注解)
② 前端打开 /swagger-ui/index.html
③ 在页面上**直接点"Try it out"发请求**,看真实响应 ✓
④ 前端按真实响应写代码 → 联调时基本一次通过关键差别在第三步:"能试"比"能看"重要得多 ✓
| 只读文档 | 能试的文档 | |
|---|---|---|
| 参数格式 | 猜 | 试一下就知道 ✓ |
| 响应结构 | 看示例 | 看到真实数据 ✓ |
| 错误响应 | 常常没写 | 自己造一个错看 ✓ |
一个实用的协作约定:
"接口文档的入口,应该在服务启动后能被任何人访问到。"
本地开发时:
http://localhost:18080/swagger-ui/index.html✓ 测试环境:http://test-api.example.com/swagger-ui/index.html✓(前端随时能自助联调) 生产环境:关闭(见 5.6)✗
先说结论:默认关闭 ✓
为什么:
| 风险 | 说明 |
|---|---|
| 接口结构完全暴露 | 攻击者拿到"所有接口 + 参数 + 错误码"——省了他做侦察的功夫 ✗ |
| 可能被自动化扫描 | /v3/api-docs 是众所周知的路径,会被扫描器发现 ✓ |
| "试试看"按钮的风险 | 如果生产环境的写接口没有权限校验,任何人都能通过 UI 发请求 ✗ |
三种处理方式:
# 方式一:按 profile 关闭(最简单)
springdoc:
api-docs:
enabled: false # 在 application-prod.yml 里设为 false
swagger-ui:
enabled: false// 方式二:生产环境仍需文档时,加鉴权(只有登录用户能访问)
@Configuration
public class OpenApiSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**").hasRole("DEVELOPER")
.anyRequest().authenticated());
return http.build();
}
}方式三:暴露"精简版"文档(只给前端需要的部分,隐藏内部接口):
// 给接口打上"内部"标记,用 GroupedOpenApi 只暴露公开的那部分
@Bean
public GroupedOpenApi publicApi() {
return GroupedOpenApi.builder()
.group("public")
.pathsToMatch("/api/**")
.pathsToExclude("/api/internal/**") // 内部接口不暴露
.build();
}推荐的组合:
| 环境 | Swagger UI | 说明 |
|---|---|---|
| 本地开发 | ✅ 开 | 自己调试方便 |
| 测试环境 | ✅ 开 | 前端自助联调 ✓ |
| 预发环境 | ⚠️ 开但加鉴权 | 需要给测试同学用 |
| 生产 | ❌ 关 | 需要文档时走"内部文档站"(离线导出的 JSON 渲染) |
一个实用的补充:生产关了 UI,仍可以把 OpenAPI JSON 导出来放到内部文档站:
# 在测试环境导出,作为"发布文档"归档
curl.exe -s http://test-api.example.com/v3/api-docs -o openapi-v1.2.0.json这样既不影响安全,也不丢失文档 ✓
先说清楚两者的差别:
| 文档 | 契约 | |
|---|---|---|
| 形式 | 一份描述(OpenAPI JSON、网页) | 一份可执行的约定 + 验证 |
| 谁读 | 人 | 测试代码 / 工具 |
| 改了会怎样 | 文档更新,没人红 ⚠️ | 测试变红 ✓ |
| 是否阻止上线 | ❌ | ✅ 可以 |
"文档"的核心弱点:它只能"告知",不能"强制" ✗
后端把响应字段从 userName 改成 username
→ 文档自动更新了(代码即文档的好处 ✓)
→ 但前端的代码没跟着改 ✗
→ 上线后前端拿不到数据 ✗契约测试解决的就是这个:
把"接口的约定"写成可执行的测试
→ 后端改了不兼容的地方 → 测试红 → 拦住合并 ✓三种层次的保障(从弱到强):
| 层次 | 手段 | 强度 |
|---|---|---|
| 1 | 写文档(或代码即文档) | ⚠️ 靠人自觉遵守 |
| 2 | 接口测试(TB 接口测试) | ✅ 保证"接口按预期工作" |
| 3 | 契约测试(T2-007) | ✅✅ 保证"双方的约定"一致 |
一句话区分:
接口测试验证"我的接口对不对";契约测试验证"我们俩对接口的理解是否一致"。
一个具体例子:
// 接口测试(后端自己写):验证响应结构和状态码
mockMvc.perform(get("/api/orders/ORD-1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.orderNo").value("ORD-1")); // ✓ 后端自己定义的结构
// 契约测试(前后端共同维护):验证"前端期望的字段都在"
// 前端声明它需要的契约 → 后端跑这个契约 → 缺字段就红实践建议:中小团队先把"接口测试"做扎实(T2-001),跨团队协作(前端独立团队、外部对接)时再上契约测试 ✓
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.7.0</version>
</dependency>访问 /swagger-ui/index.html —— 已经能看到全部接口了 ✓(虽然描述都是自动生成的)
见第 7 节。
@Operation(summary = "按订单号查询订单,参数 orderNo 是订单号,类型是字符串",
description = "参数 orderNo:string,必填,路径参数")
public OrderResponse find(@PathVariable String orderNo) { ... }问题:字段名、类型、是否必填——这些文档已经自动生成了 ✗
重复的三处坏处:
| 坏处 | 说明 |
|---|---|
| 冗余 | UI 上同一信息出现两遍 |
| 会不一致 | 类型改成 Long 了,描述的"string"忘了改 ✗ |
| 占位 | 真正有价值的信息(格式、业务语义)反而没地方写 ✗ |
正确写法:
@Operation(summary = "按订单号查询", description = "不存在时返回 404 与 RESOURCE_NOT_FOUND")
public OrderResponse find(
@Parameter(description = "订单号,形如 ORD-1", example = "ORD-1") @PathVariable String orderNo) { ... }对比:描述写"格式"和"失败行为",不写"类型" ✓
文件清单(labs/spring-boot-web/):
pom.xml springdoc 依赖
src/main/java/cn/kb/learn/web/controller/OrderController.java 注解POM:
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.7.0</version>
</dependency>Controller(注解部分):
@Tag(name = "订单接口", description = "订单的创建、查询与删除;错误响应统一为 code + message + traceId + details")
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@Operation(summary = "创建订单",
description = "校验通过返回 201 + Location 头;校验失败返回 422 与逐字段 details")
@ApiResponse(responseCode = "201", description = "创建成功")
@ApiResponse(responseCode = "422", description = "参数校验失败(VALIDATION_FAILED)")
@PostMapping
public ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest request) { ... }
@Operation(summary = "按订单号查询", description = "不存在时返回 404 与 RESOURCE_NOT_FOUND")
@GetMapping("/{orderNo}")
public OrderResponse find(
@Parameter(description = "订单号,形如 ORD-1", example = "ORD-1") @PathVariable String orderNo) { ... }
@Operation(summary = "分页查询订单", description = "size 上限 100;status 为可选筛选条件")
@GetMapping
public PageResponse<OrderResponse> list(...) { ... }
}运行命令:
cd labs
./mvnw -pl spring-boot-web package -DskipTests
java "-Dstdout.encoding=UTF-8" -jar spring-boot-web/target/spring-boot-web.jar
# 另开一个终端
curl.exe -s "http://localhost:18080/v3/api-docs" # OpenAPI JSON
curl.exe -s -o NUL -w "%{http_code}" "http://localhost:18080/swagger-ui/index.html" # 200预期输出(真实结果):
# OpenAPI JSON(节选)
{"openapi":"3.0.1",
"info":{"title":"OpenAPI definition","version":"v0"},
"servers":[{"url":"http://localhost:18080","description":"Generated server url"}],
"tags":[{"name":"订单接口",
"description":"订单的创建、查询与删除;错误响应统一为 code + message + traceId + details"}],
"paths":{"/api/orders":{"get":{"tags":["订单接口"],
"summary":"分页查询订单",
"description":"size 上限 100;status 为可选筛选条件",
"operationId":"list","parameters":[{"name...
# Swagger UI
200三处值得注意的:
| 观察 | 说明了什么 |
|---|---|
paths 里有 /api/orders | 从代码自动推导——不用手写 ✓ |
summary / description 是注解里的原话 | 注解补的是"意图",不是"结构" ✓ |
tags 的 description 说明了"错误响应统一结构" | 一句话让前端知道"错误怎么处理" ✓ |
在浏览器打开 http://localhost:18080/swagger-ui/index.html,你会看到:
订单接口
GET /api/orders 分页查询订单 [Try it out]
POST /api/orders 创建订单 [Try it out]
GET /api/orders/{orderNo} 按订单号查询 [Try it out]
DELETE /api/orders/{orderNo} [Try it out]点"Try it out"就能直接发请求——这是它相对"静态文档"的核心优势 ✓
/v3/api-docs 的处理方法处断点(或直接看 JSON):观察文档是在请求时动态生成的(所以永远与代码一致);@Operation 去掉,刷新页面——观察接口还在,但描述没了 ✓(说明"结构自动、意图手写");@GetMapping("/{orderNo}") 改成 "/find/{orderNo}"——观察文档里的路径跟着变 ✓(这就是"不会过期"的机制);@ApiResponse(responseCode = "999")——观察页面上的奇怪状态码(说明注解不做语义校验,写错也能渲染)✗;application.yml 里加 springdoc.swagger-ui.enabled: false——观察 UI 变成 404,但 /v3/api-docs 仍然可用(可以只关 UI 保留 JSON)。症状:文档里全是 OpenAPI definition、/api/orders 这种自动生成的名字,人读不懂 ✗
修复:补 @Tag / @Operation,并把 info.title 配好 ✓
见 6.3 节。
见 5.6 节。这是安全问题,不是"不方便" ✗
@Operation(summary = "创建订单") // ✗ 没写会返回哪些错误前端最需要知道的恰恰是"什么情况下会失败" ✓(因为成功路径从类型定义就能看出来)
修复:
@ApiResponse(responseCode = "201", description = "创建成功")
@ApiResponse(responseCode = "422", description = "参数校验失败(VALIDATION_FAILED,含逐字段 details)")见 5.7 节。文档更新了,前端的代码不会跟着变 ✗
修复:跨团队协作时上契约测试(T2-007)✓
springdoc:
info:
version: v0 # ✗ 永远是 v0症状:前端看不出"线上跑的是哪个版本",排查时双方对不齐 ✗
修复:把版本号做成"构建时注入":
springdoc:
info:
version: @project.version@ # Maven 资源过滤会替换或用构建信息端点(/actuator/info)统一暴露 ✓
症状:一个大项目里,所有模块的接口混在一页——前端要滚很久才找到自己的 ✗
修复:用 @Tag 分组(或用 GroupedOpenApi 按路径分组)✓
@Bean
public GroupedOpenApi orderApi() {
return GroupedOpenApi.builder().group("订单").pathsToMatch("/api/orders/**").build();
}
@Bean
public GroupedOpenApi userApi() {
return GroupedOpenApi.builder().group("用户").pathsToMatch("/api/users/**").build();
}目标:把"代码即文档"和"联调流程"亲手体验一遍。
步骤:
/{orderNo} → /{no}),重启,刷新页面——观察文档跟着变了 ✓;find 方法加 @ApiResponse(responseCode = "404", description = "..."),观察页面上的响应说明;@ApiResponse 的状态码写成 "abc",看它是否仍能渲染(提示:能);application-prod.yml 里加:springdoc:
swagger-ui:
enabled: false
api-docs:
enabled: false用 --spring.profiles.active=prod 启动,验证两个端点都 404; 6. 导出文档归档:用 curl.exe -s http://localhost:18080/v3/api-docs -o openapi.json 导出,说明为什么"生产关 UI + 归档 JSON"是个好组合。
验收方式:能说出第 2 步"文档为什么不会过期"、第 5 步为什么要关。
扩展任务:给 CreateOrderRequest 加一个自定义校验注解(《参数校验与全局异常处理》 的实验任务),并观察它是否出现在文档里——体会"自动生成能覆盖到哪、注解要补到哪"。
基础题
代码阅读题
@Operation(summary = "user接口", description = "userId是用户ID,类型Long,必填")
@GetMapping("/api/users/{userId}")
public UserResponse getUser(@PathVariable Long userId) { ... }USER_NOT_FOUND,注解该怎么改?编程题
GET /api/articles(分页 + 关键词 + 标签筛选)、POST /api/articles、GET /api/articles/{id}、DELETE /api/articles/{id};@Tag,说明"错误响应结构";@Operation(summary 一句话,description 说清业务规则与失败行为);@Parameter 与 example;info.title / version)配置好;/v3/api-docs 里 tags 与某个 path 的片段,证明注解生效;综合任务
提示 1(方向):第 1 题的方向反了——OpenAPI 是规范,Swagger UI 是渲染工具。
提示 2(关键点):第 4 题的两处问题:summary 没意义 + 重复了自动生成的类型信息。
提示 3(接近答案):第 7 题的"加字段也可能是破坏性变更"——想想 Kotlin/Swift 里的 when/switch 穷尽性检查,以及 Jackson 的 FAIL_ON_UNKNOWN_PROPERTIES。
| 是什么 | 谁依赖谁 | |
|---|---|---|
| OpenAPI | 一套"如何描述 API"的规范(原 Swagger 规范) | 独立于任何工具 ✓ |
| Swagger UI | 一个渲染 OpenAPI 文档的网页工具 | 消费 OpenAPI 文档 |
为什么这个区分重要:
因为"规范"是通用的——同一份 OpenAPI JSON,可以被 Swagger UI 渲染成网页、被 Postman 导入成集合、被代码生成器生成客户端 SDK、被契约测试工具验证 ✓
如果把它理解成"Swagger UI 的功能",就会以为它只能给人看 ✗ ——而它的最大价值恰恰在"被工具消费" ✓
/v3/api-docs;/swagger-ui/index.html(/swagger-ui.html 会重定向到它)。补充:这两个路径都可以在配置里改 ✓
springdoc:
api-docs:
path: /v3/api-docs
swagger-ui:
path: /docs一个具体的伤害链:
前端看到文档:响应字段是 userName
→ 按这个写代码
→ 联调时发现实际返回的是 username ✗
→ 前端的第一反应不是"文档错了",而是"后端改漏了/后端有 bug"
→ 双方开始互相怀疑、找证据(翻 Git 历史、找邮件)
→ 沟通成本远超"从来没写过文档"更隐蔽的一种:文档里的示例值是旧的——前端拿去跑,跑出来结果是错的,但程序不报错 ✗(比如示例里的 status 取值是旧的枚举)
结论:
文档的价值 = 准确性 × 可获取性。
准确性为 0,整个价值就是 0(甚至为负) ✓
这就是"代码即文档"最根本的优势:它的准确性由构建过程保证,不依赖任何人记得更新。 ✓
第 4 题:两处问题。
| # | 问题 | 说明 |
|---|---|---|
| 1 | summary = "user接口" 没有信息量 | UI 上显示的标题应该说清"做什么"——“查询用户详情” ✓ |
| 2 | description 重复了自动生成的类型信息 | "userId 是用户 ID,类型 Long,必填"——这三项文档都会自动推导 ✗(见 6.3 节) |
还有一个隐藏问题:没写 @Tag——它会落到默认分组里,在大项目里很难找 ⚠️
修复后的写法:
@Tag(name = "用户接口", description = "用户信息查询与维护;错误响应统一为 code + message + traceId + details")
@Operation(summary = "查询用户详情", description = "用户不存在时返回 404 与 USER_NOT_FOUND")
@GetMapping("/api/users/{userId}")
public UserResponse getUser(
@Parameter(description = "用户 ID", example = "1001") @PathVariable Long userId) { ... }第 5 题:补上错误响应的说明。
@Operation(summary = "查询用户详情", description = "用户不存在时返回 404 与 USER_NOT_FOUND")
@ApiResponse(responseCode = "200", description = "查询成功,返回用户详情")
@ApiResponse(responseCode = "404", description = "用户不存在(USER_NOT_FOUND)")
@GetMapping("/api/users/{userId}")
public UserResponse getUser(
@Parameter(description = "用户 ID", example = "1001") @PathVariable Long userId) { ... }为什么"错误码"比"成功响应"更值得写:
| 信息 | 前端能不能从类型定义推出来 |
|---|---|
| 成功时返回什么字段 | ✅ 能从 UserResponse 的定义看出来 |
| 423 状态码代表什么 | ❌ 推不出来——那是业务约定 ✗ |
| 什么情况下会 404 | ❌ 需要业务说明 |
所以一条实用的规则:
自动生成已经覆盖了"成功路径",注解应该把力气花在"失败路径"与"业务语义"上。 ✓
顺带:错误码最好和错误响应里的 code 字段对齐(《参数校验与全局异常处理》 的 USER_NOT_FOUND)——这样文档说的和实际返回的能对上 ✓
@Tag(name = "文章接口",
description = "文章的创建、查询与删除;错误响应统一为 code + message + traceId + details")
@RestController
@RequestMapping("/api/articles")
public class ArticleController {
@Operation(summary = "分页查询文章",
description = "支持关键词模糊匹配标题、标签精确筛选;size 上限 100,"
+ "默认按发布时间倒序;无匹配结果时返回空数组而不是 404")
@ApiResponse(responseCode = "200", description = "查询成功")
@GetMapping
public PageResponse<ArticleResponse> list(
@Parameter(description = "标题关键词(模糊匹配,可空)", example = "索引")
@RequestParam(required = false) String keyword,
@Parameter(description = "标签(精确匹配,可空)", example = "mysql")
@RequestParam(required = false) String tag,
@Parameter(description = "页码,从 1 开始", example = "1")
@RequestParam(defaultValue = "1") int page,
@Parameter(description = "每页条数,最大 100", example = "20")
@RequestParam(defaultValue = "20") int size) { ... }
@Operation(summary = "创建文章",
description = "标题与内容必填;创建成功返回 201 与 Location 头;"
+ "同一作者同一标题已存在时返回 409")
@ApiResponse(responseCode = "201", description = "创建成功")
@ApiResponse(responseCode = "422", description = "参数校验失败(VALIDATION_FAILED,含逐字段 details)")
@ApiResponse(responseCode = "409", description = "标题重复(ARTICLE_TITLE_DUPLICATED)")
@PostMapping
public ResponseEntity<ArticleResponse> create(@Valid @RequestBody CreateArticleRequest request) { ... }
@Operation(summary = "查询文章详情", description = "不存在或已删除时返回 404 与 ARTICLE_NOT_FOUND")
@ApiResponse(responseCode = "200", description = "查询成功")
@ApiResponse(responseCode = "404", description = "文章不存在(ARTICLE_NOT_FOUND)")
@GetMapping("/{id}")
public ArticleResponse find(
@Parameter(description = "文章 ID", example = "1001") @PathVariable Long id) { ... }
@Operation(summary = "删除文章",
description = "逻辑删除:数据仍在库中(deleted=1),不在任何查询里出现;重复删除返回 404")
@ApiResponse(responseCode = "204", description = "删除成功(无响应体)")
@ApiResponse(responseCode = "404", description = "文章不存在(ARTICLE_NOT_FOUND)")
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(
@Parameter(description = "文章 ID", example = "1001") @PathVariable Long id) { ... }
}项目信息配置:
springdoc:
info:
title: 内容服务 API
description: 文章、标签与评论的接口;所有错误响应统一为 code + message + traceId + details
version: v1.0.0
contact:
name: 内容服务团队自动生成 vs 手写:
| 内容 | 谁提供 | 例子 |
|---|---|---|
| 路径与 HTTP 方法 | ✅ 自动 | /api/articles/{id} + GET |
| 参数名与类型 | ✅ 自动 | id: integer(int64) |
| 是否必填 | ✅ 自动 | required = false → 可选 |
| 请求体结构 | ✅ 自动(从 record 推导) | CreateArticleRequest 的字段 |
| 响应体结构 | ✅ 自动 | ArticleResponse / PageResponse |
| 校验规则 | ⚠️ 部分自动(@Size/@Min 会带出约束) | — |
| 业务规则("标题重复返回 409") | ✗ 手写 | @ApiResponse + description ✓ |
| 错误码 | ✗ 手写 | 同上 ✓ |
| 示例值 | ✗ 手写 | @Parameter(example=...) ✓ |
| 调用顺序 / 前置条件 | ✗ 手写 | description ✓ |
| 分页语义(页码从 1 开始、size 上限) | ✗ 手写 | description ✓ |
文档里的片段(预期):
"tags":[{"name":"文章接口","description":"文章的创建、查询与删除;错误响应统一为 code + message + traceId + details"}],
"/api/articles":{"get":{
"tags":["文章接口"],
"summary":"分页查询文章",
"description":"支持关键词模糊匹配标题、标签精确筛选;size 上限 100,默认按发布时间倒序;无匹配结果时返回空数组而不是 404",
"operationId":"list",
"parameters":[
{"name":"keyword","in":"query","description":"标题关键词(模糊匹配,可空)","required":false,"schema":{"type":"string"},"example":"索引"},
{"name":"page","in":"query","description":"页码,从 1 开始","required":false,"schema":{"type":"integer","format":"int32"},"example":"1"}
]}}一句话回答:
自动生成的是"接口长什么样"(结构),手写的是"接口怎么用"(语义与失败行为)。 ✓
《接口协作规范(8 条)》
| # | 规范 | 目的 |
|---|---|---|
| 1 | 文档以 OpenAPI JSON 为唯一来源(代码即文档),禁止维护第二份手写文档 | 消灭"两份不一致" |
| 2 | 每个 Controller 必须有 @Tag 与每个方法必须有 @Operation(summary 一句话说明做什么) | 可读性 |
| 3 | 每个可能失败的状态码必须写 @ApiResponse,且 code 与响应体里的错误码一致 | 前端能处理错误 |
| 4 | 接口的 info.version 由构建注入,与发布版本一致 | 排查时能对齐版本 |
| 5 | 测试环境开放 Swagger UI,生产关闭(生产用归档的 JSON) | 安全 + 可用 |
| 6 | 破坏性变更必须走"新增版本"或"过渡期"(见下表) | 不打断调用方 |
| 7 | 破坏性变更的判定要覆盖"加字段"(见下) | 防止误判 |
| 8 | 关键接口必须有接口测试(跨团队协作的接口再加契约测试) | 让约定可执行 |
兼容变更 vs 破坏性变更:
| # | 变更 | 判定 | 为什么 |
|---|---|---|---|
| 1 | 新增一个"可选"的响应字段 | ⚠️ 通常兼容,但要问客户端 | 严格反序列化(FAIL_ON_UNKNOWN_PROPERTIES=true)或穷尽 switch 的客户端会崩 ✗ |
| 2 | 删除一个响应字段 | ❌ 破坏性 | 客户端读不到了 |
| 3 | 把请求参数从可选改成必填 | ❌ 破坏性 | 老客户端不传 → 400 |
| 4 | 新增一个"必填"的请求参数 | ❌ 破坏性 | 同上 |
| 5 | 新增一个可选请求参数 | ✅ 兼容 | 老客户端不传也能工作 |
| 6 | 把字段类型从 int 改成 string | ❌ 破坏性 | 客户端反序列化失败 |
| 7 | 新增一个错误码 | ⚠️ 半兼容 | 客户端若"未知错误码就走通用兜底"则没问题;若穷尽匹配则要更新 ✓ |
| 8 | 改变默认排序 / 分页默认值 | ⚠️ 行为变更 | 返回值"看起来对但顺序变了" ✗ ——最难发现的一类 |
第 8 条特别值得警惕:它不改变任何字段,但改变了语义 ✓
接口变更流程:
① 提出变更(谁:需求方/前端/后端)
产出:变更说明(含"兼容性判定",对照上表)
↓
② 影响评估(谁:后端 + 受影响的前端/调用方)
产出:影响面清单 + 过渡方案(双写?新增版本?提前通知多久?)
↓
③ 实现 + 补测试(谁:后端)
产出:代码 + 接口测试(覆盖新行为)+ 【契约测试】
↓ ← 这是"能拦住问题"的环节:不兼容的改动会让测试红
④ 更新文档(自动) + 变更记录(谁:后端)
产出:CHANGELOG + 通知(发到协作群/邮件)
↓
⑤ 灰度 / 双跑(谁:后端 + 运维)
产出:新旧接口并存一段时间,监控老接口调用量
↓
⑥ 下线老接口(谁:后端 + 运维)
前提:老接口调用量归零 ≥ N 天能拦住问题的三个环节:
| 环节 | 拦什么 |
|---|---|
| ② 影响评估 | 拦住"忘了通知某个调用方" |
| ③ 测试(含契约测试) | 拦住"改坏了但没人发现" ★ |
| ⑤ 灰度 + 监控 | 拦住"测试环境没覆盖到的调用方" |
契约测试的角色:
| 没有契约测试 | 有契约测试 |
|---|---|
| 靠"发邮件通知 + 人工确认" | 改了就红,跑 CI 就知道 ✓ |
| 调用方不配合就没人知道 | 契约是双方共同维护的,一方改了就挡 |
| 上线后才发现 | 合并前就发现 ✓ |
没有契约测试时的替代方案(务实版):
| 替代 | 强度 | 说明 |
|---|---|---|
| 接口测试 + 严格的"变更清单"评审 | ⚠️ 中 | 靠人,但比"什么都没有"强 |
| 前端写"消费测试"(用真实响应跑一遍关键逻辑) | ✅ 较好 | 前端自己就能拦住"后端改坏了" |
| 上线前跑一遍"冒烟联调"(T2-012) | ⚠️ 中 | 上线前最后一道 |
| 对老接口做调用量监控 | ✅ 好 | 没调用量了才敢删 |
"为什么'接口加字段'看起来无害,仍然可能是破坏性变更":
| 客户端的写法 | 加字段的后果 |
|---|---|
| Jackson 默认(忽略未知字段) | ✅ 没问题 |
FAIL_ON_UNKNOWN_PROPERTIES = true(很多团队为了"早发现契约问题"特意开启) | ❌ 直接反序列化失败 ✗ |
Kotlin data class + 严格 JSON 解析 | ❌ 可能抛异常 |
Kotlin/Swift 的 when / switch(穷尽性要求) | ❌ 编译期就要求加分支——不过编译期报错反而是好事 ✓ |
前端用 Object.keys() 遍历渲染 | ⚠️ 多渲染一行(视觉 bug,不报错)✗ |
| 把响应转成固定列宽的表格 / CSV | ⚠️ 列数变了,导出错位 ✗ |
结论:
"加字段"是否兼容,取决于"客户端如何处理未知字段"——而这不是后端能决定的 ✓
所以规范里要写的是"加字段前要确认调用方的解析策略",而不是简单地把它归为兼容变更 ✓
这条经验可以推广到所有接口设计:
接口的"兼容性"不是由"改了什么"决定的,而是由"调用方假设了什么"决定的。
所以:① 明确定义"调用方可以依赖什么"(契约);② 用测试把它固定下来(契约测试);③ 变更时先确认"有没有人依赖了不该依赖的东西"。 ✓
这正是 T1-017 讲的"契约测试"与 S3-REST 的"版本化"要一起用的原因:一个负责"发现",一个负责"过渡" ✓
@Tag / @Operation / @Parameter / @ApiResponse;《Maven 进阶:依赖管理与多模块》:把工程结构也管起来。