本节目标:把
record Book改造成 JPA 实体,掌握@Entity/@Id/@GeneratedValue/@Column/@Table,理解主键生成策略与「实体不能用 record」的原因,并抽出BookRepository完成第一次保存与查询。
适用版本:Spring Boot 4.1.x(Java 21)
12.2 实体与 Repository
上一节把数据源接通了,但 Book 目前只是一个 record,数据库根本不知道它对应哪张表。要让 Hibernate 接管持久化,我们需要两样东西:一个标注了映射信息的实体类,和一个 Spring Data 生成的仓库接口。本节先把它们建起来,用 H2 跑通一次完整的保存与查询;派生查询的细节留到 12.3。
12.2.1 从 record 到 @Entity
先把 Book 改写成一个实体类:
package com.example.bookstore.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "book")
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 200)
private String title;
@Column(nullable = false, length = 100)
private String author;
@Column(nullable = false, unique = true, length = 20)
private String isbn;
@Column(name = "published_year")
private int publishedYear;
protected Book() {
// JPA 需要无参构造器,用 protected 避免业务代码误用
}
public Book(String title, String author, String isbn, int publishedYear) {
this.title = title;
this.author = author;
this.isbn = isbn;
this.publishedYear = publishedYear;
}
// getter / setter 略
}
逐个看这些注解:
| 注解 | 作用 | 缺省时的行为 |
|---|---|---|
@Entity | 声明这是一个受管理的实体 | 不写则 Hibernate 完全无视这个类 |
@Table(name = "book") | 指定表名 | 默认用类名,Book → 表名 book |
@Id | 标记主键字段 | 必填,缺了启动直接报错 |
@GeneratedValue | 主键由数据库/提供者生成 | 不写则主键必须手工赋值 |
@Column | 列名、长度、可空、唯一等约束 | 默认用字段名做列名,小驼峰转下划线 |
@Transient | 排除该字段,不映射成列 | 不写则所有字段都参与映射 |
两个容易忽略的点。第一,字段名转列名的规则:Hibernate 默认把 publishedYear 映射成 published_year,所以上面那句 @Column(name = "published_year") 其实是显式写出默认值,写上只是为了可读。第二,属性访问 vs 字段访问:@Id 放在字段上就是字段访问,放在 getter 上就是属性访问;同一个实体里只能选一种,混用会导致难以定位的映射错误。
12.2.2 主键生成策略怎么选
@GeneratedValue 的 strategy 决定主键从哪来。四种常见选择:
| 策略 | 生成方式 | 优点 | 代价 | 适用 |
|---|---|---|---|---|
IDENTITY | 数据库自增列 | 简单、直观 | 每次插入后要回读主键,不能批量插入 | MySQL、SQL Server |
SEQUENCE | 数据库序列 | 可预分配、支持批量插入 | 需要序列对象 | PostgreSQL、Oracle、H2 |
UUID | 应用侧生成 UUID | 全局唯一、无需回读 | 占空间、索引不友好 | 分布式、多库合并 |
AUTO | 交给提供者决定 | 跨库可移植 | 行为随库变化,不透明 | 想省心的小项目 |
// 数据库自增
@GeneratedValue(strategy = GenerationType.IDENTITY)
// 使用名为 book_seq 的序列
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "book_seq")
@SequenceGenerator(name = "book_seq", sequenceName = "book_seq", allocationSize = 50)
// 应用侧生成 UUID
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
选择依据很简单:库是 MySQL 就用 IDENTITY;库支持序列且你会做批量插入,就用 SEQUENCE 并配一个合理的 allocationSize;主键要在多个库、多个服务间全局唯一,用 UUID。AUTO 看似万能,实则把决定权交给了 Hibernate——它在支持序列的库上倾向用序列,在不支持的库上退回表生成器,行为不透明,生产代码里建议显式写死。
12.2.3 为什么实体不能用 record
上一节我们的 Book 是个 record,很简洁,为什么实体不能继续用?因为 JPA 对实体的要求恰好和 record 的语义冲突:
- record 字段是
final的。JPA 需要在加载数据时把列值「填」进对象,final 字段无法在构造后赋值(反射强行赋值既脆弱又不符合规范)。 - record 没有无参构造器。Hibernate 从数据库读取一行时,先要能创建实例、再逐个设值;它要求实体有一个可访问的无参构造器。
- record 没有 setter,且不可变。JPA 的脏检查机制依赖「读出对象、修改字段、提交时比对」;不可变对象没有可修改的字段,脏检查无从谈起。
所以实体必须是可变的普通类。而 record 恰恰适合它该待的地方——DTO。入参、出参这些跨层传输的数据本来就该不可变,用 record 描述最贴切:
package com.example.bookstore.web;
public record BookResponse(
Long id,
String title,
String author,
String isbn,
int publishedYear) {
public static BookResponse from(Book book) {
return new BookResponse(
book.getId(),
book.getTitle(),
book.getAuthor(),
book.getIsbn(),
book.getPublishedYear());
}
}
一句话记住:实体是「被 ORM 操纵的可变对象」,DTO 是「跨边界传递的不可变值」,两者职责不同,不要混用同一个类型。
12.2.4 4.x 注意:@EntityScan 的包位置变了
默认情况下,Spring Boot 从主类所在包及其子包扫描实体,通常不需要显式配置。但如果你把实体放在了主类包之外,就需要 @EntityScan。4.x 里这个注解换了包:
// 4.x
import org.springframework.boot.persistence.autoconfigure.EntityScan;
// 3.x(旧位置,4.x 已迁移)
// import org.springframework.boot.autoconfigure.domain.EntityScan
@SpringBootApplication
@EntityScan("com.example.shared.model")
public class BookstoreApplication {
public static void main(String[] args) {
SpringApplication.run(BookstoreApplication.class, args);
}
}
这类「包迁移」是 4.0 模块化重构的副作用:原来的 spring-boot-autoconfigure 大模块被拆细,注解跟着搬到了 org.springframework.boot.<technology> 下。照抄 3.x 的 import 会直接编译失败,改过来即可。
12.2.5 Repository 的四种形态
Spring Data 的价值在于:你只声明接口,它生成实现。仓库接口有几种形态,继承关系如下:
Repository<T, ID> (标记接口,无方法)
└── CrudRepository<T, ID> (save/findById/delete/count...)
└── ListCrudRepository<T, ID>(把返回 Iterable 的方法改成返回 List)
└── JpaRepository<T, ID>(外加 flush、saveAndFlush、批量删除)
PagingAndSortingRepository<T, ID> (分页与排序,独立分支)
日常选择很简单:
| 接口 | 包含什么 | 什么时候用 |
|---|---|---|
CrudRepository | 基础增删改查,集合返回 Iterable | 很少直接用,因为 Iterable 不好使 |
ListCrudRepository | 同 CrudRepository,但返回 List | 只需要基础 CRUD,不想引入 JPA 专有 API |
JpaRepository | 上面全部 + flush、deleteAllInBatch | 绝大多数场景 |
PagingAndSortingRepository | findAll(Pageable)、findAll(Sort) | 需要分页排序,通常与上面组合 |
我们的图书仓库直接继承 JpaRepository:
package com.example.bookstore.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import com.example.bookstore.domain.Book;
public interface BookRepository extends JpaRepository<Book, Long> {
}
就这几行,save、findById、findAll、deleteById、count、分页查询全部可用了——它们由 Spring Data 在启动时生成代理实现。
12.2.6 为什么 @Repository 可以省略
很多教程会给仓库接口加上 @Repository。但在 Spring Data 里,这个注解是可省的:
@Repository的本来职责,是让 Spring 的PersistenceExceptionTranslationPostProcessor把 DAO 抛出的原生异常翻译成DataAccessException。这一机制针对的是你自己写的@Component式 DAO。- Spring Data 生成的仓库代理已经内置了异常翻译,无论你有没有加
@Repository。 - 仓库接口的 Bean 是由
@EnableJpaRepositories(@SpringBootApplication已隐含启用)扫描注册的,不依赖@Repository这个 stereotype。
所以写 interface BookRepository extends JpaRepository<...> 就够了。加了也无害,但会让初学者误以为「不加就注册不了」。
12.2.7 实体的 equals 与 hashCode
实体放进 Set、或者从会话里查两次做比较时,equals/hashCode 的实现方式会直接影响结果。三种做法:
| 做法 | 问题 | 结论 |
|---|---|---|
用默认的 Object 实现 | 同一行的两个实例不相等 | 只在「一次会话内」够用 |
用自增 id | 未持久化时 id 为 null,放进 Set 后 id 变化会导致找不到 | 有陷阱 |
用业务唯一键(如 isbn) | 需要保证业务键真的唯一且不变 | 推荐 |
推荐基于业务唯一键实现:
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof Book other)) {
return false;
}
return isbn != null && isbn.equals(other.isbn);
}
@Override
public int hashCode() {
return isbn == null ? 0 : isbn.hashCode();
}
核心原则是用于 equals 的字段在对象生命周期内必须稳定。自增 id 在 save 之前是 null、之后才有值,用它算 hashCode 会让对象在放进 HashSet 后「消失」。所以要么用稳定的业务键,要么干脆用 id 但约定「未持久化对象不参与集合运算」。
12.2.8 H2 实测:一次完整的保存与查询
把实体和仓库接起来,写一个 CommandLineRunner 在启动时跑一遍:
package com.example.bookstore;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import com.example.bookstore.domain.Book;
import com.example.bookstore.repository.BookRepository;
@Component
public class DataSeeder implements CommandLineRunner {
private final BookRepository repository;
public DataSeeder(BookRepository repository) {
this.repository = repository;
}
@Override
public void run(String... args) {
Book saved = repository.save(
new Book("Effective Java", "Joshua Bloch", "978-0134685991", 2018));
System.out.println("保存成功,主键 = " + saved.getId());
repository.findById(saved.getId())
.ifPresent(b -> System.out.println("查到: " + b.getTitle()));
System.out.println("总记录数 = " + repository.count());
}
}
启动应用,控制台会输出(配合上一节的 show-sql: true,还能看到 SQL):
Hibernate: insert into book (author,isbn,published_year,title) values (?,?,?,?)
保存成功,主键 = 1
Hibernate: select ... from book where id=?
查到: Effective Java
总记录数 = 1
注意 insert 语句里没有 id 列——因为 IDENTITY 策略下主键由数据库生成,Hibernate 插入后再回读。这也解释了为什么 IDENTITY 无法批量插入:每插一行都得等数据库返回主键,攒不了批。
小结
本节把领域模型真正变成了持久化对象:
- 实体是可变普通类,需要无参构造器;
record不可变、无 setter,与 JPA 的脏检查机制冲突,只适合做 DTO。 - 主键策略按库选:MySQL 用
IDENTITY,支持序列且要批量插入用SEQUENCE,要全局唯一用UUID,少用AUTO。 - 仓库直接继承
JpaRepository即可,@Repository可省略,因为 Spring Data 代理已内置异常翻译。 - 实体的
equals/hashCode应基于稳定的业务唯一键,避免用尚未生成的id。 - 4.x 里
@EntityScan已迁到org.springframework.boot.persistence.autoconfigure。
实体和仓库就位后,save/findById 能用了,但「按作者查」「按年份区间查」还写不出来。下一节讲派生查询,把这些方法名变成 SQL。
阅读导航:上一节:12.1 数据源与连接配置 · 下一节:12.3 派生查询方法 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。