Skip to content

接口文档与前后端联调 ​

S3-027建议学入门约 45 分钟JDK 21企业开发基础 · Spring Boot

1. 本节定位 ​

S3-REST 定了接口的设计规范,《Spring MVC 请求映射与参数绑定》 把它写成了代码。本节解决最后一个协作问题:

前端怎么知道这些接口长什么样?

text
没有文档:  前端问后端 → 后端截图/口述 → 前端猜着写 → 联调时发现全错 → 又改一遍
有文档:    前端打开 Swagger UI → 自己试 → 直接写对

本节要建立的核心认知:

好的接口文档不是"写"出来的,是"生成"出来的——因为手写的文档一定会过期。

2. 学习目标 ​

完成本节后,你应该能够:

  • 说出"手写文档"的三个致命问题,以及"代码即文档"如何解决它们;
  • 说出 OpenAPI 是什么、它与 Swagger UI 的关系;
  • 用 springdoc + 注解给接口补上"人话描述"(@Tag / @Operation / @Parameter / @ApiResponse);
  • 说出文档里除了字段列表还该写什么;
  • 说出生产环境该不该开放 Swagger UI,以及替代方案;
  • 说出"文档"与"契约"的区别,以及契约测试解决什么。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

"手写接口文档"的三个致命问题:

#问题后果
1一定会过期代码改了文档没改——过期的文档比没有文档更糟 ✗(它会主动误导人)
2没人愿意写开发觉得"代码都写了,还要写一遍文档" ✗
3不能试Word/Markdown 里的接口,前端没法直接调——只能读,不能验证

真实场景:

text
后端:"我把接口文档更新到 Confluence 了"
前端:打开一看 —— 最后一次编辑是 3 个月前 ✗
前端:自己读代码猜参数字段吧
联调:字段名一个都对不上 → 又花半天

"代码即文档"解决了全部三个问题:

问题代码即文档的解法
会过期文档从代码生成——代码改了,文档自动跟着变 ✓
没人写只写"注解"(一两行),不写另一份文档 ✓
不能试Swagger UI 提供"试试看"按钮——能直接发请求 ✓

5. 核心概念 ​

5.1 OpenAPI 与 Swagger UI 的分工 ​

text
你的代码(@RestController + 注解 + 类型信息)
      │  springdoc 扫描并生成
      ▼
OpenAPI 文档(一份 JSON)        ← 标准格式,任何工具都能读
      │  Swagger UI 渲染
      ▼
可视化页面(能看、能试)
名词是什么分工
OpenAPI一套描述 API 的规范(原 Swagger 规范)数据格式(JSON/YAML)
Swagger UI一个网页工具把 OpenAPI 文档渲染成可交互页面
springdocSpring Boot 的集成库从代码生成 OpenAPI 文档 ✓

一个常见混淆:Swagger 现在既指"规范"也指"工具家族"——但规范已改名为 OpenAPI ✓ 日常沟通里说"看下 Swagger"一般指"看那个可视化页面" ✓

5.2 引入与配置 ​

xml
<dependency>
  <groupId>org.springdoc</groupId>
  <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
  <version>2.7.0</version>
</dependency>

引完依赖就自动生效 ✓(又一次自动配置的力量,《Spring Boot 自动配置与项目结构》)

两个默认端点:

端点内容
/v3/api-docsOpenAPI 文档(JSON) —— 给工具读
/swagger-ui/index.htmlSwagger UI 页面 —— 给人看

本项目实测:

text
GET /v3/api-docs                → 200,返回 JSON
GET /swagger-ui/index.html      → 200 ✓

文档内容(实测输出,节选):

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...

注意三件事:

观察说明
paths 是自动生成的从 @GetMapping / @PostMapping 推导
tags 里的描述来自 @Tag人话描述是"加注解"补的 ✓
parameters 也是自动的从 @RequestParam / @PathVariable 的类型推导

⚠️ info.title 是默认的 OpenAPI definition ✗ —— 生产项目要改:

yaml
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: 后端团队

5.3 常用注解:让文档"说人话" ​

自动生成的文档有个问题:它只有"结构",没有"意图" ✗

text
自动生成:GET /api/orders/{orderNo}  →  参数 orderNo: string

前端看到这个会想问:

前端的问题自动文档能回答吗
orderNo 长什么格式?❌
查不到会返回什么?❌(只有 200 的响应结构)
这个接口做什么的?❌
size 能传多大?❌

补上四个注解,文档就"说人话"了:

java
@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方法上可能返回的状态码与含义

实测效果(文档里的内容):

json
"tags":[{"name":"订单接口","description":"订单的创建、查询与删除;错误响应统一为 code + message + traceId + details"}],
"/api/orders":{"get":{"tags":["订单接口"],
                      "summary":"分页查询订单",
                      "description":"size 上限 100;status 为可选筛选条件", ...}}

注解写得对不对,直接看渲染出来的页面 ✓

⚠️ 三个实践要点:

要点说明
注解不要重复描述类型信息字段名、类型、必填已经自动生成了——别在 description 里再抄一遍 ✗
@Operation 的 summary 一句话说清"做什么""创建订单" ✓ 而不是"这个接口用来创建订单..." ✗
@ApiResponse 要覆盖错误码 ★前端最需要知道的是"什么情况下会失败" ✓

5.4 文档里该写什么 ​

一张对比表("最少" vs "有用"):

内容自动生成需要手写前端最关心
路径与方法✅—需要
字段名与类型✅—需要
必填 / 可选✅(required = false 等)—需要
校验规则(长度、范围)⚠️ 部分(@Size 等会带出来)补充很需要 ✓
示例值❌@Parameter(example=...)很需要 ✓
错误码与含义❌@ApiResponse很需要 ✓
业务语义(如"status 的取值含义")❌description很需要 ✓
调用顺序 / 依赖关系❌description 或单独文档需要

一条判断标准:

"前端看完这份文档,能不能不问任何人就把接口调对?"

不能 → 说明该补的注解还没补。

本项目文档里的一句话示例(值得学的写法):

java
@Tag(name = "订单接口",
     description = "订单的创建、查询与删除;错误响应统一为 code + message + traceId + details")

这一句同时说明了三件事:

信息前端能据此做什么
接口范围知道这一组是订单相关 ✓
错误响应的统一结构写一套错误处理逻辑就够了 ✓
有 traceId报错时能给客服一个编号 ✓

5.5 前后端联调的流程变化 ​

没有 Swagger UI 时:

text
① 后端开发完接口
② 写文档(或口头讲)
③ 前端按文档写(文档可能已过期 ✗)
④ 联调:字段名对不上、状态码理解不一致
⑤ 反复沟通 → 效率低

有了 Swagger UI 后:

text
① 后端开发完接口(带注解)
② 前端打开 /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)✗

5.6 生产环境该不该开 Swagger UI ​

先说结论:默认关闭 ✓

为什么:

风险说明
接口结构完全暴露攻击者拿到"所有接口 + 参数 + 错误码"——省了他做侦察的功夫 ✗
可能被自动化扫描/v3/api-docs 是众所周知的路径,会被扫描器发现 ✓
"试试看"按钮的风险如果生产环境的写接口没有权限校验,任何人都能通过 UI 发请求 ✗

三种处理方式:

yaml
# 方式一:按 profile 关闭(最简单)
springdoc:
  api-docs:
    enabled: false                   # 在 application-prod.yml 里设为 false
  swagger-ui:
    enabled: false
java
// 方式二:生产环境仍需文档时,加鉴权(只有登录用户能访问)
@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();
    }
}

方式三:暴露"精简版"文档(只给前端需要的部分,隐藏内部接口):

java
// 给接口打上"内部"标记,用 GroupedOpenApi 只暴露公开的那部分
@Bean
public GroupedOpenApi publicApi() {
    return GroupedOpenApi.builder()
            .group("public")
            .pathsToMatch("/api/**")
            .pathsToExclude("/api/internal/**")       // 内部接口不暴露
            .build();
}

推荐的组合:

环境Swagger UI说明
本地开发✅ 开自己调试方便
测试环境✅ 开前端自助联调 ✓
预发环境⚠️ 开但加鉴权需要给测试同学用
生产❌ 关需要文档时走"内部文档站"(离线导出的 JSON 渲染)

一个实用的补充:生产关了 UI,仍可以把 OpenAPI JSON 导出来放到内部文档站:

powershell
# 在测试环境导出,作为"发布文档"归档
curl.exe -s http://test-api.example.com/v3/api-docs -o openapi-v1.2.0.json

这样既不影响安全,也不丢失文档 ✓

5.7 文档 ≠ 契约 ​

先说清楚两者的差别:

文档契约
形式一份描述(OpenAPI JSON、网页)一份可执行的约定 + 验证
谁读人测试代码 / 工具
改了会怎样文档更新,没人红 ⚠️测试变红 ✓
是否阻止上线❌✅ 可以

"文档"的核心弱点:它只能"告知",不能"强制" ✗

text
后端把响应字段从 userName 改成 username
  → 文档自动更新了(代码即文档的好处 ✓)
  → 但前端的代码没跟着改 ✗
  → 上线后前端拿不到数据 ✗

契约测试解决的就是这个:

text
把"接口的约定"写成可执行的测试
  → 后端改了不兼容的地方 → 测试红 → 拦住合并 ✓

三种层次的保障(从弱到强):

层次手段强度
1写文档(或代码即文档)⚠️ 靠人自觉遵守
2接口测试(TB 接口测试)✅ 保证"接口按预期工作"
3契约测试(T2-007)✅✅ 保证"双方的约定"一致

一句话区分:

接口测试验证"我的接口对不对";契约测试验证"我们俩对接口的理解是否一致"。

一个具体例子:

java
// 接口测试(后端自己写):验证响应结构和状态码
mockMvc.perform(get("/api/orders/ORD-1"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.orderNo").value("ORD-1"));      // ✓ 后端自己定义的结构

// 契约测试(前后端共同维护):验证"前端期望的字段都在"
// 前端声明它需要的契约 → 后端跑这个契约 → 缺字段就红

实践建议:中小团队先把"接口测试"做扎实(T2-001),跨团队协作(前端独立团队、外部对接)时再上契约测试 ✓

6. 递进示例 ​

6.1 最小示例:引依赖就有的文档 ​

xml
<dependency>
  <groupId>org.springdoc</groupId>
  <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
  <version>2.7.0</version>
</dependency>

访问 /swagger-ui/index.html —— 已经能看到全部接口了 ✓(虽然描述都是自动生成的)

6.2 业务示例:加注解让文档说人话 ​

见第 7 节。

6.3 反例:把注解写成类型信息的复述 ​

java
@Operation(summary = "按订单号查询订单,参数 orderNo 是订单号,类型是字符串",
        description = "参数 orderNo:string,必填,路径参数")
public OrderResponse find(@PathVariable String orderNo) { ... }

问题:字段名、类型、是否必填——这些文档已经自动生成了 ✗

重复的三处坏处:

坏处说明
冗余UI 上同一信息出现两遍
会不一致类型改成 Long 了,描述的"string"忘了改 ✗
占位真正有价值的信息(格式、业务语义)反而没地方写 ✗

正确写法:

java
@Operation(summary = "按订单号查询", description = "不存在时返回 404 与 RESOURCE_NOT_FOUND")
public OrderResponse find(
        @Parameter(description = "订单号,形如 ORD-1", example = "ORD-1") @PathVariable String orderNo) { ... }

对比:描述写"格式"和"失败行为",不写"类型" ✓

7. 完整可运行代码 ​

文件清单(labs/spring-boot-web/):

text
pom.xml                                               springdoc 依赖
src/main/java/cn/kb/learn/web/controller/OrderController.java    注解

POM:

xml
<dependency>
  <groupId>org.springdoc</groupId>
  <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
  <version>2.7.0</version>
</dependency>

Controller(注解部分):

java
@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(...) { ... }
}

运行命令:

powershell
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

预期输出(真实结果):

text
# 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,你会看到:

text
订单接口
  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"就能直接发请求——这是它相对"静态文档"的核心优势 ✓

8. Debug 观察点 ​

  1. 在 /v3/api-docs 的处理方法处断点(或直接看 JSON):观察文档是在请求时动态生成的(所以永远与代码一致);
  2. 把 @Operation 去掉,刷新页面——观察接口还在,但描述没了 ✓(说明"结构自动、意图手写");
  3. 把 @GetMapping("/{orderNo}") 改成 "/find/{orderNo}"——观察文档里的路径跟着变 ✓(这就是"不会过期"的机制);
  4. 故意写一个 @ApiResponse(responseCode = "999")——观察页面上的奇怪状态码(说明注解不做语义校验,写错也能渲染)✗;
  5. 在 application.yml 里加 springdoc.swagger-ui.enabled: false——观察 UI 变成 404,但 /v3/api-docs 仍然可用(可以只关 UI 保留 JSON)。

9. 常见错误 ​

错误 1:以为"引了依赖就完事" ​

症状:文档里全是 OpenAPI definition、/api/orders 这种自动生成的名字,人读不懂 ✗

修复:补 @Tag / @Operation,并把 info.title 配好 ✓

错误 2:注解里重复类型信息 ​

见 6.3 节。

错误 3:生产环境开着 Swagger UI ​

见 5.6 节。这是安全问题,不是"不方便" ✗

错误 4:只写成功响应,不写错误码 ​

java
@Operation(summary = "创建订单")          // ✗ 没写会返回哪些错误

前端最需要知道的恰恰是"什么情况下会失败" ✓(因为成功路径从类型定义就能看出来)

修复:

java
@ApiResponse(responseCode = "201", description = "创建成功")
@ApiResponse(responseCode = "422", description = "参数校验失败(VALIDATION_FAILED,含逐字段 details)")

错误 5:把文档当成"契约" ​

见 5.7 节。文档更新了,前端的代码不会跟着变 ✗

修复:跨团队协作时上契约测试(T2-007)✓

错误 6:版本号不更新 ​

yaml
springdoc:
  info:
    version: v0                  # ✗ 永远是 v0

症状:前端看不出"线上跑的是哪个版本",排查时双方对不齐 ✗

修复:把版本号做成"构建时注入":

yaml
springdoc:
  info:
    version: @project.version@        # Maven 资源过滤会替换

或用构建信息端点(/actuator/info)统一暴露 ✓

错误 7:接口分了组但没配分组 ​

症状:一个大项目里,所有模块的接口混在一页——前端要滚很久才找到自己的 ✗

修复:用 @Tag 分组(或用 GroupedOpenApi 按路径分组)✓

java
@Bean
public GroupedOpenApi orderApi() {
    return GroupedOpenApi.builder().group("订单").pathsToMatch("/api/orders/**").build();
}

@Bean
public GroupedOpenApi userApi() {
    return GroupedOpenApi.builder().group("用户").pathsToMatch("/api/users/**").build();
}

10. 动手实验 ​

目标:把"代码即文档"和"联调流程"亲手体验一遍。

步骤:

  1. 运行第 7 节的命令,访问 Swagger UI 页面,用"Try it out"创建一个订单(观察 201 与响应体);
  2. 验证"不会过期":把某个接口的路径改掉(如 /{orderNo} → /{no}),重启,刷新页面——观察文档跟着变了 ✓;
  3. 补全错误文档:给 find 方法加 @ApiResponse(responseCode = "404", description = "..."),观察页面上的响应说明;
  4. 验证"注解不做校验":把 @ApiResponse 的状态码写成 "abc",看它是否仍能渲染(提示:能);
  5. 关闭生产环境的 UI:在 application-prod.yml 里加:
yaml
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 加一个自定义校验注解(《参数校验与全局异常处理》 的实验任务),并观察它是否出现在文档里——体会"自动生成能覆盖到哪、注解要补到哪"。

11. 练习题 ​

基础题

  1. 判断:OpenAPI 是 Swagger UI 的一个功能。(对 / 错)
  2. 填空:OpenAPI 文档的默认端点是 ______;Swagger UI 的默认入口是 ______。
  3. 简答:为什么说"过期的文档比没有文档更糟"?

代码阅读题

java
@Operation(summary = "user接口", description = "userId是用户ID,类型Long,必填")
@GetMapping("/api/users/{userId}")
public UserResponse getUser(@PathVariable Long userId) { ... }
  1. 这段注解有什么问题?
  2. 如果这个接口在"用户不存在"时返回 404 与 USER_NOT_FOUND,注解该怎么改?

编程题

  1. 为一个"文章"接口补齐文档注解:
    • 接口:GET /api/articles(分页 + 关键词 + 标签筛选)、POST /api/articles、GET /api/articles/{id}、DELETE /api/articles/{id};
    • 要求:
      • 类上加 @Tag,说明"错误响应结构";
      • 每个方法加 @Operation(summary 一句话,description 说清业务规则与失败行为);
      • 关键参数加 @Parameter 与 example;
      • 覆盖全部错误码(404 / 422 / 409);
      • 项目信息(info.title / version)配置好;
    • 启动后贴出 /v3/api-docs 里 tags 与某个 path 的片段,证明注解生效;
    • 回答:哪些内容是自动生成的、哪些是你补的?

综合任务

  1. 设计一套"接口协作规范"并落地:
    • 背景:前后端两个团队,之前因为"文档过期 + 无契约"出过两次线上问题(字段改名、状态码变更);
    • 要求:
      • 写一份至少 8 条的接口协作规范(覆盖:文档来源、注解要求、错误码、版本管理、变更流程、破坏性变更的判定);
      • 区分"兼容变更"与"破坏性变更":列出至少 5 个例子并分类(如"新增一个可选响应字段" vs "删除一个响应字段");
      • 设计"接口变更的流程":从提出到上线的每一步,谁做什么、什么环节能拦住问题;
      • 说明"契约测试在这个流程里扮演什么角色",以及"没有契约测试时用什么替代";
    • 额外要求:说明"为什么'接口加字段'看起来无害,仍然可能是破坏性变更"(提示:客户端的严格反序列化、switch 穷尽检查)。
分级提示

提示 1(方向):第 1 题的方向反了——OpenAPI 是规范,Swagger UI 是渲染工具。

提示 2(关键点):第 4 题的两处问题:summary 没意义 + 重复了自动生成的类型信息。

提示 3(接近答案):第 7 题的"加字段也可能是破坏性变更"——想想 Kotlin/Swift 里的 when/switch 穷尽性检查,以及 Jackson 的 FAIL_ON_UNKNOWN_PROPERTIES。

12. 参考答案 ​

基础题
  1. 错,关系反了。
是什么谁依赖谁
OpenAPI一套"如何描述 API"的规范(原 Swagger 规范)独立于任何工具 ✓
Swagger UI一个渲染 OpenAPI 文档的网页工具消费 OpenAPI 文档

为什么这个区分重要:

因为"规范"是通用的——同一份 OpenAPI JSON,可以被 Swagger UI 渲染成网页、被 Postman 导入成集合、被代码生成器生成客户端 SDK、被契约测试工具验证 ✓

如果把它理解成"Swagger UI 的功能",就会以为它只能给人看 ✗ ——而它的最大价值恰恰在"被工具消费" ✓

  1. /v3/api-docs;/swagger-ui/index.html(/swagger-ui.html 会重定向到它)。

补充:这两个路径都可以在配置里改 ✓

yaml
springdoc:
  api-docs:
    path: /v3/api-docs
  swagger-ui:
    path: /docs
  1. 因为"没有文档"时,人会去看代码、去问——最终能找到正确答案;而"过期文档"会让人以为自己知道答案 ✗

一个具体的伤害链:

text
前端看到文档:响应字段是 userName
  → 按这个写代码
  → 联调时发现实际返回的是 username ✗
  → 前端的第一反应不是"文档错了",而是"后端改漏了/后端有 bug"
  → 双方开始互相怀疑、找证据(翻 Git 历史、找邮件)
  → 沟通成本远超"从来没写过文档"

更隐蔽的一种:文档里的示例值是旧的——前端拿去跑,跑出来结果是错的,但程序不报错 ✗(比如示例里的 status 取值是旧的枚举)

结论:

文档的价值 = 准确性 × 可获取性。

准确性为 0,整个价值就是 0(甚至为负) ✓

这就是"代码即文档"最根本的优势:它的准确性由构建过程保证,不依赖任何人记得更新。 ✓

代码阅读题(第 4、5 题)

第 4 题:两处问题。

#问题说明
1summary = "user接口" 没有信息量UI 上显示的标题应该说清"做什么"——“查询用户详情” ✓
2description 重复了自动生成的类型信息"userId 是用户 ID,类型 Long,必填"——这三项文档都会自动推导 ✗(见 6.3 节)

还有一个隐藏问题:没写 @Tag——它会落到默认分组里,在大项目里很难找 ⚠️

修复后的写法:

java
@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 题:补上错误响应的说明。

java
@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)——这样文档说的和实际返回的能对上 ✓

编程题(第 6 题)
java
@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) { ... }
}

项目信息配置:

yaml
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 ✓

文档里的片段(预期):

json
"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"}
    ]}}

一句话回答:

自动生成的是"接口长什么样"(结构),手写的是"接口怎么用"(语义与失败行为)。 ✓

综合任务(第 7 题)

《接口协作规范(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 条特别值得警惕:它不改变任何字段,但改变了语义 ✓

接口变更流程:

text
① 提出变更(谁:需求方/前端/后端)
   产出:变更说明(含"兼容性判定",对照上表)
        ↓
② 影响评估(谁:后端 + 受影响的前端/调用方)
   产出:影响面清单 + 过渡方案(双写?新增版本?提前通知多久?)
        ↓
③ 实现 + 补测试(谁:后端)
   产出:代码 + 接口测试(覆盖新行为)+ 【契约测试】
        ↓  ← 这是"能拦住问题"的环节:不兼容的改动会让测试红
④ 更新文档(自动) + 变更记录(谁:后端)
   产出: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 的"版本化"要一起用的原因:一个负责"发现",一个负责"过渡" ✓

13. 掌握标准 ​

  • [ ] 能说出"手写文档"的三个问题与"代码即文档"的解法;
  • [ ] 能区分 OpenAPI(规范)与 Swagger UI(渲染工具);
  • [ ] 知道 springdoc 的两个默认端点;
  • [ ] 会用 @Tag / @Operation / @Parameter / @ApiResponse;
  • [ ] 知道注解该补"业务语义与失败行为",不该复述类型信息;
  • [ ] 知道生产环境要关闭 Swagger UI,并知道替代方案(归档 JSON / 加鉴权 / 分组暴露);
  • [ ] 能说出"文档"与"契约"的区别,知道文档不能强制约束;
  • [ ] 能判断"加字段"这类变更是否兼容,取决于调用方的解析策略。

14. 扩展资源 ​

15. 下一步学习 ​

《Maven 进阶:依赖管理与多模块》:把工程结构也管起来。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引