目前的心跳项目只在自己的进程内生成 UP,还没有真正访问被监控服务。若要判断另一个 Spring Boot 实例是否可用,应用需要作为 HTTP 客户端,请求对方的 /api/v1/health,解析响应,再把检查结果交给现有 Repository 保存。
Spring MVC 应用可以使用同步的 RestClient 完成这类调用。本章会为它建立清晰边界:配置对象描述目标地址,客户端封装远程 HTTP 协议,Service 编排调用与持久化。这样 Controller 不需要知道目标 URL,Repository 也不会承担网络请求职责。
1. 应用也可以是 HTTP 客户端
浏览器调用当前项目时,当前项目是服务端;当前项目继续调用另一个服务时,它又成为客户端。一次请求可能经过两段 HTTP 通信:
1浏览器2-> POST /api/v1/health/check-configured3当前 Spring Boot 应用4-> GET http://inventory:8080/api/v1/health5被监控服务6-> 返回 { "code": 0, "data": { "status": "UP" } }7当前应用8-> 保存检查结果并响应浏览器
入站调用由 Controller 接收,出站调用由专门的客户端类发起。二者都使用 HTTP,但方向和职责不同。
RestClient 是同步客户端:调用线程会等待远程响应,适合当前基于 Spring MVC 的阻塞式应用。若项目采用 WebFlux 并需要端到端非阻塞处理,再考虑 WebClient;不要仅因为名字里带有「Web」就在 MVC 教程中混用两套编程模型。
2. 依赖与目标配置
在 pom.xml 中加入 Spring Boot 的 RestClient Starter:
1<dependency>2<groupId>org.springframework.boot</groupId>3<artifactId>spring-boot-starter-restclient</artifactId>4</dependency>
应用自身的信息继续由 app.health 管理,被监控目标则使用独立前缀:
01app:02health:03status: UP04service: spring05monitoring:06target-name: inventory07target-url: ${TARGET_HEALTH_URL:http://localhost:8081/api/v1/health}0809spring:10http:11clients:12connect-timeout: 2s13read-timeout: 3s14redirects: dont-follow
连接超时限制建立连接最多等待多久,读取超时限制连接建立后等待响应数据的时间。二者解决的不是同一个阶段,生产调用都需要明确设置。关闭自动重定向可以避免监控请求在不知情的情况下访问新地址。
为目标配置建立独立 record:
01package cn.usehook.spring.config;0203import jakarta.validation.constraints.NotBlank;04import jakarta.validation.constraints.NotNull;05import java.net.URI;06import org.springframework.boot.context.properties.ConfigurationProperties;07import org.springframework.validation.annotation.Validated;0809@Validated10@ConfigurationProperties(prefix = "app.monitoring")11public record MonitoringProperties(12@NotBlank String targetName,13@NotNull URI targetUrl14) {}
前文已在入口类使用 @ConfigurationPropertiesScan,这个 record 位于主包之下,会自动注册成 Bean。使用 URI 而不是 String,可以在启动绑定阶段发现格式错误,也能避免业务代码到处重复解析地址。
3. 封装远程协议
Spring Boot 会提供预配置的 RestClient.Builder。构造器接收 Builder,再为当前客户端创建实例:
01package cn.usehook.spring.monitoring;0203import java.net.URI;04import org.springframework.stereotype.Component;05import org.springframework.web.client.RestClient;06import org.springframework.web.client.RestClientException;0708@Component09public class RemoteHealthClient {1011private final RestClient restClient;1213public RemoteHealthClient(RestClient.Builder builder) {14this.restClient = builder.build();15}1617public ProbeResult probe(URI targetUrl) {18try {19RemoteHealthResponse response = restClient.get()20.uri(targetUrl)21.retrieve()22.body(RemoteHealthResponse.class);2324if (25response == null26|| response.code() != 027|| response.data() == null28|| !"UP".equals(response.data().status())29) {30return ProbeResult.down();31}3233return ProbeResult.up();34} catch (RestClientException exception) {35return ProbeResult.down();36}37}3839private record RemoteHealthResponse(40int code,41String message,42RemoteHealthData data43) {}4445private record RemoteHealthData(String status, String service) {}46}
retrieve() 发起请求并处理响应,body(RemoteHealthResponse.class) 使用消息转换器把 JSON 反序列化为 record。这里没有复用本项目的 HealthStatus,因为远程响应属于外部协议;单独的私有类型可以阻止对方字段变化直接污染本地业务模型。
当前监控规则把连接失败、超时、非成功状态码、无法解析的响应和非 UP 状态统一视为 DOWN。这是业务选择,不是 RestClient 的固定行为。若调用的是支付或订单 API,通常应该抛出明确异常,而不是把各种失败压缩成一个状态。
本地结果使用枚举,避免在业务代码中继续传播任意字符串:
01package cn.usehook.spring.monitoring;0203import cn.usehook.spring.health.HealthState;0405public record ProbeResult(HealthState state) {0607public static ProbeResult up() {08return new ProbeResult(HealthState.UP);09}1011public static ProbeResult down() {12return new ProbeResult(HealthState.DOWN);13}14}
HealthState 已在「Java 类与对象」章节介绍,可以直接使用 UP 和 DOWN 两个固定实例。
4. 在 Service 中编排
外部调用可能等待数秒,不应该被包进数据库事务。否则等待远程响应期间,连接和事务资源会一直被占用。将网络调用放在无事务的编排服务中,再把短暂的数据库写入交给另一个 Bean:
01package cn.usehook.spring.monitoring;0203import cn.usehook.spring.config.MonitoringProperties;04import cn.usehook.spring.health.HealthStatus;05import org.springframework.stereotype.Service;0607@Service08public class HealthCheckService {0910private final MonitoringProperties properties;11private final RemoteHealthClient client;12private final HealthResultRecorder recorder;1314public HealthCheckService(15MonitoringProperties properties,16RemoteHealthClient client,17HealthResultRecorder recorder18) {19this.properties = properties;20this.client = client;21this.recorder = recorder;22}2324public HealthStatus checkConfiguredTarget() {25ProbeResult result = client.probe(properties.targetUrl());26return recorder.record(27properties.targetName(),28result.state()29);30}31}
负责落库的 Bean 保持较短事务:
01package cn.usehook.spring.monitoring;0203import cn.usehook.spring.health.HealthStatus;04import cn.usehook.spring.health.HealthStatusRepository;05import cn.usehook.spring.health.HealthState;06import org.springframework.stereotype.Service;07import org.springframework.transaction.annotation.Transactional;0809@Service10public class HealthResultRecorder {1112private final HealthStatusRepository repository;1314public HealthResultRecorder(HealthStatusRepository repository) {15this.repository = repository;16}1718@Transactional19public HealthStatus record(String service, HealthState state) {20return repository.save(new HealthStatus(state.name(), service));21}22}
这里使用两个 Spring Bean 不只是为了缩短文件。@Transactional 由代理生效;将 record() 放进独立 Bean 后,HealthCheckService 对它的调用会经过事务代理。网络调用结束后才开启事务,数据库写入完成后立即提交。
不要改变原有 POST /api/v1/health/check 的请求体契约。为配置目标增加一个语义独立的入口,Controller 只负责转换结果:
01private final HealthService healthService;02private final HealthCheckService healthCheckService;0304public HealthController(05HealthService healthService,06HealthCheckService healthCheckService07) {08this.healthService = healthService;09this.healthCheckService = healthCheckService;10}1112@PostMapping("/health/check-configured")13public HealthResponse checkConfiguredTarget() {14HealthStatus status = healthCheckService.checkConfiguredTarget();15return HealthResponse.success(status);16}
这段增量代码还需要引入 HealthCheckService、HealthStatus 和 HealthResponse。新接口没有请求体,因为目标名称与地址都来自受控配置;旧接口仍保留客户端传入 service 的原有含义。两条路径可以复用响应 DTO 与 Repository 契约,但不会把两个不同操作伪装成同一个接口。
5. 失败边界与安全边界
远程调用不能只考虑成功响应。当前方案至少明确了以下边界:
| 情况 | 当前监控语义 | 还应记录什么 |
|---|---|---|
| 建立连接失败 | DOWN | 目标名称、失败类别、耗时 |
| 请求或读取超时 | DOWN | 超时阶段、配置阈值 |
| HTTP 4xx 或 5xx | DOWN | 状态码,不记录敏感响应体 |
| JSON 无法解析 | DOWN | 协议错误与目标名称 |
返回业务状态 DOWN | DOWN | 远程业务状态 |
示例为了聚焦调用流程,只返回状态。生产代码还应使用参数化日志、指标或追踪记录失败原因,但不要把认证令牌和完整响应体写入日志。
目标 URL 应来自受信任的配置或经过严格校验的数据。若普通用户可以任意提交地址,服务器可能被利用来访问内网管理端点或云元数据服务,形成 SSRF。动态目标至少需要限制协议、主机和端口,并在网络层限制应用可访问的地址范围。
重试也不是默认答案。只有操作具备幂等性、故障可能短暂且总等待时间受到限制时才适合重试;重试次数、退避时间和最外层请求超时必须共同设计。一次用户请求内无限重试只会把局部故障放大成线程耗尽。
6. 不启动远程服务也能测试
客户端测试可以用 MockRestServiceServer 接管 Builder,不需要真的启动第二个 Spring Boot 应用:
01package cn.usehook.spring.monitoring;0203import org.junit.jupiter.api.Test;04import org.springframework.http.MediaType;05import org.springframework.test.web.client.MockRestServiceServer;06import org.springframework.web.client.RestClient;0708import static org.assertj.core.api.Assertions.assertThat;09import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;10import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;1112class RemoteHealthClientTests {1314@Test15void readsUpStateFromRemoteService() {16RestClient.Builder builder = RestClient.builder();17MockRestServiceServer server = MockRestServiceServer18.bindTo(builder)19.build();20RemoteHealthClient client = new RemoteHealthClient(builder);2122server.expect(requestTo("http://inventory/api/v1/health"))23.andRespond(withSuccess(24"""25{26"code": 0,27"message": "success",28"data": {"status":"UP","service":"inventory"}29}30""",31MediaType.APPLICATION_JSON32));3334ProbeResult result = client.probe(35java.net.URI.create("http://inventory/api/v1/health")36);3738assertThat(result.state()).isEqualTo(HealthState.UP);39server.verify();40}41}
这个测试验证请求地址、JSON 反序列化和状态转换,边界正好落在客户端类。还应分别补充 500、超时或连接异常、畸形 JSON 和业务状态 DOWN 的用例。Service 测试则模拟 RemoteHealthClient 与 HealthResultRecorder,验证目标配置是否被正确传递,不要在同一个测试里重复验证两层细节。
至此,心跳项目第一次真正跨出了本进程。下一篇会在这条调用链之上增加定时任务与异步执行,让系统能够主动、周期性地检查目标,同时控制线程池、并发和任务重叠。