「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:
1public class InMemoryHealthStatusRepository implements HealthStatusRepository {2// 原有实现保持不变3}
不需要删除这个类。它仍然可以在单元测试中通过 new InMemoryHealthStatusRepository() 直接创建,只是不再由组件扫描注册成应用 Bean。接下来只有 PostgreSQL 实现使用 @Repository:
1HealthService2-> HealthStatusRepository 接口3-> PostgresHealthStatusRepository Bean4-> JdbcClient5-> health_status 表67InMemoryHealthStatusRepository8-> 保留为普通类,可供不启动数据库的单元测试使用
如果未来确实要按开发、测试和生产环境选择不同实现,可以再使用 Profile 或条件配置。当前只有一个运行时实现更直观,也避免在尚未讲解环境切换时引入额外配置。
2. 映射数据库行
数据库查询返回的是 ResultSet 中的一行,Service 需要的是 HealthStatus。先在新实现中定义一个可复用的 RowMapper,明确列与 record 组件的对应关系:
1private static final RowMapper<HealthStatus> ROW_MAPPER = (resultSet, rowNumber) ->2new HealthStatus(3resultSet.getString("status"),4resultSet.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 完成这个操作:
01@Override02public HealthStatus save(HealthStatus status) {03Objects.requireNonNull(status, "status must not be null");04Objects.requireNonNull(status.service(), "status.service must not be null");05Objects.requireNonNull(status.status(), "status.status must not be null");0607jdbcClient.sql("""08insert into health_status (service, status, checked_at)09values (:service, :status, current_timestamp)10on conflict (service) do update11set status = excluded.status,12checked_at = excluded.checked_at13""")14.param("service", status.service())15.param("status", status.status())16.update();1718return 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():
01@Override02public Optional<HealthStatus> findByService(String service) {03Objects.requireNonNull(service, "service must not be null");0405return jdbcClient.sql("""06select service, status07from health_status08where service = :service09""")10.param("service", service)11.query(ROW_MAPPER)12.optional();13}
主键条件最多匹配一行,因此查询不到时返回 Optional.empty(),查到时返回包含 HealthStatus 的 Optional。如果数据库约束遭到破坏而返回多行,optional() 会抛出异常,不会悄悄取第一行。
列表查询需要延续接口约定的服务名称升序:
01@Override02public List<HealthStatus> findAll() {03return jdbcClient.sql("""04select service, status05from health_status06order by service07""")08.query(ROW_MAPPER)09.list();10}
没有 ORDER BY 时,关系数据库不保证返回顺序。把排序明确写入 SQL,数据库实现才与内存实现的 findAll() 契约保持一致。数据规模增长后,还需要在 SQL 层增加分页,而不是把整张表读到 Java 内存再截取。
5. 完整 Repository
把字段、构造方法和三个接口方法组合起来,得到完整实现:
01package cn.usehook.spring.health;0203import java.util.List;04import java.util.Objects;05import java.util.Optional;06import org.springframework.jdbc.core.RowMapper;07import org.springframework.jdbc.core.simple.JdbcClient;08import org.springframework.stereotype.Repository;0910@Repository11public class PostgresHealthStatusRepository implements HealthStatusRepository {1213private static final RowMapper<HealthStatus> ROW_MAPPER = (resultSet, rowNumber) ->14new HealthStatus(15resultSet.getString("status"),16resultSet.getString("service")17);1819private final JdbcClient jdbcClient;2021public PostgresHealthStatusRepository(JdbcClient jdbcClient) {22this.jdbcClient = jdbcClient;23}2425@Override26public HealthStatus save(HealthStatus status) {27Objects.requireNonNull(status, "status must not be null");28Objects.requireNonNull(status.service(), "status.service must not be null");29Objects.requireNonNull(status.status(), "status.status must not be null");3031jdbcClient.sql("""32insert into health_status (service, status, checked_at)33values (:service, :status, current_timestamp)34on conflict (service) do update35set status = excluded.status,36checked_at = excluded.checked_at37""")38.param("service", status.service())39.param("status", status.status())40.update();4142return status;43}4445@Override46public Optional<HealthStatus> findByService(String service) {47Objects.requireNonNull(service, "service must not be null");4849return jdbcClient.sql("""50select service, status51from health_status52where service = :service53""")54.param("service", service)55.query(ROW_MAPPER)56.optional();57}5859@Override60public List<HealthStatus> findAll() {61return jdbcClient.sql("""62select service, status63from health_status64order by service65""")66.query(ROW_MAPPER)67.list();68}69}
@Repository 让这个类成为数据访问 Bean,构造器中的 JdbcClient 由容器注入。JdbcClient 又使用已经配置好的 DataSource,所以 Repository 不创建连接,也不读取数据库密码。
SQL 语法错误、连接失败和约束冲突会由 Spring JDBC 转换为 DataAccessException 体系。它们表示数据访问失败,不应该被 findByService() 转换成 Optional.empty();空 Optional 只表示查询成功但没有匹配行。
6. 通过接口验证持久化
启动应用后,先通过 POST 接口检查并保存一个新服务:
1curl -i -X POST http://localhost:8080/api/v1/health/check \2-H 'Content-Type: application/json' \3-d '{"service":"postgresql"}'
再调用只查询已保存状态的接口:
1curl -i http://localhost:8080/api/v1/health/status/postgresql
停止并重新启动 Spring Boot 后再次执行第二条命令,仍然应该读到记录。这个结果说明数据来自 PostgreSQL,而不是内存 Repository 中的 Map。也可以直接在 psql 中确认:
1select service, status, checked_at2from health_status3order by service;
Repository 的数据库集成测试需要真实 PostgreSQL,因为它要验证 SQL、约束和行映射,而不仅是 Java 分支:
01package cn.usehook.spring.health;0203import org.junit.jupiter.api.Test;04import org.springframework.beans.factory.annotation.Autowired;05import org.springframework.boot.test.context.SpringBootTest;06import org.springframework.jdbc.core.simple.JdbcClient;0708import static org.assertj.core.api.Assertions.assertThat;0910@SpringBootTest11class PostgresHealthStatusRepositoryTests {1213private final HealthStatusRepository repository;14private final JdbcClient jdbcClient;1516@Autowired17PostgresHealthStatusRepositoryTests(18HealthStatusRepository repository,19JdbcClient jdbcClient20) {21this.repository = repository;22this.jdbcClient = jdbcClient;23}2425@Test26void savesAndUpdatesLatestStatus() {27String service = "repository-test";2829try {30repository.save(new HealthStatus("UP", service));31repository.save(new HealthStatus("DOWN", service));3233assertThat(repository.findByService(service))34.contains(new HealthStatus("DOWN", service));35} finally {36jdbcClient.sql("delete from health_status where service = :service")37.param("service", service)38.update();39}40}41}
测试在 finally 中删除自己创建的数据,避免重复运行互相影响。确保 PostgreSQL 已启动后执行:
1DB_PASSWORD='spring' ./mvnw -Dtest=PostgresHealthStatusRepositoryTests test
到这里,Controller -> Service -> Repository -> PostgreSQL 已经完整连通,但一次 Service 调用目前只执行一条写入。下一章会增加健康检查历史表,并说明怎样让「更新最新状态」和「追加历史记录」要么一起成功,要么一起回滚。