跳到主内容
创建时间: 2026-09-16最后更新: 2026-09-16

前面的 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 代码改动很小,只要删除响应字段或改变字段类型,就可能破坏客户端。

路径应该表达调用方正在访问的资源。当前项目可以这样理解:

api-resources.txt
01
/api/v1/health
02
-> 当前应用的公开健康状态
03
04
/api/v1/health/check
05
-> 提交一次心跳检查命令
06
07
/api/v1/health/status/{service}
08
-> 某个服务最近保存的状态
09
10
/api/v1/health/history
11
-> 心跳检查历史集合

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 中先查询再插入仍然可能遇到并发竞争。

idempotency-flow.txt
1
客户端发送 Idempotency-Key
2
-> 服务器查询或插入唯一键
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 响应头告诉客户端新资源地址:

HealthCheckController.java
1
URI location = URI.create("/api/v1/health-checks/" + result.id());
2
return ResponseEntity.created(location).body(response);

不要在还没有独立记录 ID 和查询地址时机械返回 201。状态码应反映真实契约,而不是把「POST 一律返回 201」当成格式规则。

成功响应和失败响应可以使用不同结构。成功时返回 HealthResponse,失败时返回标准 ProblemDetail,客户端先依据 HTTP 状态判断大类,再读取稳定的 errorCode 处理具体业务原因。将所有失败包装进 200 OK 会让网关、监控和通用客户端无法正确理解结果。

4. 版本策略

接口版本用于管理不兼容变化。当前路径中的 v1 表示第一版外部契约,而不是应用构建版本,也不是数据库迁移版本。

常见版本位置包括:

方式示例特点
路径/api/v1/health直观,浏览器和日志中容易识别
请求头API-Version: 1URI 稳定,但调用方必须携带请求头
媒体类型参数Accept: application/json;version=1与内容协商结合,配置更复杂
查询参数/health?version=1容易调用,通常不作为主要长期方案

项目已经选择路径版本,因此 Controller 使用固定前缀即可:

HealthController.java
1
@RestController
2
@RequestMapping("/api/v1")
3
public class HealthController {
4
// 当前版本的接口
5
}

并非任何修改都要创建 v2。通常可以兼容演进的变化包括新增可选响应字段、新增接口和放宽输入范围;删除字段、修改字段类型、改变字段语义或把可选字段变成必填,则可能需要新版本或迁移期。

发布新版本时不应立即删除旧版本。先同时提供两版契约,记录旧版调用量,向客户端发送弃用时间,再在确认迁移完成后下线。内部 Service 可以被多个版本 Controller 复用,版本差异主要留在 HTTP DTO 和转换层。

5. MVC 版本支持

Spring Framework 7 可以从请求头、查询参数、路径段或媒体类型参数中解析版本,再使用映射注解的 version 属性选择 Controller 方法。当前项目已经使用固定 /api/v1,不需要为了使用新 API 再重复配置版本解析。

如果另一个项目决定使用请求头版本,可以这样配置:

src/main/java/cn/usehook/spring/config/ApiVersionConfiguration.java
01
package cn.usehook.spring.config;
02
03
import org.springframework.context.annotation.Configuration;
04
import org.springframework.web.servlet.config.annotation.ApiVersionConfigurer;
05
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
06
07
@Configuration(proxyBeanMethods = false)
08
public class ApiVersionConfiguration implements WebMvcConfigurer {
09
10
@Override
11
public void configureApiVersioning(ApiVersionConfigurer configurer) {
12
configurer
13
.useRequestHeader("API-Version")
14
.addSupportedVersions("1", "2");
15
}
16
}

Controller 再声明各方法支持的版本:

VersionedHealthController.java
01
package cn.usehook.spring.controller;
02
03
import org.springframework.web.bind.annotation.GetMapping;
04
import org.springframework.web.bind.annotation.RequestMapping;
05
import org.springframework.web.bind.annotation.RestController;
06
07
@RestController
08
@RequestMapping("/api/health")
09
public class VersionedHealthController {
10
11
@GetMapping(version = "1")
12
HealthResponseV1 healthV1() {
13
return new HealthResponseV1("UP");
14
}
15
16
@GetMapping(version = "2")
17
HealthResponseV2 healthV2() {
18
return new HealthResponseV2("UP", "spring");
19
}
20
21
record HealthResponseV1(String status) {}
22
23
record 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:

HealthControllerWebTests.java
1
assertThat(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、问题详情和版本边界。