前面的 Controller、DTO 和异常处理已经解决了「怎样接收请求」以及「怎样返回稳定 JSON」。接口能够运行并不代表契约已经设计清楚:路径代表什么资源、HTTP 方法会不会修改状态、失败使用哪个状态码、以后怎样升级,都需要在客户端接入前确定。
本章继续整理心跳接口,不重写 Service 和 Repository。重点是把已经出现的路径放回 HTTP 语义中检查,并说明当前 /api/v1 为什么存在。
1. 资源与契约
API 契约是客户端与服务器共同依赖的约定,至少包括:
| 契约部分 | 心跳案例 | 变化影响 |
|---|---|---|
| 方法与路径 | POST /api/v1/health/check | 决定请求要执行的操作 |
| 请求数据 | HealthCheckRequest | 决定客户端怎样提交参数 |
| 成功响应 | HealthResponse | 决定客户端怎样读取结果 |
| 失败响应 | ProblemDetail | 决定客户端怎样识别错误 |
| 状态码 | 200、400、404 | 表达 HTTP 层处理结果 |
| 认证要求 | 公开或需要身份 | 决定调用方怎样提供凭据 |
Java 方法名、Service 实现和数据库表属于应用内部。只要对外契约保持兼容,内部可以从内存 Repository 改成 PostgreSQL,也可以从 JdbcClient 改成 JPA。反过来,即使 Java 代码改动很小,只要删除响应字段或改变字段类型,就可能破坏客户端。
路径应该表达调用方正在访问的资源。当前项目可以这样理解:
01/api/v1/health02-> 当前应用的公开健康状态0304/api/v1/health/check05-> 提交一次心跳检查命令0607/api/v1/health/status/{service}08-> 某个服务最近保存的状态0910/api/v1/health/history11-> 心跳检查历史集合
health/check 带有动作名称,属于命令式接口。它不是语法错误:当前业务确实是要求服务器立即执行一次检查,而不是直接上传一个已经完成的检查结果。若以后把「检查记录」建模为正式资源,可以演进为 POST /api/v1/health-checks,但不应该只为了让路径看起来像名词就改变真实业务含义。
2. 方法与幂等性
HTTP 方法不只是 Controller 上不同的注解,它们向客户端、代理和缓存表达操作语义:
| 方法 | 常见语义 | 是否幂等 | 当前案例 |
|---|---|---|---|
| GET | 读取资源 | 是 | 读取应用状态、最近状态或历史 |
| POST | 创建资源或提交处理命令 | 不保证 | 执行并保存一次检查 |
| PUT | 用完整内容替换指定资源 | 是 | 当前没有对应需求 |
| PATCH | 部分修改指定资源 | 不保证 | 可以用于启用或停用监控配置 |
| DELETE | 删除指定资源 | 是 | 当前没有对应需求 |
「幂等」表示相同请求执行一次或多次,对服务器最终状态产生的预期效果相同。它不表示每次响应完全一样,也不表示服务器没有记录访问日志。
心跳检查会追加历史记录,因此重复提交可能产生多条记录,适合使用 POST。GET 请求则不应该因为读取而新增历史或改变业务状态。前面为了演示参数绑定创建的 GET /api/v1/health/{service} 如果继续保留,应只执行无副作用探测;需要保存结果时统一使用 POST 接口。
客户端超时后重试 POST,服务器可能已经完成第一次写入。确实要求「同一个业务请求只保存一次」时,应让客户端提供幂等键,并在数据库中建立唯一约束;仅在 Controller 中先查询再插入仍然可能遇到并发竞争。
1客户端发送 Idempotency-Key2-> 服务器查询或插入唯一键3-> 已处理:返回原结果4-> 未处理:执行业务并保存结果
当前教学案例不立即实现幂等键,因为普通手动心跳允许每次调用形成独立记录。这里需要掌握的是:HTTP 方法描述默认语义,真正的一致性仍要由业务规则和数据库约束保证。
3. 状态与响应
HTTP 状态码描述请求在协议层的结果,响应 DTO 描述业务数据。两者不能互相替代:
| 状态码 | 含义 | 心跳案例 |
|---|---|---|
200 OK | 成功读取或处理 | 读取当前状态、同步执行检查 |
201 Created | 创建了可独立访问的新资源 | 创建一条拥有独立 ID 的检查记录 |
204 No Content | 成功且没有响应体 | 删除或无需返回内容的更新 |
400 Bad Request | 请求格式或参数无效 | DTO 校验失败、版本无效 |
401 Unauthorized | 缺少有效认证 | 没有 Bearer Token 或 Basic 凭据 |
403 Forbidden | 身份无权执行 | 已认证但没有所需权限 |
404 Not Found | 目标资源不存在 | 找不到指定服务的最近状态 |
409 Conflict | 当前资源状态与操作冲突 | 乐观锁版本冲突 |
500 Internal Server Error | 未预期的服务端失败 | 未被业务异常覆盖的错误 |
如果 POST 只触发同步计算并直接返回结果,使用 200 OK 是合理的。如果它创建了带独立 URI 的检查记录,则可以返回 201 Created,并通过 Location 响应头告诉客户端新资源地址:
1URI location = URI.create("/api/v1/health-checks/" + result.id());2return ResponseEntity.created(location).body(response);
不要在还没有独立记录 ID 和查询地址时机械返回 201。状态码应反映真实契约,而不是把「POST 一律返回 201」当成格式规则。
成功响应和失败响应可以使用不同结构。成功时返回 HealthResponse,失败时返回标准 ProblemDetail,客户端先依据 HTTP 状态判断大类,再读取稳定的 errorCode 处理具体业务原因。将所有失败包装进 200 OK 会让网关、监控和通用客户端无法正确理解结果。
4. 版本策略
接口版本用于管理不兼容变化。当前路径中的 v1 表示第一版外部契约,而不是应用构建版本,也不是数据库迁移版本。
常见版本位置包括:
| 方式 | 示例 | 特点 |
|---|---|---|
| 路径 | /api/v1/health | 直观,浏览器和日志中容易识别 |
| 请求头 | API-Version: 1 | URI 稳定,但调用方必须携带请求头 |
| 媒体类型参数 | Accept: application/json;version=1 | 与内容协商结合,配置更复杂 |
| 查询参数 | /health?version=1 | 容易调用,通常不作为主要长期方案 |
项目已经选择路径版本,因此 Controller 使用固定前缀即可:
1@RestController2@RequestMapping("/api/v1")3public class HealthController {4// 当前版本的接口5}
并非任何修改都要创建 v2。通常可以兼容演进的变化包括新增可选响应字段、新增接口和放宽输入范围;删除字段、修改字段类型、改变字段语义或把可选字段变成必填,则可能需要新版本或迁移期。
发布新版本时不应立即删除旧版本。先同时提供两版契约,记录旧版调用量,向客户端发送弃用时间,再在确认迁移完成后下线。内部 Service 可以被多个版本 Controller 复用,版本差异主要留在 HTTP DTO 和转换层。
5. MVC 版本支持
Spring Framework 7 可以从请求头、查询参数、路径段或媒体类型参数中解析版本,再使用映射注解的 version 属性选择 Controller 方法。当前项目已经使用固定 /api/v1,不需要为了使用新 API 再重复配置版本解析。
如果另一个项目决定使用请求头版本,可以这样配置:
01package cn.usehook.spring.config;0203import org.springframework.context.annotation.Configuration;04import org.springframework.web.servlet.config.annotation.ApiVersionConfigurer;05import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;0607@Configuration(proxyBeanMethods = false)08public class ApiVersionConfiguration implements WebMvcConfigurer {0910@Override11public void configureApiVersioning(ApiVersionConfigurer configurer) {12configurer13.useRequestHeader("API-Version")14.addSupportedVersions("1", "2");15}16}
Controller 再声明各方法支持的版本:
01package cn.usehook.spring.controller;0203import org.springframework.web.bind.annotation.GetMapping;04import org.springframework.web.bind.annotation.RequestMapping;05import org.springframework.web.bind.annotation.RestController;0607@RestController08@RequestMapping("/api/health")09public class VersionedHealthController {1011@GetMapping(version = "1")12HealthResponseV1 healthV1() {13return new HealthResponseV1("UP");14}1516@GetMapping(version = "2")17HealthResponseV2 healthV2() {18return new HealthResponseV2("UP", "spring");19}2021record HealthResponseV1(String status) {}2223record HealthResponseV2(String status, String service) {}24}
这段独立示例把两个响应 record 放在 Controller 内,只为集中展示版本映射;大型项目仍应像 DTO 章节一样拆成单独文件。请求 /api/health 时携带 API-Version: 2,MVC 才选择第二个方法。版本缺失、无法解析或不在支持列表中时会进入 MVC 错误处理。路径版本与请求头版本是两种替代策略,当前心跳项目继续使用前者,不把两种策略叠加在同一组接口上。
6. 契约验证
接口契约需要测试保护。MVC 测试至少应覆盖:
- 正确的方法和路径能够匹配;
- 不支持的方法返回 405;
Content-Type与Accept不匹配时返回准确状态;- DTO 字段和问题详情保持稳定;
- 版本解析策略与 Controller 映射一致。
对于当前路径版本,测试直接请求完整 URI:
1assertThat(mvc.get().uri("/api/v1/health"))2.hasStatusOk()3.bodyJson()4.extractingPath("$.data.service")5.asString()6.isEqualTo("spring");
不要让测试只断言 Controller 方法被调用。客户端依赖的是方法、路径、状态、响应头和 JSON,而不是 Java 内部方法名。契约改变时,测试失败应该促使开发者判断这是兼容调整、需要新版本,还是意外回归。
到这里,Web 层已经从请求管线走到稳定契约。下一篇先为 Service 建立快速单元测试,再使用 MVC 测试验证路径、DTO、问题详情和版本边界。