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

上一章已经把 HealthCheckHistoryEntity 映射到 health_check_history 表。直接使用 EntityManager 可以完成数据访问,但每个实体都要重复编写保存、按主键查询、删除和分页等常规代码。

Spring Data JPA 允许我们声明 Repository 接口,并在运行时创建实现。本章用它替换历史记录的 JdbcClient 查询,同时保留 HealthService、HealthCheckRecord 和 PageResult,让数据访问技术的变化停留在持久化边界内。

1. 声明 Repository 接口

在实体旁边创建接口:

src/main/java/cn/usehook/spring/health/persistence/HealthCheckHistoryJpaRepository.java
01
package cn.usehook.spring.health.persistence;
02
03
import org.springframework.data.domain.Page;
04
import org.springframework.data.domain.Pageable;
05
import org.springframework.data.jpa.repository.JpaRepository;
06
07
public interface HealthCheckHistoryJpaRepository
08
extends JpaRepository<HealthCheckHistoryEntity, Long> {
09
10
Page<HealthCheckHistoryEntity> findByService(String service, Pageable pageable);
11
12
long 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 明确表达:

HealthCheckHistoryJpaRepository.java
01
@Query("""
02
select history
03
from HealthCheckHistoryEntity history
04
where history.status = :status
05
and history.checkedAt >= :from
06
order by history.checkedAt desc, history.id desc
07
""")
08
List<HealthCheckHistoryEntity> findRecentByStatus(
09
@Param("status") String status,
10
@Param("from") Instant from
11
);

这段代码需要增加 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 一起传入:

pageable-example.java
1
Sort order = Sort.by(Sort.Direction.DESC, "checkedAt", "id");
2
Pageable pageable = PageRequest.of(0, 20, order);
3
4
Page<HealthCheckHistoryEntity> result = repository.findByService(
5
"spring",
6
pageable
7
);

排序字段同样是实体属性名。这里保留 checkedAt 与 id 的复合排序,原因与手写 SQL 相同:时间相等时还需要唯一字段保证翻页顺序稳定。

如果客户端只需要判断是否还有下一页,不需要总记录数,可以让查询方法返回 Slice<HealthCheckHistoryEntity>。Slice 会读取足够的数据判断 hasNext(),不承诺提供总数,因而可以省去计数查询。需要页码导航时用 Page,无限滚动或「加载更多」通常更适合 Slice。

Pageable 仍然使用偏移分页。它减少的是样板代码,不会消除大偏移量的数据库成本;数据规模很大时依旧要评估游标分页。

4. 用适配层替换手写 SQL

接下来改写现有 HealthCheckHistoryRepository。它的公开方法保持不变,内部改为调用 Spring Data 接口:

src/main/java/cn/usehook/spring/health/HealthCheckHistoryRepository.java
01
package cn.usehook.spring.health;
02
03
import cn.usehook.spring.health.persistence.HealthCheckHistoryEntity;
04
import cn.usehook.spring.health.persistence.HealthCheckHistoryJpaRepository;
05
import java.util.List;
06
import java.util.Objects;
07
import org.springframework.data.domain.Page;
08
import org.springframework.data.domain.PageRequest;
09
import org.springframework.data.domain.Pageable;
10
import org.springframework.data.domain.Sort;
11
import org.springframework.stereotype.Repository;
12
13
@Repository
14
public class HealthCheckHistoryRepository {
15
16
private final HealthCheckHistoryJpaRepository repository;
17
18
public HealthCheckHistoryRepository(HealthCheckHistoryJpaRepository repository) {
19
this.repository = repository;
20
}
21
22
public void append(HealthStatus status) {
23
Objects.requireNonNull(status, "status must not be null");
24
25
HealthCheckHistoryEntity entity = HealthCheckHistoryEntity.create(
26
status.service(),
27
status.status()
28
);
29
repository.save(entity);
30
}
31
32
public PageResult<HealthCheckRecord> findPage(
33
String service,
34
int page,
35
int size,
36
HistorySort sort
37
) {
38
Sort.Direction direction = switch (sort) {
39
case NEWEST -> Sort.Direction.DESC;
40
case OLDEST -> Sort.Direction.ASC;
41
};
42
Pageable pageable = PageRequest.of(
43
page,
44
size,
45
Sort.by(direction, "checkedAt", "id")
46
);
47
48
Page<HealthCheckHistoryEntity> result = service == null
49
? repository.findAll(pageable)
50
: repository.findByService(service, pageable);
51
52
List<HealthCheckRecord> items = result.getContent().stream()
53
.map(HealthCheckHistoryRepository::toRecord)
54
.toList();
55
56
return new PageResult<>(
57
items,
58
result.getNumber(),
59
result.getSize(),
60
result.getTotalElements(),
61
result.getTotalPages()
62
);
63
}
64
65
private static HealthCheckRecord toRecord(HealthCheckHistoryEntity entity) {
66
return new HealthCheckRecord(
67
entity.getId(),
68
entity.getService(),
69
entity.getStatus(),
70
entity.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 操作仍然参与同一个数据库事务:

mixed-transaction.txt
1
HealthService.check() 事务
2
-> JdbcClient 更新 health_status
3
-> Spring Data JPA 保存 health_check_history
4
-> 两步成功后共同提交
5
-> 任一步抛出运行时异常时共同回滚

JDBC 与 JPA 能共享这条事务边界,是因为它们使用同一个 Spring 管理的 DataSource 和事务管理器。项目如果以后引入多个数据源或多个事务管理器,就必须明确每个方法使用哪一个,不能再假设它们天然属于同一事务。

Spring Data 的单个写方法本身带有事务设置,但它只能覆盖自己的操作。跨越多个 Repository 的完整业务规则仍应由 Service 的事务包住,不能把事务边界退回每个 save()。

开发期间可以临时查看 Hibernate SQL:

application.yaml
1
logging:
2
level:
3
org.hibernate.SQL: debug
4
org.hibernate.orm.jdbc.bind: trace

参数绑定日志可能包含服务名或其他业务数据,只适合在受控的本地排查中短期开启,不应该作为生产默认配置。观察 SQL 的目的不是确认「有输出」,而是检查查询次数、筛选条件、排序和索引是否符合预期。

6. 验证 Repository 行为

继续使用 Testcontainers,测试适配层和 Spring Data 派生查询:

src/test/java/cn/usehook/spring/health/HealthCheckHistoryRepositoryTests.java
01
package cn.usehook.spring.health;
02
03
import cn.usehook.spring.TestcontainersConfiguration;
04
import cn.usehook.spring.health.persistence.HealthCheckHistoryJpaRepository;
05
import org.junit.jupiter.api.Test;
06
import org.springframework.beans.factory.annotation.Autowired;
07
import org.springframework.boot.test.context.SpringBootTest;
08
import org.springframework.context.annotation.Import;
09
import org.springframework.transaction.annotation.Transactional;
10
11
import static org.assertj.core.api.Assertions.assertThat;
12
13
@SpringBootTest
14
@Import(TestcontainersConfiguration.class)
15
@Transactional
16
class HealthCheckHistoryRepositoryTests {
17
18
private final HealthCheckHistoryRepository repository;
19
private final HealthCheckHistoryJpaRepository jpaRepository;
20
21
@Autowired
22
HealthCheckHistoryRepositoryTests(
23
HealthCheckHistoryRepository repository,
24
HealthCheckHistoryJpaRepository jpaRepository
25
) {
26
this.repository = repository;
27
this.jpaRepository = jpaRepository;
28
}
29
30
@Test
31
void savesFiltersAndPaginatesEntities() {
32
for (int index = 0; index < 7; index++) {
33
repository.append(new HealthStatus("UP", "spring-data"));
34
}
35
36
PageResult<HealthCheckRecord> result = repository.findPage(
37
"spring-data",
38
0,
39
5,
40
HistorySort.NEWEST
41
);
42
43
assertThat(result.items()).hasSize(5);
44
assertThat(result.totalElements()).isEqualTo(7);
45
assertThat(result.totalPages()).isEqualTo(2);
46
assertThat(jpaRepository.countByService("spring-data")).isEqualTo(7);
47
}
48
}

调用分页查询前,Hibernate 会刷新当前事务中尚未同步的新增实体,因此刚追加的数据能够进入查询结果。测试同时覆盖了实体创建、Spring Data 保存、方法名派生查询、分页和适配层转换。

Spring Data JPA 减少了常规数据访问代码,但不会替代边界设计。实体留在持久化层,DTO 留在 Web 层,业务事务留在 Service;分页与索引是否高效,则继续由生成的 SQL 和 PostgreSQL 执行计划决定。下一篇加入监控分组与受监控服务,继续学习实体之间的关联关系和加载策略。