《Spring Boot 入门》12.2 实体与 Repository

本节把 record 版 Book 改造成 JPA 实体:讲透 @Entity、@Id、@GeneratedValue、@Column、@Table 与主键生成策略的取舍,说清为什么实体不能用 record 而 record 适合做 DTO,梳理 CrudRepository 与 JpaRepository 的继承关系,并给出实体 equals/hashCode 的可靠写法。

本节目标:把 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 的语义冲突:

  1. record 字段是 final 的。JPA 需要在加载数据时把列值「填」进对象,final 字段无法在构造后赋值(反射强行赋值既脆弱又不符合规范)。
  2. record 没有无参构造器。Hibernate 从数据库读取一行时,先要能创建实例、再逐个设值;它要求实体有一个可访问的无参构造器。
  3. 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绝大多数场景
PagingAndSortingRepositoryfindAll(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 派生查询方法 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计