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

目前的心跳项目只在自己的进程内生成 UP,还没有真正访问被监控服务。若要判断另一个 Spring Boot 实例是否可用,应用需要作为 HTTP 客户端,请求对方的 /api/v1/health,解析响应,再把检查结果交给现有 Repository 保存。

Spring MVC 应用可以使用同步的 RestClient 完成这类调用。本章会为它建立清晰边界:配置对象描述目标地址,客户端封装远程 HTTP 协议,Service 编排调用与持久化。这样 Controller 不需要知道目标 URL,Repository 也不会承担网络请求职责。

1. 应用也可以是 HTTP 客户端

浏览器调用当前项目时,当前项目是服务端;当前项目继续调用另一个服务时,它又成为客户端。一次请求可能经过两段 HTTP 通信:

outbound-request-flow.txt
1
浏览器
2
-> POST /api/v1/health/check-configured
3
当前 Spring Boot 应用
4
-> GET http://inventory:8080/api/v1/health
5
被监控服务
6
-> 返回 { "code": 0, "data": { "status": "UP" } }
7
当前应用
8
-> 保存检查结果并响应浏览器

入站调用由 Controller 接收,出站调用由专门的客户端类发起。二者都使用 HTTP,但方向和职责不同。

RestClient 是同步客户端:调用线程会等待远程响应,适合当前基于 Spring MVC 的阻塞式应用。若项目采用 WebFlux 并需要端到端非阻塞处理,再考虑 WebClient;不要仅因为名字里带有「Web」就在 MVC 教程中混用两套编程模型。

2. 依赖与目标配置

在 pom.xml 中加入 Spring Boot 的 RestClient Starter:

pom.xml
1
<dependency>
2
<groupId>org.springframework.boot</groupId>
3
<artifactId>spring-boot-starter-restclient</artifactId>
4
</dependency>

应用自身的信息继续由 app.health 管理,被监控目标则使用独立前缀:

src/main/resources/application.yaml
01
app:
02
health:
03
status: UP
04
service: spring
05
monitoring:
06
target-name: inventory
07
target-url: ${TARGET_HEALTH_URL:http://localhost:8081/api/v1/health}
08
09
spring:
10
http:
11
clients:
12
connect-timeout: 2s
13
read-timeout: 3s
14
redirects: dont-follow

连接超时限制建立连接最多等待多久,读取超时限制连接建立后等待响应数据的时间。二者解决的不是同一个阶段,生产调用都需要明确设置。关闭自动重定向可以避免监控请求在不知情的情况下访问新地址。

为目标配置建立独立 record:

src/main/java/cn/usehook/spring/config/MonitoringProperties.java
01
package cn.usehook.spring.config;
02
03
import jakarta.validation.constraints.NotBlank;
04
import jakarta.validation.constraints.NotNull;
05
import java.net.URI;
06
import org.springframework.boot.context.properties.ConfigurationProperties;
07
import org.springframework.validation.annotation.Validated;
08
09
@Validated
10
@ConfigurationProperties(prefix = "app.monitoring")
11
public record MonitoringProperties(
12
@NotBlank String targetName,
13
@NotNull URI targetUrl
14
) {}

前文已在入口类使用 @ConfigurationPropertiesScan,这个 record 位于主包之下,会自动注册成 Bean。使用 URI 而不是 String,可以在启动绑定阶段发现格式错误,也能避免业务代码到处重复解析地址。

3. 封装远程协议

Spring Boot 会提供预配置的 RestClient.Builder。构造器接收 Builder,再为当前客户端创建实例:

src/main/java/cn/usehook/spring/monitoring/RemoteHealthClient.java
01
package cn.usehook.spring.monitoring;
02
03
import java.net.URI;
04
import org.springframework.stereotype.Component;
05
import org.springframework.web.client.RestClient;
06
import org.springframework.web.client.RestClientException;
07
08
@Component
09
public class RemoteHealthClient {
10
11
private final RestClient restClient;
12
13
public RemoteHealthClient(RestClient.Builder builder) {
14
this.restClient = builder.build();
15
}
16
17
public ProbeResult probe(URI targetUrl) {
18
try {
19
RemoteHealthResponse response = restClient.get()
20
.uri(targetUrl)
21
.retrieve()
22
.body(RemoteHealthResponse.class);
23
24
if (
25
response == null
26
|| response.code() != 0
27
|| response.data() == null
28
|| !"UP".equals(response.data().status())
29
) {
30
return ProbeResult.down();
31
}
32
33
return ProbeResult.up();
34
} catch (RestClientException exception) {
35
return ProbeResult.down();
36
}
37
}
38
39
private record RemoteHealthResponse(
40
int code,
41
String message,
42
RemoteHealthData data
43
) {}
44
45
private record RemoteHealthData(String status, String service) {}
46
}

retrieve() 发起请求并处理响应,body(RemoteHealthResponse.class) 使用消息转换器把 JSON 反序列化为 record。这里没有复用本项目的 HealthStatus,因为远程响应属于外部协议;单独的私有类型可以阻止对方字段变化直接污染本地业务模型。

当前监控规则把连接失败、超时、非成功状态码、无法解析的响应和非 UP 状态统一视为 DOWN。这是业务选择,不是 RestClient 的固定行为。若调用的是支付或订单 API,通常应该抛出明确异常,而不是把各种失败压缩成一个状态。

本地结果使用枚举,避免在业务代码中继续传播任意字符串:

src/main/java/cn/usehook/spring/monitoring/ProbeResult.java
01
package cn.usehook.spring.monitoring;
02
03
import cn.usehook.spring.health.HealthState;
04
05
public record ProbeResult(HealthState state) {
06
07
public static ProbeResult up() {
08
return new ProbeResult(HealthState.UP);
09
}
10
11
public static ProbeResult down() {
12
return new ProbeResult(HealthState.DOWN);
13
}
14
}

HealthState 已在「Java 类与对象」章节介绍,可以直接使用 UP 和 DOWN 两个固定实例。

4. 在 Service 中编排

外部调用可能等待数秒,不应该被包进数据库事务。否则等待远程响应期间,连接和事务资源会一直被占用。将网络调用放在无事务的编排服务中,再把短暂的数据库写入交给另一个 Bean:

src/main/java/cn/usehook/spring/monitoring/HealthCheckService.java
01
package cn.usehook.spring.monitoring;
02
03
import cn.usehook.spring.config.MonitoringProperties;
04
import cn.usehook.spring.health.HealthStatus;
05
import org.springframework.stereotype.Service;
06
07
@Service
08
public class HealthCheckService {
09
10
private final MonitoringProperties properties;
11
private final RemoteHealthClient client;
12
private final HealthResultRecorder recorder;
13
14
public HealthCheckService(
15
MonitoringProperties properties,
16
RemoteHealthClient client,
17
HealthResultRecorder recorder
18
) {
19
this.properties = properties;
20
this.client = client;
21
this.recorder = recorder;
22
}
23
24
public HealthStatus checkConfiguredTarget() {
25
ProbeResult result = client.probe(properties.targetUrl());
26
return recorder.record(
27
properties.targetName(),
28
result.state()
29
);
30
}
31
}

负责落库的 Bean 保持较短事务:

src/main/java/cn/usehook/spring/monitoring/HealthResultRecorder.java
01
package cn.usehook.spring.monitoring;
02
03
import cn.usehook.spring.health.HealthStatus;
04
import cn.usehook.spring.health.HealthStatusRepository;
05
import cn.usehook.spring.health.HealthState;
06
import org.springframework.stereotype.Service;
07
import org.springframework.transaction.annotation.Transactional;
08
09
@Service
10
public class HealthResultRecorder {
11
12
private final HealthStatusRepository repository;
13
14
public HealthResultRecorder(HealthStatusRepository repository) {
15
this.repository = repository;
16
}
17
18
@Transactional
19
public HealthStatus record(String service, HealthState state) {
20
return repository.save(new HealthStatus(state.name(), service));
21
}
22
}

这里使用两个 Spring Bean 不只是为了缩短文件。@Transactional 由代理生效;将 record() 放进独立 Bean 后,HealthCheckService 对它的调用会经过事务代理。网络调用结束后才开启事务,数据库写入完成后立即提交。

不要改变原有 POST /api/v1/health/check 的请求体契约。为配置目标增加一个语义独立的入口,Controller 只负责转换结果:

src/main/java/cn/usehook/spring/controller/HealthController.java
01
private final HealthService healthService;
02
private final HealthCheckService healthCheckService;
03
04
public HealthController(
05
HealthService healthService,
06
HealthCheckService healthCheckService
07
) {
08
this.healthService = healthService;
09
this.healthCheckService = healthCheckService;
10
}
11
12
@PostMapping("/health/check-configured")
13
public HealthResponse checkConfiguredTarget() {
14
HealthStatus status = healthCheckService.checkConfiguredTarget();
15
return HealthResponse.success(status);
16
}

这段增量代码还需要引入 HealthCheckService、HealthStatus 和 HealthResponse。新接口没有请求体,因为目标名称与地址都来自受控配置;旧接口仍保留客户端传入 service 的原有含义。两条路径可以复用响应 DTO 与 Repository 契约,但不会把两个不同操作伪装成同一个接口。

5. 失败边界与安全边界

远程调用不能只考虑成功响应。当前方案至少明确了以下边界:

情况当前监控语义还应记录什么
建立连接失败DOWN目标名称、失败类别、耗时
请求或读取超时DOWN超时阶段、配置阈值
HTTP 4xx 或 5xxDOWN状态码,不记录敏感响应体
JSON 无法解析DOWN协议错误与目标名称
返回业务状态 DOWNDOWN远程业务状态

示例为了聚焦调用流程,只返回状态。生产代码还应使用参数化日志、指标或追踪记录失败原因,但不要把认证令牌和完整响应体写入日志。

目标 URL 应来自受信任的配置或经过严格校验的数据。若普通用户可以任意提交地址,服务器可能被利用来访问内网管理端点或云元数据服务,形成 SSRF。动态目标至少需要限制协议、主机和端口,并在网络层限制应用可访问的地址范围。

重试也不是默认答案。只有操作具备幂等性、故障可能短暂且总等待时间受到限制时才适合重试;重试次数、退避时间和最外层请求超时必须共同设计。一次用户请求内无限重试只会把局部故障放大成线程耗尽。

6. 不启动远程服务也能测试

客户端测试可以用 MockRestServiceServer 接管 Builder,不需要真的启动第二个 Spring Boot 应用:

src/test/java/cn/usehook/spring/monitoring/RemoteHealthClientTests.java
01
package cn.usehook.spring.monitoring;
02
03
import org.junit.jupiter.api.Test;
04
import org.springframework.http.MediaType;
05
import org.springframework.test.web.client.MockRestServiceServer;
06
import org.springframework.web.client.RestClient;
07
08
import static org.assertj.core.api.Assertions.assertThat;
09
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
10
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
11
12
class RemoteHealthClientTests {
13
14
@Test
15
void readsUpStateFromRemoteService() {
16
RestClient.Builder builder = RestClient.builder();
17
MockRestServiceServer server = MockRestServiceServer
18
.bindTo(builder)
19
.build();
20
RemoteHealthClient client = new RemoteHealthClient(builder);
21
22
server.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
""",
31
MediaType.APPLICATION_JSON
32
));
33
34
ProbeResult result = client.probe(
35
java.net.URI.create("http://inventory/api/v1/health")
36
);
37
38
assertThat(result.state()).isEqualTo(HealthState.UP);
39
server.verify();
40
}
41
}

这个测试验证请求地址、JSON 反序列化和状态转换,边界正好落在客户端类。还应分别补充 500、超时或连接异常、畸形 JSON 和业务状态 DOWN 的用例。Service 测试则模拟 RemoteHealthClient 与 HealthResultRecorder,验证目标配置是否被正确传递,不要在同一个测试里重复验证两层细节。

至此,心跳项目第一次真正跨出了本进程。下一篇会在这条调用链之上增加定时任务与异步执行,让系统能够主动、周期性地检查目标,同时控制线程池、并发和任务重叠。