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

前几章使用 JdbcClient 手写 SQL,查询内容、参数绑定和行映射都清晰可见。这种方式很适合精确控制 SQL,但当表和常规增删改查逐渐增多时,每个 Repository 都要重复处理对象与数据库行之间的转换。

JPA 提供了另一种持久化模型:先声明 Java 对象怎样映射到表,再通过持久化上下文管理对象状态。本章只完成 health_check_history 的实体映射并解释运行机制;下一章再使用 Spring Data JPA 创建 Repository。

1. JPA、Hibernate 与 Spring Data

这三个名称经常一起出现,但不是同一个层次:

名称角色当前项目中的作用
Jakarta Persistence(JPA)持久化规范定义 @Entity、@Id、EntityManager 等 API 和行为
Hibernate ORMJPA 实现读取映射、跟踪实体并生成发送给 PostgreSQL 的 SQL
Spring Data JPARepository 抽象根据接口提供常规数据访问实现和查询能力

应用代码主要引用 jakarta.persistence 中的标准注解,真正执行这些规则的是 Hibernate。Spring Data JPA 又建立在 JPA 之上,它不会取代 Hibernate,也不是数据库驱动。

完整调用关系是:

jpa-layers.txt
1
Controller / Service
2
-> Spring Data Repository(下一章)
3
-> JPA EntityManager
4
-> Hibernate ORM
5
-> JDBC 驱动
6
-> PostgreSQL

JPA 并不意味着数据库里不再有 SQL。Hibernate 会根据实体操作和查询生成 SQL;开发者仍然要理解表结构、索引、事务和查询计划,才能判断这些 SQL 是否符合需求。

2. 添加 JPA 依赖与配置

在 pom.xml 中添加 JPA Starter:

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

它提供 Spring Data JPA、Jakarta Persistence API、Hibernate 和 Spring ORM 集成。现有 PostgreSQL 驱动、Flyway 依赖与数据库配置继续保留。当前 Repository 仍在使用 JdbcClient,因此此时也不删除 JDBC Starter。

表结构已经由 Flyway 管理,不让 Hibernate 另建一套结构。在 application.yaml 中增加:

src/main/resources/application.yaml
1
spring:
2
jpa:
3
hibernate:
4
ddl-auto: validate
5
open-in-view: false

ddl-auto: validate 让 Hibernate 在启动时检查实体需要的表和列是否存在,但不创建、删除或修改它们。实际结构变更仍然只能通过新的 Flyway 迁移完成。

open-in-view: false 表示 HTTP 请求离开 Service 的事务边界后,不继续保持持久化上下文。这样能避免 Controller 或 JSON 序列化阶段意外触发数据库查询。当前实体没有关联关系,但在第一次引入 JPA 时就明确边界更容易形成一致结构。

3. 创建历史实体

把实体放在主应用包 cn.usehook.spring 的子包中,Spring Boot 会自动扫描到它:

src/main/java/cn/usehook/spring/health/persistence/HealthCheckHistoryEntity.java
01
package cn.usehook.spring.health.persistence;
02
03
import jakarta.persistence.Column;
04
import jakarta.persistence.Entity;
05
import jakarta.persistence.GeneratedValue;
06
import jakarta.persistence.GenerationType;
07
import jakarta.persistence.Id;
08
import jakarta.persistence.Table;
09
import java.time.Instant;
10
import java.util.Objects;
11
12
@Entity
13
@Table(name = "health_check_history")
14
public class HealthCheckHistoryEntity {
15
16
@Id
17
@GeneratedValue(strategy = GenerationType.IDENTITY)
18
private Long id;
19
20
@Column(nullable = false, length = 50)
21
private String service;
22
23
@Column(nullable = false, length = 20)
24
private String status;
25
26
@Column(name = "checked_at", nullable = false, updatable = false)
27
private Instant checkedAt;
28
29
protected HealthCheckHistoryEntity() {}
30
31
private HealthCheckHistoryEntity(String service, String status, Instant checkedAt) {
32
this.service = Objects.requireNonNull(service, "service must not be null");
33
this.status = Objects.requireNonNull(status, "status must not be null");
34
this.checkedAt = Objects.requireNonNull(checkedAt, "checkedAt must not be null");
35
}
36
37
public static HealthCheckHistoryEntity create(String service, String status) {
38
return new HealthCheckHistoryEntity(service, status, Instant.now());
39
}
40
41
public Long getId() {
42
return id;
43
}
44
45
public String getService() {
46
return service;
47
}
48
49
public String getStatus() {
50
return status;
51
}
52
53
public Instant getCheckedAt() {
54
return checkedAt;
55
}
56
}

@Entity 把类声明为 JPA 实体,@Table 指定它对应的实际表名。@Entity 的名称主要用于 JPQL,而不是用来替代数据库表名。

映射注解放在字段上,因此 JPA 使用字段访问:Hibernate 可以直接写入私有字段,不要求为每个属性创建 setter。类仍然需要一个无参数构造器供持久化实现重建对象,使用 protected 可以满足要求,又不会把它变成普通业务代码的创建入口。

这里没有使用 record。实体具有由持久化上下文管理的生命周期,主键也会在写入后生成;普通类更自然地表达这种身份和状态变化。对外请求、响应和不可变业务值仍然可以继续使用 record。

4. 逐项理解字段映射

实体字段与 Flyway 创建的列一一对应:

Java 字段映射PostgreSQL 列说明
id@Id、IDENTITYidentity bigint数据库插入时生成主键
service长度 50、非空varchar(50) not null服务名称
status长度 20、非空varchar(20) not null检查状态
checkedAt不可更新、非空timestamptz not null检查发生时间

id 使用 Long 而不是 long,因为新建但尚未保存的实体还没有数据库身份,此时 id 是 null。保存后 PostgreSQL 生成值,Hibernate 再把它写回实体。

GenerationType.IDENTITY 与表中的 generated always as identity 对应。它不是随机编号,也不是 Java 在内存里计算的值;执行插入时必须与数据库交互才能取得主键。

checkedAt 使用 Instant 表达时间线上的一个确定时刻,Hibernate 会把它映射到 PostgreSQL 的 timestamptz。工厂方法在创建实体时提供时间,因此 JPA 写入路径不会依赖列默认值;数据库上的 default current_timestamp 仍可为手写 SQL 等未显式传值的写入提供默认值。

length、nullable 和 updatable 描述映射意图,但它们不能取代数据库约束和 Flyway 迁移。例如 nullable = false 不等同于输入校验,真正阻止空值落库的最后边界仍是数据库的 NOT NULL。

5. 持久化上下文与实体状态

EntityManager 是 JPA 操作持久化上下文的主要接口。持久化上下文可以理解为当前工作单元内的实体管理区,同一个实体会经历不同状态:

状态含义
瞬时 transient由 new 或工厂方法创建,还未交给 JPA 管理
托管 managed已进入当前持久化上下文,Hibernate 会跟踪它
游离 detached曾被管理,但当前持久化上下文已经结束或实体已脱离
删除 removed已标记删除,将在刷新时执行删除

下面的代码展示一次保存过程:

entity-manager-example.java
1
HealthCheckHistoryEntity entity = HealthCheckHistoryEntity.create("spring", "UP");
2
3
entityManager.persist(entity);
4
entityManager.flush();

创建后实体处于瞬时状态,persist() 让它成为托管状态。flush() 把当前持久化上下文中待执行的变化同步到数据库连接,但不等于提交事务;如果后续发生异常,已经执行的 SQL 仍然可以随事务回滚。

对托管实体字段的修改通常不需要显式调用更新方法。Hibernate 会在刷新时比较状态并生成 UPDATE,这称为脏检查。历史记录在当前业务中是追加后不修改的事实,所以实体没有提供公开 setter;最新状态实体则可能需要受控的更新方法。

不要把实体对象跨越层级长期保存,也不要依赖事务结束后的延迟加载。Service 应该在事务内完成查询和业务操作,再把需要的数据转换成业务模型或 DTO。

6. 用真实数据库验证映射

沿用「Testcontainers 集成测试」中的容器配置,通过 EntityManager 验证映射与 Flyway 表结构能够配合工作:

src/test/java/cn/usehook/spring/health/persistence/HealthCheckHistoryEntityTests.java
01
package cn.usehook.spring.health.persistence;
02
03
import cn.usehook.spring.TestcontainersConfiguration;
04
import jakarta.persistence.EntityManager;
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 HealthCheckHistoryEntityTests {
17
18
private final EntityManager entityManager;
19
20
@Autowired
21
HealthCheckHistoryEntityTests(EntityManager entityManager) {
22
this.entityManager = entityManager;
23
}
24
25
@Test
26
void persistsAndReadsEntity() {
27
HealthCheckHistoryEntity entity = HealthCheckHistoryEntity.create("jpa", "UP");
28
29
entityManager.persist(entity);
30
entityManager.flush();
31
entityManager.clear();
32
33
HealthCheckHistoryEntity saved = entityManager.find(
34
HealthCheckHistoryEntity.class,
35
entity.getId()
36
);
37
38
assertThat(entity.getId()).isNotNull();
39
assertThat(saved.getService()).isEqualTo("jpa");
40
assertThat(saved.getStatus()).isEqualTo("UP");
41
assertThat(saved.getCheckedAt()).isNotNull();
42
}
43
}

flush() 强制插入 SQL 在断言前执行,映射或约束错误不会拖到测试结束才暴露。clear() 清空当前持久化上下文,后面的 find() 必须重新访问数据库,而不是直接返回内存中原来的对象。

测试仍然在方法结束后回滚,不污染其他用例。应用启动时的 ddl-auto: validate 负责检查整体结构,这个测试则进一步确认实体能真实写入和读回。完成这层映射后,下一章才有足够基础把直接使用 EntityManager 的样板代码交给 Spring Data JPA Repository。