上一章已经把 HealthCheckHistoryEntity 映射到 health_check_history 表。直接使用 EntityManager 可以完成数据访问,但每个实体都要重复编写保存、按主键查询、删除和分页等常规代码。
Spring Data JPA 允许我们声明 Repository 接口,并在运行时创建实现。本章用它替换历史记录的 JdbcClient 查询,同时保留 HealthService、HealthCheckRecord 和 PageResult,让数据访问技术的变化停留在持久化边界内。
1. 声明 Repository 接口
在实体旁边创建接口:
01package cn.usehook.spring.health.persistence;0203import org.springframework.data.domain.Page;04import org.springframework.data.domain.Pageable;05import org.springframework.data.jpa.repository.JpaRepository;0607public interface HealthCheckHistoryJpaRepository08extends JpaRepository<HealthCheckHistoryEntity, Long> {0910Page<HealthCheckHistoryEntity> findByService(String service, Pageable pageable);1112long countByService(String service);13}
JpaRepository<HealthCheckHistoryEntity, Long> 的两个泛型参数分别表示实体类型和主键类型。继承后,接口已经拥有 save()、findById()、findAll()、count() 和 deleteById() 等常规方法。
这里没有编写 implements HealthCheckHistoryJpaRepository 的类。应用启动时,Spring Data 会检查接口、实体和方法签名,再创建代理实现并注册为 Bean。接口位于主应用包的子包中,Spring Boot 可以自动发现,不需要手动添加 @Repository。
名称带有 Jpa 是为了区分两层:HealthCheckHistoryJpaRepository 是直接操作实体的框架接口,现有 HealthCheckHistoryRepository 则是应用持久化边界,负责在实体与业务模型之间转换。
2. 从方法名生成查询
findByService 会按方法名中的实体属性生成查询。它读取的是 Java 属性 service,不是数据库列名;列名转换由实体映射处理。常见关键字可以组合出不同条件:
| 方法签名 | 表达的条件 |
|---|---|
findByService(...) | service 相等 |
findByStatusAndCheckedAtAfter(...) | 状态相等,并且时间晚于给定值 |
existsByService(...) | 是否至少存在一条记录 |
countByService(...) | 指定服务的记录数 |
findTop10ByServiceOrderByCheckedAtDesc(...) | 指定服务中时间最新的十条 |
方法名适合结构简单、含义直接的查询。条件过多时,继续把所有逻辑塞进名称会变得难读,可以使用 JPQL 明确表达:
01@Query("""02select history03from HealthCheckHistoryEntity history04where history.status = :status05and history.checkedAt >= :from06order by history.checkedAt desc, history.id desc07""")08List<HealthCheckHistoryEntity> findRecentByStatus(09@Param("status") String status,10@Param("from") Instant from11);
这段代码需要增加 java.time.Instant、java.util.List、org.springframework.data.jpa.repository.Query 和 org.springframework.data.repository.query.Param 的 import。
JPQL 查询的是实体和属性,所以写 HealthCheckHistoryEntity、checkedAt,而不是表名 health_check_history、列名 checked_at。只有必须使用 PostgreSQL 特有语法时才考虑原生 SQL,并明确设置 nativeQuery = true;原生 SQL 会降低数据库可移植性,也要求开发者自己确保返回列能够映射。
Spring Data 会在应用启动时解析 Repository 查询。属性名拼错或 JPQL 无法解析时,上下文通常会直接启动失败,这比第一次请求到来时才发现错误更容易定位。
3. Page、Slice 与排序
接口返回 Page<HealthCheckHistoryEntity> 时,Spring Data 通常执行两条查询:一条取得当前页,一条统计总数。Page 因此可以提供:
| 方法 | 含义 |
|---|---|
getContent() | 当前页实体列表 |
getNumber() | 当前页码,从 0 开始 |
getSize() | 请求的每页数量 |
getTotalElements() | 满足条件的总记录数 |
getTotalPages() | 总页数 |
hasNext() | 是否还有下一页 |
分页与排序通过 Pageable 一起传入:
1Sort order = Sort.by(Sort.Direction.DESC, "checkedAt", "id");2Pageable pageable = PageRequest.of(0, 20, order);34Page<HealthCheckHistoryEntity> result = repository.findByService(5"spring",6pageable7);
排序字段同样是实体属性名。这里保留 checkedAt 与 id 的复合排序,原因与手写 SQL 相同:时间相等时还需要唯一字段保证翻页顺序稳定。
如果客户端只需要判断是否还有下一页,不需要总记录数,可以让查询方法返回 Slice<HealthCheckHistoryEntity>。Slice 会读取足够的数据判断 hasNext(),不承诺提供总数,因而可以省去计数查询。需要页码导航时用 Page,无限滚动或「加载更多」通常更适合 Slice。
Pageable 仍然使用偏移分页。它减少的是样板代码,不会消除大偏移量的数据库成本;数据规模很大时依旧要评估游标分页。
4. 用适配层替换手写 SQL
接下来改写现有 HealthCheckHistoryRepository。它的公开方法保持不变,内部改为调用 Spring Data 接口:
01package cn.usehook.spring.health;0203import cn.usehook.spring.health.persistence.HealthCheckHistoryEntity;04import cn.usehook.spring.health.persistence.HealthCheckHistoryJpaRepository;05import java.util.List;06import java.util.Objects;07import org.springframework.data.domain.Page;08import org.springframework.data.domain.PageRequest;09import org.springframework.data.domain.Pageable;10import org.springframework.data.domain.Sort;11import org.springframework.stereotype.Repository;1213@Repository14public class HealthCheckHistoryRepository {1516private final HealthCheckHistoryJpaRepository repository;1718public HealthCheckHistoryRepository(HealthCheckHistoryJpaRepository repository) {19this.repository = repository;20}2122public void append(HealthStatus status) {23Objects.requireNonNull(status, "status must not be null");2425HealthCheckHistoryEntity entity = HealthCheckHistoryEntity.create(26status.service(),27status.status()28);29repository.save(entity);30}3132public PageResult<HealthCheckRecord> findPage(33String service,34int page,35int size,36HistorySort sort37) {38Sort.Direction direction = switch (sort) {39case NEWEST -> Sort.Direction.DESC;40case OLDEST -> Sort.Direction.ASC;41};42Pageable pageable = PageRequest.of(43page,44size,45Sort.by(direction, "checkedAt", "id")46);4748Page<HealthCheckHistoryEntity> result = service == null49? repository.findAll(pageable)50: repository.findByService(service, pageable);5152List<HealthCheckRecord> items = result.getContent().stream()53.map(HealthCheckHistoryRepository::toRecord)54.toList();5556return new PageResult<>(57items,58result.getNumber(),59result.getSize(),60result.getTotalElements(),61result.getTotalPages()62);63}6465private static HealthCheckRecord toRecord(HealthCheckHistoryEntity entity) {66return new HealthCheckRecord(67entity.getId(),68entity.getService(),69entity.getStatus(),70entity.getCheckedAt()71);72}73}
这个类是适配层,而不是多余转发。它阻止 JPA 实体、Pageable 和 Spring Data 接口进入 Service;上层仍然只理解 HealthStatus、HealthCheckRecord、PageResult 和 HistorySort。以后更换查询方式时,不需要跟着修改 Controller 和响应 DTO。
上一章实体中的 create() 负责构造合法的新实体,toRecord() 则负责把持久化对象转换为业务读取模型。转换方向集中在边界中,不让实体直接承担 HTTP 响应职责。
5. 保存、刷新与事务
repository.save(entity) 并不承诺调用结束时事务已经提交。对当前没有主键的新实体,Spring Data JPA 会让 EntityManager 持久化它;Hibernate 在需要取得 identity 主键、执行查询、显式刷新或提交事务时,把变化同步为 SQL。
「事务」章节的 HealthService.check() 已经声明 @Transactional,所以更新 health_status 的 JdbcClient SQL 和保存历史实体的 JPA 操作仍然参与同一个数据库事务:
1HealthService.check() 事务2-> JdbcClient 更新 health_status3-> Spring Data JPA 保存 health_check_history4-> 两步成功后共同提交5-> 任一步抛出运行时异常时共同回滚
JDBC 与 JPA 能共享这条事务边界,是因为它们使用同一个 Spring 管理的 DataSource 和事务管理器。项目如果以后引入多个数据源或多个事务管理器,就必须明确每个方法使用哪一个,不能再假设它们天然属于同一事务。
Spring Data 的单个写方法本身带有事务设置,但它只能覆盖自己的操作。跨越多个 Repository 的完整业务规则仍应由 Service 的事务包住,不能把事务边界退回每个 save()。
开发期间可以临时查看 Hibernate SQL:
1logging:2level:3org.hibernate.SQL: debug4org.hibernate.orm.jdbc.bind: trace
参数绑定日志可能包含服务名或其他业务数据,只适合在受控的本地排查中短期开启,不应该作为生产默认配置。观察 SQL 的目的不是确认「有输出」,而是检查查询次数、筛选条件、排序和索引是否符合预期。
6. 验证 Repository 行为
继续使用 Testcontainers,测试适配层和 Spring Data 派生查询:
01package cn.usehook.spring.health;0203import cn.usehook.spring.TestcontainersConfiguration;04import cn.usehook.spring.health.persistence.HealthCheckHistoryJpaRepository;05import org.junit.jupiter.api.Test;06import org.springframework.beans.factory.annotation.Autowired;07import org.springframework.boot.test.context.SpringBootTest;08import org.springframework.context.annotation.Import;09import org.springframework.transaction.annotation.Transactional;1011import static org.assertj.core.api.Assertions.assertThat;1213@SpringBootTest14@Import(TestcontainersConfiguration.class)15@Transactional16class HealthCheckHistoryRepositoryTests {1718private final HealthCheckHistoryRepository repository;19private final HealthCheckHistoryJpaRepository jpaRepository;2021@Autowired22HealthCheckHistoryRepositoryTests(23HealthCheckHistoryRepository repository,24HealthCheckHistoryJpaRepository jpaRepository25) {26this.repository = repository;27this.jpaRepository = jpaRepository;28}2930@Test31void savesFiltersAndPaginatesEntities() {32for (int index = 0; index < 7; index++) {33repository.append(new HealthStatus("UP", "spring-data"));34}3536PageResult<HealthCheckRecord> result = repository.findPage(37"spring-data",380,395,40HistorySort.NEWEST41);4243assertThat(result.items()).hasSize(5);44assertThat(result.totalElements()).isEqualTo(7);45assertThat(result.totalPages()).isEqualTo(2);46assertThat(jpaRepository.countByService("spring-data")).isEqualTo(7);47}48}
调用分页查询前,Hibernate 会刷新当前事务中尚未同步的新增实体,因此刚追加的数据能够进入查询结果。测试同时覆盖了实体创建、Spring Data 保存、方法名派生查询、分页和适配层转换。
Spring Data JPA 减少了常规数据访问代码,但不会替代边界设计。实体留在持久化层,DTO 留在 Web 层,业务事务留在 Service;分页与索引是否高效,则继续由生成的 SQL 和 PostgreSQL 执行计划决定。下一篇加入监控分组与受监控服务,继续学习实体之间的关联关系和加载策略。