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

「Repository」章节定义的 HealthStatusRepository 已经明确了保存、单条查询和列表查询的契约,但当时的实现只把数据放在 ConcurrentHashMap 中。前两篇已经准备好 JdbcClient 和 health_status 表,现在可以替换 Repository 的实现,而不改动 Controller、DTO 和 Service 的业务调用方式。

本章继续手写 SQL,不使用 JPA 或 Spring Data 自动生成查询。重点是看清 Repository 怎样连接 Java 对象与数据库行,以及存储技术替换后为什么上层代码仍然成立。

1. 替换数据访问实现

容器目前能够发现 InMemoryHealthStatusRepository,因为它带有 @Repository。如果再创建一个实现相同接口的 PostgreSQL Bean,HealthService 的构造器就会面对两个候选对象,Spring 无法判断该注入哪一个。

当前项目要正式切换到 PostgreSQL,所以先从内存实现中移除 @Repository 和对应的 import:

src/main/java/cn/usehook/spring/health/InMemoryHealthStatusRepository.java
1
public class InMemoryHealthStatusRepository implements HealthStatusRepository {
2
// 原有实现保持不变
3
}

不需要删除这个类。它仍然可以在单元测试中通过 new InMemoryHealthStatusRepository() 直接创建,只是不再由组件扫描注册成应用 Bean。接下来只有 PostgreSQL 实现使用 @Repository:

repository-replacement.txt
1
HealthService
2
-> HealthStatusRepository 接口
3
-> PostgresHealthStatusRepository Bean
4
-> JdbcClient
5
-> health_status 表
6
7
InMemoryHealthStatusRepository
8
-> 保留为普通类,可供不启动数据库的单元测试使用

如果未来确实要按开发、测试和生产环境选择不同实现,可以再使用 Profile 或条件配置。当前只有一个运行时实现更直观,也避免在尚未讲解环境切换时引入额外配置。

2. 映射数据库行

数据库查询返回的是 ResultSet 中的一行,Service 需要的是 HealthStatus。先在新实现中定义一个可复用的 RowMapper,明确列与 record 组件的对应关系:

src/main/java/cn/usehook/spring/health/PostgresHealthStatusRepository.java
1
private static final RowMapper<HealthStatus> ROW_MAPPER = (resultSet, rowNumber) ->
2
new HealthStatus(
3
resultSet.getString("status"),
4
resultSet.getString("service")
5
);

HealthStatus 的构造顺序是 status、service,与表中的列声明顺序不同。显式按列名读取可以避免依赖 SELECT 返回列的位置,也让这种差异直接呈现在代码中。

表里还有 checked_at,但当前 HealthStatus 和响应 DTO 没有这个属性,所以映射器没有读取它。数据库表可以包含内部持久化字段,不要求每一列都直接暴露给 Service 或 HTTP 客户端。等业务需要返回检查时间时,再同时调整业务模型、DTO 和查询映射。

rowNumber 表示当前结果行的位置,这个映射不依赖行号,因此没有使用它。RowMapper 只负责「一行怎样变成一个对象」,查询结果究竟允许零条、一条还是多条,仍由调用方法选择。

3. 保存最新状态

Repository 的 save() 契约要求相同服务再次保存时替换旧状态。PostgreSQL 可以通过一条 INSERT ... ON CONFLICT DO UPDATE 完成这个操作:

src/main/java/cn/usehook/spring/health/PostgresHealthStatusRepository.java
01
@Override
02
public HealthStatus save(HealthStatus status) {
03
Objects.requireNonNull(status, "status must not be null");
04
Objects.requireNonNull(status.service(), "status.service must not be null");
05
Objects.requireNonNull(status.status(), "status.status must not be null");
06
07
jdbcClient.sql("""
08
insert into health_status (service, status, checked_at)
09
values (:service, :status, current_timestamp)
10
on conflict (service) do update
11
set status = excluded.status,
12
checked_at = excluded.checked_at
13
""")
14
.param("service", status.service())
15
.param("status", status.status())
16
.update();
17
18
return status;
19
}

执行过程分为两种情况:

数据状态PostgreSQL 行为
service 尚不存在插入新行
service 已经存在更新冲突行的 status 和 checked_at

ON CONFLICT (service) 能工作,是因为 service 是主键,数据库已经为它建立唯一约束。excluded 表示原本准备插入的新行,因此 excluded.status 和 excluded.checked_at 是本次保存的新值。

这类语句也称为 upsert。PostgreSQL 保证它产生原子性的插入或更新结果,即使多个请求同时保存同一服务,也不会因为应用先查询、再决定插入而产生重复行竞争。参数仍然通过 param() 绑定,外部值不会被拼接进 SQL 结构。

当前表没有数据库生成后还要返回给 Java 的业务字段,所以方法执行成功后原样返回 status。如果模型以后包含自增主键或数据库计算字段,可以使用 RETURNING 查询保存后的整行,而不是猜测数据库最终写入的值。

4. 查询一条与多条

单条查询允许服务不存在,因此使用「JdbcClient」章节介绍的 optional():

src/main/java/cn/usehook/spring/health/PostgresHealthStatusRepository.java
01
@Override
02
public Optional<HealthStatus> findByService(String service) {
03
Objects.requireNonNull(service, "service must not be null");
04
05
return jdbcClient.sql("""
06
select service, status
07
from health_status
08
where service = :service
09
""")
10
.param("service", service)
11
.query(ROW_MAPPER)
12
.optional();
13
}

主键条件最多匹配一行,因此查询不到时返回 Optional.empty(),查到时返回包含 HealthStatus 的 Optional。如果数据库约束遭到破坏而返回多行,optional() 会抛出异常,不会悄悄取第一行。

列表查询需要延续接口约定的服务名称升序:

src/main/java/cn/usehook/spring/health/PostgresHealthStatusRepository.java
01
@Override
02
public List<HealthStatus> findAll() {
03
return jdbcClient.sql("""
04
select service, status
05
from health_status
06
order by service
07
""")
08
.query(ROW_MAPPER)
09
.list();
10
}

没有 ORDER BY 时,关系数据库不保证返回顺序。把排序明确写入 SQL,数据库实现才与内存实现的 findAll() 契约保持一致。数据规模增长后,还需要在 SQL 层增加分页,而不是把整张表读到 Java 内存再截取。

5. 完整 Repository

把字段、构造方法和三个接口方法组合起来,得到完整实现:

src/main/java/cn/usehook/spring/health/PostgresHealthStatusRepository.java
01
package cn.usehook.spring.health;
02
03
import java.util.List;
04
import java.util.Objects;
05
import java.util.Optional;
06
import org.springframework.jdbc.core.RowMapper;
07
import org.springframework.jdbc.core.simple.JdbcClient;
08
import org.springframework.stereotype.Repository;
09
10
@Repository
11
public class PostgresHealthStatusRepository implements HealthStatusRepository {
12
13
private static final RowMapper<HealthStatus> ROW_MAPPER = (resultSet, rowNumber) ->
14
new HealthStatus(
15
resultSet.getString("status"),
16
resultSet.getString("service")
17
);
18
19
private final JdbcClient jdbcClient;
20
21
public PostgresHealthStatusRepository(JdbcClient jdbcClient) {
22
this.jdbcClient = jdbcClient;
23
}
24
25
@Override
26
public HealthStatus save(HealthStatus status) {
27
Objects.requireNonNull(status, "status must not be null");
28
Objects.requireNonNull(status.service(), "status.service must not be null");
29
Objects.requireNonNull(status.status(), "status.status must not be null");
30
31
jdbcClient.sql("""
32
insert into health_status (service, status, checked_at)
33
values (:service, :status, current_timestamp)
34
on conflict (service) do update
35
set status = excluded.status,
36
checked_at = excluded.checked_at
37
""")
38
.param("service", status.service())
39
.param("status", status.status())
40
.update();
41
42
return status;
43
}
44
45
@Override
46
public Optional<HealthStatus> findByService(String service) {
47
Objects.requireNonNull(service, "service must not be null");
48
49
return jdbcClient.sql("""
50
select service, status
51
from health_status
52
where service = :service
53
""")
54
.param("service", service)
55
.query(ROW_MAPPER)
56
.optional();
57
}
58
59
@Override
60
public List<HealthStatus> findAll() {
61
return jdbcClient.sql("""
62
select service, status
63
from health_status
64
order by service
65
""")
66
.query(ROW_MAPPER)
67
.list();
68
}
69
}

@Repository 让这个类成为数据访问 Bean,构造器中的 JdbcClient 由容器注入。JdbcClient 又使用已经配置好的 DataSource,所以 Repository 不创建连接,也不读取数据库密码。

SQL 语法错误、连接失败和约束冲突会由 Spring JDBC 转换为 DataAccessException 体系。它们表示数据访问失败,不应该被 findByService() 转换成 Optional.empty();空 Optional 只表示查询成功但没有匹配行。

6. 通过接口验证持久化

启动应用后,先通过 POST 接口检查并保存一个新服务:

terminal.bash
1
curl -i -X POST http://localhost:8080/api/v1/health/check \
2
-H 'Content-Type: application/json' \
3
-d '{"service":"postgresql"}'

再调用只查询已保存状态的接口:

terminal.bash
1
curl -i http://localhost:8080/api/v1/health/status/postgresql

停止并重新启动 Spring Boot 后再次执行第二条命令,仍然应该读到记录。这个结果说明数据来自 PostgreSQL,而不是内存 Repository 中的 Map。也可以直接在 psql 中确认:

psql.sql
1
select service, status, checked_at
2
from health_status
3
order by service;

Repository 的数据库集成测试需要真实 PostgreSQL,因为它要验证 SQL、约束和行映射,而不仅是 Java 分支:

src/test/java/cn/usehook/spring/health/PostgresHealthStatusRepositoryTests.java
01
package cn.usehook.spring.health;
02
03
import org.junit.jupiter.api.Test;
04
import org.springframework.beans.factory.annotation.Autowired;
05
import org.springframework.boot.test.context.SpringBootTest;
06
import org.springframework.jdbc.core.simple.JdbcClient;
07
08
import static org.assertj.core.api.Assertions.assertThat;
09
10
@SpringBootTest
11
class PostgresHealthStatusRepositoryTests {
12
13
private final HealthStatusRepository repository;
14
private final JdbcClient jdbcClient;
15
16
@Autowired
17
PostgresHealthStatusRepositoryTests(
18
HealthStatusRepository repository,
19
JdbcClient jdbcClient
20
) {
21
this.repository = repository;
22
this.jdbcClient = jdbcClient;
23
}
24
25
@Test
26
void savesAndUpdatesLatestStatus() {
27
String service = "repository-test";
28
29
try {
30
repository.save(new HealthStatus("UP", service));
31
repository.save(new HealthStatus("DOWN", service));
32
33
assertThat(repository.findByService(service))
34
.contains(new HealthStatus("DOWN", service));
35
} finally {
36
jdbcClient.sql("delete from health_status where service = :service")
37
.param("service", service)
38
.update();
39
}
40
}
41
}

测试在 finally 中删除自己创建的数据,避免重复运行互相影响。确保 PostgreSQL 已启动后执行:

terminal.bash
1
DB_PASSWORD='spring' ./mvnw -Dtest=PostgresHealthStatusRepositoryTests test

到这里,Controller -> Service -> Repository -> PostgreSQL 已经完整连通,但一次 Service 调用目前只执行一条写入。下一章会增加健康检查历史表,并说明怎样让「更新最新状态」和「追加历史记录」要么一起成功,要么一起回滚。