前几章使用 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 ORM | JPA 实现 | 读取映射、跟踪实体并生成发送给 PostgreSQL 的 SQL |
| Spring Data JPA | Repository 抽象 | 根据接口提供常规数据访问实现和查询能力 |
应用代码主要引用 jakarta.persistence 中的标准注解,真正执行这些规则的是 Hibernate。Spring Data JPA 又建立在 JPA 之上,它不会取代 Hibernate,也不是数据库驱动。
完整调用关系是:
1Controller / Service2-> Spring Data Repository(下一章)3-> JPA EntityManager4-> Hibernate ORM5-> JDBC 驱动6-> PostgreSQL
JPA 并不意味着数据库里不再有 SQL。Hibernate 会根据实体操作和查询生成 SQL;开发者仍然要理解表结构、索引、事务和查询计划,才能判断这些 SQL 是否符合需求。
2. 添加 JPA 依赖与配置
在 pom.xml 中添加 JPA Starter:
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 中增加:
1spring:2jpa:3hibernate:4ddl-auto: validate5open-in-view: false
ddl-auto: validate 让 Hibernate 在启动时检查实体需要的表和列是否存在,但不创建、删除或修改它们。实际结构变更仍然只能通过新的 Flyway 迁移完成。
open-in-view: false 表示 HTTP 请求离开 Service 的事务边界后,不继续保持持久化上下文。这样能避免 Controller 或 JSON 序列化阶段意外触发数据库查询。当前实体没有关联关系,但在第一次引入 JPA 时就明确边界更容易形成一致结构。
3. 创建历史实体
把实体放在主应用包 cn.usehook.spring 的子包中,Spring Boot 会自动扫描到它:
01package cn.usehook.spring.health.persistence;0203import jakarta.persistence.Column;04import jakarta.persistence.Entity;05import jakarta.persistence.GeneratedValue;06import jakarta.persistence.GenerationType;07import jakarta.persistence.Id;08import jakarta.persistence.Table;09import java.time.Instant;10import java.util.Objects;1112@Entity13@Table(name = "health_check_history")14public class HealthCheckHistoryEntity {1516@Id17@GeneratedValue(strategy = GenerationType.IDENTITY)18private Long id;1920@Column(nullable = false, length = 50)21private String service;2223@Column(nullable = false, length = 20)24private String status;2526@Column(name = "checked_at", nullable = false, updatable = false)27private Instant checkedAt;2829protected HealthCheckHistoryEntity() {}3031private HealthCheckHistoryEntity(String service, String status, Instant checkedAt) {32this.service = Objects.requireNonNull(service, "service must not be null");33this.status = Objects.requireNonNull(status, "status must not be null");34this.checkedAt = Objects.requireNonNull(checkedAt, "checkedAt must not be null");35}3637public static HealthCheckHistoryEntity create(String service, String status) {38return new HealthCheckHistoryEntity(service, status, Instant.now());39}4041public Long getId() {42return id;43}4445public String getService() {46return service;47}4849public String getStatus() {50return status;51}5253public Instant getCheckedAt() {54return checkedAt;55}56}
@Entity 把类声明为 JPA 实体,@Table 指定它对应的实际表名。@Entity 的名称主要用于 JPQL,而不是用来替代数据库表名。
映射注解放在字段上,因此 JPA 使用字段访问:Hibernate 可以直接写入私有字段,不要求为每个属性创建 setter。类仍然需要一个无参数构造器供持久化实现重建对象,使用 protected 可以满足要求,又不会把它变成普通业务代码的创建入口。
这里没有使用 record。实体具有由持久化上下文管理的生命周期,主键也会在写入后生成;普通类更自然地表达这种身份和状态变化。对外请求、响应和不可变业务值仍然可以继续使用 record。
4. 逐项理解字段映射
实体字段与 Flyway 创建的列一一对应:
| Java 字段 | 映射 | PostgreSQL 列 | 说明 |
|---|---|---|---|
id | @Id、IDENTITY | identity 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 | 已标记删除,将在刷新时执行删除 |
下面的代码展示一次保存过程:
1HealthCheckHistoryEntity entity = HealthCheckHistoryEntity.create("spring", "UP");23entityManager.persist(entity);4entityManager.flush();
创建后实体处于瞬时状态,persist() 让它成为托管状态。flush() 把当前持久化上下文中待执行的变化同步到数据库连接,但不等于提交事务;如果后续发生异常,已经执行的 SQL 仍然可以随事务回滚。
对托管实体字段的修改通常不需要显式调用更新方法。Hibernate 会在刷新时比较状态并生成 UPDATE,这称为脏检查。历史记录在当前业务中是追加后不修改的事实,所以实体没有提供公开 setter;最新状态实体则可能需要受控的更新方法。
不要把实体对象跨越层级长期保存,也不要依赖事务结束后的延迟加载。Service 应该在事务内完成查询和业务操作,再把需要的数据转换成业务模型或 DTO。
6. 用真实数据库验证映射
沿用「Testcontainers 集成测试」中的容器配置,通过 EntityManager 验证映射与 Flyway 表结构能够配合工作:
01package cn.usehook.spring.health.persistence;0203import cn.usehook.spring.TestcontainersConfiguration;04import jakarta.persistence.EntityManager;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 HealthCheckHistoryEntityTests {1718private final EntityManager entityManager;1920@Autowired21HealthCheckHistoryEntityTests(EntityManager entityManager) {22this.entityManager = entityManager;23}2425@Test26void persistsAndReadsEntity() {27HealthCheckHistoryEntity entity = HealthCheckHistoryEntity.create("jpa", "UP");2829entityManager.persist(entity);30entityManager.flush();31entityManager.clear();3233HealthCheckHistoryEntity saved = entityManager.find(34HealthCheckHistoryEntity.class,35entity.getId()36);3738assertThat(entity.getId()).isNotNull();39assertThat(saved.getService()).isEqualTo("jpa");40assertThat(saved.getStatus()).isEqualTo("UP");41assertThat(saved.getCheckedAt()).isNotNull();42}43}
flush() 强制插入 SQL 在断言前执行,映射或约束错误不会拖到测试结束才暴露。clear() 清空当前持久化上下文,后面的 find() 必须重新访问数据库,而不是直接返回内存中原来的对象。
测试仍然在方法结束后回滚,不污染其他用例。应用启动时的 ddl-auto: validate 负责检查整体结构,这个测试则进一步确认实体能真实写入和读回。完成这层映射后,下一章才有足够基础把直接使用 EntityManager 的样板代码交给 Spring Data JPA Repository。