JPA 学习笔记 7:高级内容

JPA 学习笔记 7:高级内容

通过前6章的学习,我们已经可以使用 Hibernate/JPA/Spring Data JPA 完成对数据库的访问,但在细节上,Hibernate/JPA 提供一些更多的功能。

@Basic

ID 属性以外的持久化属性可以用@Basic注解标注:

java 复制代码
@Entity
@Data
@NoArgsConstructor
public class Car {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Basic
    private String brand;
    @NaturalId
    @Basic
    private String engineCode;
    // ...
}

但通常@Basic注解会被省略,这些属性会被默认为实体的持久化属性:

java 复制代码
@Entity
@Data
@NoArgsConstructor
public class Car {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String brand;
    @NaturalId
    private String engineCode;
	// ...
}

@Basic的最常见用途是标注一个属性是非 Null 的:

java 复制代码
@NaturalId
@Basic(optional = false)
private String engineCode;

此时执行测试:

java 复制代码
Car car = new Car();
car.setBrand("奔驰");
entityManager.persist(car);

会报错,Hibernate 会在执行 SQL 前检查相应的属性是否为 NULL。

当然也可以使用@Column(nullable = false),但它们是有区别的,@Column仅表示实体映射到数据库中的结构,不约束实体本身,换言之这样设置后如果相应字段为 NULL,报错会发生在数据库 SQL 执行时。

如果要定义非 NULL 实体属性,一个更好的做法是使用 Hibernate Validator 的@NotNull注解。

需要添加 Hibernate Validator 依赖:

xml 复制代码
<!-- Jakarta Expression Language (EL) -->
<dependency>
    <groupId>org.glassfish</groupId>
    <artifactId>jakarta.el</artifactId>
    <version>4.0.2</version>
</dependency>
<!-- 确保 Hibernate Validator 版本兼容 -->
<dependency>
    <groupId>org.hibernate.validator</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>8.0.1.Final</version>
</dependency>

使用@NotNull注解标注非Null持久属性:

java 复制代码
@NaturalId
@NotNull
private String engineCode;

Version 属性

可以通过 Version 属性为实体开启乐观锁。

先看一个示例:

测试用的实体类:

java 复制代码
@Entity
@Data
@NoArgsConstructor
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(length = 10, nullable = false)
    private String name;
    @Column(columnDefinition = "decimal(10,2)")
    private BigDecimal price;

    public Book(String name, BigDecimal price) {
        this.name = name;
        this.price = price;
    }
}

测试用例:

java 复制代码
// 先准备一些数据
Book book = new Book("MySQL 应知应会", new BigDecimal("10.0"));
entityManager.persist(book);
transaction.commit();
transaction.begin();
Book book1 = entityManager.find(Book.class, book.getId());
book1.setPrice(new BigDecimal("20.0"));
// 模拟在某个地方被其他线程修改了
CountDownLatch  countDownLatch = new CountDownLatch(1);
new Thread(() -> {
    transaction.commit();
    entityManager = entityManagerFactory.createEntityManager();
    transaction = entityManager.getTransaction();
    transaction.begin();
    Book book3 = entityManager.find(Book.class, book.getId());
    book3.setPrice(new BigDecimal("40.0"));
    entityManager.merge(book3);
    transaction.commit();
    transaction.begin();
    System.out.println("另外一个线程修改了数据");
    countDownLatch.countDown();
}).start();
countDownLatch.await();
entityManager.merge(book1);
System.out.println("当前线程修改了数据");

当前线程读取数据后,修改了某些属性,在提交修改到数据库前,其它线程读取并修改了该行数据。此时当前线程再提交修改就会导致其它线程的修改丢失了。

这种问题本质上是并发时修改相同的共享资源,可以通过加锁(或者分布式锁)来解决,JPA 也提供一个 Version 属性,可以简单地在表上实现一个乐观锁。

java 复制代码
@Entity
@Data
@NoArgsConstructor
public class Book {
    // ...
    @Version
    @Column(columnDefinition = "int unsigned")
    private Integer version;
	// ...
}

这里的 version 字段初始默认是0,每次执行 UPDATE 语句后会自增。此时再通过 JPA 更新数据时会检查 version,比如:

复制代码
Hibernate: 
    update
        Book 
    set
        name=?,
        price=?,
        version=? 
    where
        id=? 
        and version=?

如果 version 与读取时的 version 不匹配,就会报错:

复制代码
jakarta.persistence.OptimisticLockException: Row was already updated or deleted by another transaction for entity [cn.icexmoon.entity.Book with id '13']

Hibernate 官方建议对经常修改的实体都应该设置 version 属性。

Version 属性除了可以使用常见的 int 类型,还可以使用时间等类型,具体可以参考官方文档

枚举类型

JPA 使用 @Enumerated 注解标记枚举类型的实体属性:

java 复制代码
@Entity
@Data
@NoArgsConstructor
public class Car {
	// ...
    @Enumerated
    private Color color;
	// ...
}

默认这些属性将以枚举实例的ordinal()方法的值(定义的顺序)存储,可以改变为使用枚举实例的名称存储:

java 复制代码
@Enumerated(EnumType.STRING)
private Color color;

除此之外,也可以使用枚举实例中自定义的属性值进行存储:

java 复制代码
public enum Color{
    Yellow(1),Red(2),Blue(3);
    @EnumeratedValue
    final int value;
    Color(int value){
        this.value = value;
    }
}

实体类继承

实体类之间可以继承,没有继承任意实体类的实体类被称作根实体(Root Entity)。根实体必须有 Id 属性,且可以是普通类或抽象类。

比如可以将每个表都有的通用字段定义在根实体中:

java 复制代码
@Data
@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public abstract class RootEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.TABLE)
    private Long id;
    private Date createTime;
    @Column(length = 10)
    private String createUser;
    private Date updateTime;
    @Column(length = 10)
    private String updateUser;
}

根实体必须定义 ID 属性,子实体:

java 复制代码
@Table(name = "child")
@Entity
@Data
@NoArgsConstructor
public class ChildEntity extends RootEntity {
    private String name;
    public ChildEntity(String name) {
        this.name = name;
    }
}

子实体中不能有 ID 属性。这种实体互相继承存在一些限制,比如如果子实体的表拥有全部独立字段,就需要在根实体中使用@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS),且此时的 ID 属性生成策略不能是@GeneratedValue(strategy = GenerationType.IDENTITY)

更推荐的方式是将通用字段定义在一个非实体的普通类中:

java 复制代码
@Data
@MappedSuperclass
public abstract class BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private Date createTime;
    @Column(length = 10)
    private String createUser;
    private Date updateTime;
    @Column(length = 10)
    private String updateUser;
}

@MappedSuperclass注解表明子实体类可以继承基类的映射关系(包括主键)。

实体类直接继承这个普通类:

java 复制代码
@Table(name = "school")
@Entity
@Getter
@Setter
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@NoArgsConstructor
public class School extends BaseEntity{
    private String name;

    public School(String name) {
        this.name = name;
    }
}

继承策略

有三种不同的继承策略:

  • SINGLE_TABLE,所有继承层次上的实体都映射到同一张表上保存,用某个字段进行区分
  • JOINED,将继承层次上的实体映射到单独的表,但每张表只保存实体声明的属性(不包含继承的属性)
  • TABLE_PER_CLASS,将继承层次上的实体映射到单独的表,每张表包含全部的属性

使用SINGLE_TABLE策略继承实体的示例:

java 复制代码
@Entity
@Data
@NoArgsConstructor
@Table(name = "person_v2")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(discriminatorType = DiscriminatorType.CHAR, name = "kind")
@DiscriminatorValue("P")
public class Person2 {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(length = 20)
    private String name;
    private Integer age;
}
java 复制代码
@Entity
@Data
@NoArgsConstructor
@DiscriminatorValue("A")
public class Author2 extends Person2 {
    private String penName;
}

在根实体上,使用@Inheritance定义继承策略,在这里是可选的,因为默认的继承策略就是SINGLE_TABLE

当继承策略是SINGLE_TABLE时,必须使用@DiscriminatorColumn注解指定同一张表中区分不同实体数据的字段类型和字段名称。@DiscriminatorValue注解指定所在实体在区分列中的值。

子实体中只需要使用@DiscriminatorValue注解,因为是同一张表,子实体不需要定义@Id属性,以及不能使用@Table

最终生成的表和测试数据:

使用JOINED继承策略的示例:

java 复制代码
@Entity
@Data
@NoArgsConstructor
@Table(name = "person_v3")
@Inheritance(strategy = InheritanceType.JOINED)
public class Person3 {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(length = 20)
    private String name;
    private Integer age;
}
java 复制代码
@Entity
@Data
@NoArgsConstructor
@Table(name = "author_v3")
public class Author3 extends Person3{
    private String penName;
}

根实体生成的表person_v3

子实体生成的表author_v3

TABLE_PER_CLASS 的继承策略示例之前已经说过了,这里不再赘述。这种方式不推荐,应当考虑继承普通类并使用@MappedSuperclass注解。

自定义类型转换

可以自定义类型转换器以处理更多 Hibernate 不支持的实体属性类型:

java 复制代码
@Converter(autoApply = true)
public class EnumSetConverter
        // converts Java values of type EnumSet<DayOfWeek> to integers for storage in an INT column
        implements AttributeConverter<EnumSet<DayOfWeek>,Integer> {
    @Override
    public Integer convertToDatabaseColumn(EnumSet<DayOfWeek> enumSet) {
        int encoded = 0;
        var values = DayOfWeek.values();
        for (int i = 0; i<values.length; i++) {
            if (enumSet.contains(values[i])) {
                encoded |= 1<<i;
            }
        }
        return encoded;
    }

    @Override
    public EnumSet<DayOfWeek> convertToEntityAttribute(Integer encoded) {
        var set = EnumSet.noneOf(DayOfWeek.class);
        var values = DayOfWeek.values();
        for (int i = 0; i<values.length; i++) {
            if (((1<<i) & encoded) != 0) {
                set.add(values[i]);
            }
        }
        return set;
    }
}

因为类型转换器类使用了@Converter(autoApply = true)注解,因此只要这个类注册到 JPA 中,任何实体类中的EnumSet<DayOfWeek>类型的持久属性都会使用这个类型转换器。

java 复制代码
@Entity
@Data
public class Week {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private EnumSet<DayOfWeek> workDays;
    private EnumSet<DayOfWeek> holidays;
}

也可以不使用@Converter(autoApply = true),转而在实体属性上指定使用的类型转换器:

java 复制代码
@Convert(converter = EnumSetConverter.class)
private EnumSet<DayOfWeek> workDays;
@Convert(converter = EnumSetConverter.class)
private EnumSet<DayOfWeek> holidays;

JSON

将自定义类型以 JSON 的形式存储和读取是很常见的需求,在 Hibernate 中可以简单通过以下方式实现:

java 复制代码
@Entity
@Data
@NoArgsConstructor
@Table(name = "person")
public class Person {
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public static class Name{
        private String firstName;
        private String lastName;
    }
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @JdbcTypeCode(SqlTypes.JSON)
    private Name name;
	// ...
}

这里对自定义类型的实体属性name使用了@JdbcTypeCode(SqlTypes.JSON)注解,这样做可以显式指定 Hibernate 使用特定的表字段类型映射实体属性。

将一个实体属性映射到 JSON 类型的表字段上时,Hibernate 会自动调用类路径上可用的 JSON 转换中间件(通常时 jackson)进行 JSON 的序列化/反序列化。因此如果没有相关中间件,需要添加:

xml 复制代码
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>${jackson.version}</version>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-core</artifactId>
    <version>${jackson.version}</version>
</dependency>

实际测试发现 hibernate 7.1.x 不能使用最新的 jackson 依赖(>=3.0),否则会报错。

嵌入类型

如果表中某些字段可以映射到一个类型进行组织,可以将其定义为嵌入类型(Embeddable Type),比如:

java 复制代码
@Entity
@Data
@NoArgsConstructor
@Table(name = "person")
public class Person {
    @Embeddable
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public static class Name{
        private String firstName;
        private String lastName;
    }
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private Name name;
    // ...
}

name 属性将映射到表中的多个字段进行存储:

sql 复制代码
create table person
(
    id        bigint auto_increment
        primary key,
    age       int          null,
    firstName varchar(255) null,
    lastName  varchar(255) null
);

这里可以使用record替代静态内部类,进一步简化代码:

java 复制代码
@Entity
@Data
@NoArgsConstructor
@Table(name = "person")
public class Person {
    @Embeddable
    public record Name(String firstName, String lastName) {
    }
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private Name name;
	// ...
}

JSON

如果不想用多个字段保存嵌入类型,也可以 JSON 序列化后用一个字段保存:

java 复制代码
@Entity
@Data
@NoArgsConstructor
@Table(name = "person")
public class Person {
    @Embeddable
    public record Name(String firstName, String lastName) {
    }
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @JdbcTypeCode(SqlTypes.JSON)
    private Name name;
	// ...
}

嵌入类型可以嵌套,即一个嵌入类型中包含其它嵌入类型的属性。嵌入类型不能单独存在,它们依附于实体。

数组

Hibernate(非 JPA)可以将实体中的数组类型的属性存储为表中的一列:

java 复制代码
@Entity
@Data
public class Week2 {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Array(length = 7)
    private DayOfWeek[] workDays;
    @Array(length = 2)
    private DayOfWeek[] holidays;
}

数组元素的类型可以是基本类型或者枚举,映射的目标列类型取决于数据库对 Array 类型的支持,在 MySQL 会使用 JSON 类型的列存储。

ElementCollection

JPA 有一个@ElementCollection注解,可以将标注的基本类型组成的容器属性用额外的表进行保存,并通过外键关联到实体表:

java 复制代码
@Entity
@Data
public class Week3 {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @ElementCollection
    private List<DayOfWeek> workDays;
    @ElementCollection
    private List<DayOfWeek> holidays;
}

不过这种方式局限性很大,不推荐使用。

数据库映射

可以使用一系列注解将实体映射到数据库。

表映射

使用@Table可以很容易地将一个实体类映射到数据库表,比较有趣的是,可以通过@SecondTable将表中的某些属性映射到其他的表中:

java 复制代码
@Entity
@Data
@Table(name = "customer2")
@SecondaryTable(name = "customer_address2", pkJoinColumns = @PrimaryKeyJoinColumn(name = "id"))
public class Customer2 {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    @Column(table = "customer_address2")
    private String city;
    @Column(table = "customer_address2")
    private String Address;
}

@SecondaryTable指定了额外的表存储实体类中的特殊属性(这些属性通过@Column(table=...)指定),其属性name指定额外表的表名,pkJoinColumns指定外键关联。

可以用@SecondaryTable定义多个额外的从表:

java 复制代码
@Entity
@Data
@Table(name = "customer3")
@SecondaryTables({
        @SecondaryTable(name = "customer_address3", pkJoinColumns = @PrimaryKeyJoinColumn(name = "id")),
        @SecondaryTable(name = "customer_base_info3", pkJoinColumns = @PrimaryKeyJoinColumn(name = "id"))
})
public class Customer3 {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    @Column(table = "customer_address3")
    private String city;
    @Column(table = "customer_address3")
    private String Address;
    @Column(table = "customer_base_info3")
    private String phone;
    @Column(table = "customer_base_info3")
    private Integer age;
}

这种方式在进行竖向表拆分时很有用,可以在不改变实体类的情况下对实体对应的表进行拆分。

字段映射

使用 @Column 注解可以将实体属性映射到表结构的字段。

java 复制代码
@Entity
@Table(name = "book4")
@SecondaryTable(name = "book4_detail", pkJoinColumns = @jakarta.persistence.PrimaryKeyJoinColumn(name = "id"))
@Data
public class Book4 {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotNull
    @Column
    private String name;

    @NotNull
    @NotBlank
    @Column(unique = true)
    private String ISBN;

    @Column(name = "book_desc", table = "book4_detail", length = LONG32)
    private String desc;

    @Min(0)
    @NotNull
    @Column(scale = 2, precision = 10)
    private BigDecimal price;
}

就像上面示例展示的,@Column注解可以结合 Hibernate Validation 的相关注解 以在持久化实体对象的时候进行校验。比如这里的 ISBN 不能为空,单价必须大于零等。

@Column(nullable=false)相比,@NotNull注解是在 JPA (持久层)进行限制,前者是在 JDBC(数据库层)进行限制。

对于高精度的金额计算,Java 中使用 BigDecimal,对应的数据库字段类型是DECIMAL(XX,XX),可以使用@Column(scale=xxx,precision=xxx)表示,比如这里的@Column(scale = 2, precision = 10)表示数据库字段类型为DECIMAL(10,2)

关联表

如果实体之间是多对多关系,就需要通过 @JoinTable 指定一个关联表:

java 复制代码
@Entity
@Data
@Table(name = "course2")
@NoArgsConstructor
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class Course2 {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(unique = true, nullable = false)
    @EqualsAndHashCode.Include
    private String name;
    @ManyToMany(mappedBy = "couses")
    private List<Student2> students;
	// ...
}
java 复制代码
@Entity
@Data
@Table(name = "student2")
@NoArgsConstructor
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class Student2 {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(unique = true, nullable = false)
    @EqualsAndHashCode.Include
    private String name;
    @ManyToMany(cascade = CascadeType.ALL)
    @JoinTable(name = "student_course2", joinColumns = @JoinColumn(name = "student_id"), inverseJoinColumns = @JoinColumn(name = "course_id"))
    List<Course2> couses;
	// ...
}

使用关联表也可以表示一对多、多对一,甚至是一对一关系,虽然没有必要这么做。

关联字段

通常,对于多对一/一对多/一对一关系,需要使用@JoinColumn指定关联字段:

java 复制代码
@Entity
@Data
@Table(name = "classroom3")
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@NoArgsConstructor
public class ClassRoom3 {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(unique = true, nullable = false)
    @EqualsAndHashCode.Include
    private String name;
    @OneToMany(mappedBy = "classRoom", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private List<Student3> students;
	// ...
}
java 复制代码
@Entity
@Data
@Table(name = "student3")
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@NoArgsConstructor
public class Student3 {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(unique = true, nullable = false)
    @EqualsAndHashCode.Include
    private String name;
    @ManyToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    @JoinColumn(name = "classroom_id")
    private ClassRoom3 classRoom;
	// ...
}

上面的示例关联的是主键,所以只需要指定当前实体对应的表的列名,不需要指定被关联方的表的列名。除了关联主键,还可以关联任意表的唯一性字段,比如实体类的自然主键:

java 复制代码
@Entity
@Table(name = "book5")
@Data
public class Book5 {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    @Length(max = 10)
    @NotBlank
    @Column(name = "isbn", unique = true)
    @NaturalId
    private String ISBN;
    // ...
}
java 复制代码
@Entity
@Table(name = "book5_window")
@Data
public class BookWindow5 {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @OneToOne
    @JoinColumn(name = "book_isbn", referencedColumnName = "isbn")
    private Book5 book;
}

JPA 会为这种字段关联生成对应的表外键,键名自动生成,比如FKohltfxt19wyf79etan86nbjco。如果需要指定外键名称,可以:

java 复制代码
@JoinColumn(name = "book_isbn", referencedColumnName = "isbn", foreignKey = @ForeignKey(name = "fk_book_window_book"))

如果需要关联的表是联合主键:

java 复制代码
@Entity
@Data
@Table(name = "book5_history")
public class BookHistory5 {
    @Embeddable
    @Data
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public static class BookHistoryId {
        @Column(name = "book_id")
        private Long bookId;
        @Column(name = "version")
        private Integer version;

        public BookHistoryId(Long bookId, Integer version) {
            this.bookId = bookId;
            this.version = version;
        }
    }

    @EmbeddedId
    private BookHistoryId bookHistoryId;
    // ...
}
java 复制代码
@Entity
@Table(name = "book5_history_detail")
@Data
public class BookHistoryDetail5 {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @OneToOne
    @JoinColumn(name = "book_id", referencedColumnName = "book_id")
    @JoinColumn(name = "version", referencedColumnName = "version")
    private BookHistory5 bookHistory;
    // ...
}

正如前面所说,此时 JPA 自动生成的外键键名并不友好,可以用下面的方式指定键名:

java 复制代码
@JoinColumns(value = {
    @JoinColumn(name = "book_id", referencedColumnName = "book_id"),
    @JoinColumn(name = "version", referencedColumnName = "version")
}, foreignKey = @ForeignKey(name = "fk_book_history_detail_book_history"))
private BookHistory5 bookHistory;

表之间的主键映射

对于实体继承,如果是 JOINED,通常子表和主表使用相同的列名(通常是 id)进行关联,如果子表使用不同的别名,可以通过@PrimaryKeyJoinColumn 进行指定:

java 复制代码
@Entity
@Table(name = "person6")
@Data
@Inheritance(strategy = InheritanceType.JOINED)
public class Person6 {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @NotBlank
    @Length(max = 20)
    @Column(nullable = false, length = 20)
    private String name;
    @Length(max = 50)
    @Column(length = 50)
    private String email;
}
java 复制代码
@Entity
@Table(name = "author6")
@Data
@PrimaryKeyJoinColumn(name = "person_id")
public class Author6 extends Person6 {
    @NotBlank
    @Length(max = 20)
    @Column(length = 20, nullable = false)
    private String penName;
}

在使用@SecondaryTable时,也可以使用@PrimaryKeyJoinColumn映射主键,在前面的表映射一节已经展示过,这里不做赘述。

列长度

对于字符串类型的字段,通过指定@Columnlength属性,JPA 可以自动选择合适的 JDBC 类型创建数据库表字段。Hibernate 也提供一些预设值:

常量 描述
DEFAULT 255 VARCHARVARBINARY 列的默认长度,当未明确指定时
LONG 32600 Hibernate 支持的每个数据库上允许的 VARCHARVARBINARY 的最大列长度
LONG16 32767 使用 16 位可以表示的最大长度(但这个长度对于某些数据库的 VARCHARVARBINARY 列来说太大)
LONG32 2147483647 Java 字符串的最大长度

比如:

java 复制代码
@Entity
@Table(name = "book6")
@Data
public class Book6 {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(length = 20)
    private String name;
    @Column(length = 10)
    private String author;
    @Column(name = "book_desc", length = 200)
    private String desc;
    @Column(name = "detail_desc", length = 2000)
    private String detailDesc;
}

也可以:

java 复制代码
@Entity
@Table(name = "book7")
@Data
public class Book7 {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(length = DEFAULT)
    private String name;
    @Column(length = DEFAULT)
    private String author;
    @Column(name = "book_desc", length = DEFAULT)
    private String desc;
    @Column(name = "detail_desc", length = LONG)
    private String detailDesc;
}

这里的@Column(length = DEFAULT)实际对应的数据库类型是VARCHAR(255)@Column(length = LONG)则对应text

Lobs

对于超长的字符串,MYSQL 的字段类型是longtext(对应 Oracle 中的 CLOB),对于超长的二进制内容,MYSQL 的字段类型是longblob(对应 Oracle 中的 BLOB )。

可以用下面的方式定义这两种类型的字段:

java 复制代码
@Column(name = "long_text", length = LONG32)
private String longText;
@Column(length = LONG32)
private byte[] file;

作为替代方案,还可以:

java 复制代码
@Column(name = "long_text")
private Clob longText;
private Blob file;

这样做的好处是,Clob 和 Blob 类型可以存储和索引的数据长度要大于 String 和 byte[]。

IDE 可能会报错,提示'基本' 特性类型不应为 'Clob',可能是因为这并非 JPA 的标准,而是 Hibernate 支持的类型。

不方便的地方在于,使用这样的类型可能需要在读写时进行类型转换:

java 复制代码
LobHelper lobHelper = Hibernate.getLobHelper();
// 从文本文件读取内容到字符串
String content = FileUtil.readString("D:\\download\\scm-2025-12-09.log", StandardCharsets.UTF_8);
book.setLongText(lobHelper.createClob(content));
byte[] bytes = FileUtil.readBytes("D:\\download\\新建文件夹.zip");
book.setFile(lobHelper.createBlob(bytes));
java 复制代码
String longText = book.getLongText().getSubString(1L, (int) book.getLongText().length());
InputStream binaryStream = book.getFile().getBinaryStream();
FileUtil.writeFromStream(binaryStream, "D:\\download\\新建文件夹3.zip");

映射到公式

有时候,SQL 查询结果中的某些字段不是直接从表中获取,而是需要从查询结果中通过公式计算得出,比如根据金额和税率计算税后金额和税额等。这些可以通过在实体类中使用@Formula实现:

java 复制代码
@Entity
@Table(name = "`order`")
@Data
@ToString
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @NotNull
    @Min(0)
    @Column(name = "total", precision = 10, scale = 2)
    private BigDecimal total; // 税前订单总价
    @NotNull
    @Min(0)
    @Column(name = "tax_rate", precision = 4, scale = 4)
    private BigDecimal taxRate; // 税率
    @Formula("total * tax_rate")
    private BigDecimal tax; // 税
    @Formula("total * (1 + tax_rate)")
    private BigDecimal totalWithTax; // 税后订单总价
}

执行查询时,Hibernate 生成的 SQL 语句:

sqlite 复制代码
 	select
        o1_0.id,
        o1_0.total * o1_0.tax_rate,
        o1_0.tax_rate,
        o1_0.total,
        o1_0.total * (1 + o1_0.tax_rate) 
    from
        `order` o1_0 
    where
        o1_0.id=?

派生标识符

父实体:

java 复制代码
@Entity
@Table(name = "book_category")
@Data
public class BookCategory {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
}

子实体:

java 复制代码
@Entity
@Table(name = "book_sub_category")
@Data
@IdClass(BookSubCategory.BookSubCategoryId.class)
public class BookSubCategory{
    public record BookSubCategoryId(String id, Long bookCategory) {
    }
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private String id;
    @Id
    @ManyToOne
    @JoinColumn(name = "book_category_id")
    private BookCategory bookCategory;
    private String name;
}

子实体使用联合主键,由@IdClass(BookSubCategory.BookSubCategoryId.class)定义,且联合主键中的一列(book_category_id)来自父实体的主键。

  • 需要注意的是,在这种情况下,子实体的主键不能通过自增的方式生成,因此这里使用了 UUID。此外,@IdClass指定的联合主键,是通过其属性名称和子实体类中的属性进行匹配,因此命名需要格外注意。
  • 这个示例并不是很恰当,我看不出这样做的必要性,这里完全可以让子实体类使用单一主键,通过外键关联父实体类,这个例子本身只会让问题变得更复杂。

上面的例子中为了让BookSubCategoryId的属性与BookSubCategory属性一一对应,定义了一个Long bookCategory的属性,多少有点奇怪,可以用@MapsId修改:

java 复制代码
@Entity
@Table(name = "book_sub_category2")
@Data
@IdClass(BookSubCategory2.BookSubCategoryId.class)
public class BookSubCategory2 {
    public record BookSubCategoryId(String id, Long bookCategoryId) {
    }
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private String id;
    @Id
    private Long bookCategoryId;

    @MapsId("bookCategoryId")
    @ManyToOne
    @JoinColumn(name = "book_category_id")
    private BookCategory2 bookCategory;
    private String name;
}

这里在子实体中显式地定义了联合主键的两个属性,并通过@MapsId将外键关系BookCategory2映射到主键bookCategoryId

更直观的方式是使用@EmbeddedId

java 复制代码
@Entity
@Table(name = "book_sub_category3")
@Data
public class BookSubCategory3 {
    @Embeddable
    public record BookSubCategoryId(
            @GeneratedValue(strategy = GenerationType.UUID)
            @Column(name = "id")
            String id,
            @Column(name = "book_category_id")
            Long bookCategoryId) {
    }

    @EmbeddedId
    private BookSubCategoryId id;

    @MapsId("bookCategoryId")
    @ManyToOne
    @JoinColumn(name = "book_category_id")
    private BookCategory3 bookCategory;
    private String name;
}

添加约束

可以使用@Column注解为单一列添加唯一约束:

java 复制代码
@NotBlank
@Column(nullable = false)
private String name;

如果要为多列添加唯一约束,可以:

java 复制代码
@Entity
@Data
@Table(name = "student_course5", uniqueConstraints =
    @UniqueConstraint(name = "unique_student_course5", columnNames = {"student_id", "course_id"}))
public class StudentCourse5 {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "student_id")
    private Long studentId;
    @Column(name = "course_id")
    private Long courseId;
    // ...
}

似乎 Hibernate/JPA 不支持用注解的方式添加带条件的唯一索引。

为指定列添加条件约束:

java 复制代码
@Column(nullable = false, check = @CheckConstraint(name = "age_check", constraint = "age >= 0 AND age <= 120"))
private Integer age;

实际上,如果使用了 Hibernate Validation 注解,Hibernate 会自动添加相关的条件约束:

java 复制代码
@Min(0)
@Max(120)
@NotNull
private Integer age;

这里生成的条件约束和上面是相同的,而且在 JPA 层增加了额外检查,更为推荐。

也可以在实体类上添加条件约束:

java 复制代码
@Entity
@Table(name = "student5", check = @CheckConstraint(
        name = "name_check", constraint = "name IS NOT NULL and length(name)>0 and length(name)<20"))
@Data
public class Student5 {
    // ...
}

监听器

可以对实体类应用监听器,以处理实体类不同生命周期的特殊需要:

java 复制代码
@Data
@MappedSuperclass
@EntityListeners(BaseEntity.BaseEntityEvents.class)
public abstract class BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private Date createTime;
    @Column(length = 10)
    private String createUser;
    private Date updateTime;
    @Column(length = 10)
    private String updateUser;
    public static class BaseEntityEvents {
        @PrePersist
        public void prePersist(BaseEntity baseEntity) {
            baseEntity.setCreateTime(new Date());
            baseEntity.setUpdateTime(new Date());
        }

        @PreUpdate
        public void preUpdate(BaseEntity baseEntity) {
            baseEntity.setUpdateTime(new Date());
        }
    }
}

这里在实体类的基类上创建了一个监听器类(BaseEntityEvents),并使用@EntityListeners(BaseEntity.BaseEntityEvents.class)注解绑定到实体类上,这样继承了基类的实体类在更新和创建时会自动添加创建时间和更新时间。

监听中可以从容器中依赖注入(如果使用了容器框架),因此可以实现更广泛的用途,比如获取当前登录用户信息,在更新或创建时将更新人/创建人的信息也写入数据库通用字段。

本文的完整示例代码可以从这里获取。

参考资料

相关推荐
indexsunny3 小时前
互联网大厂Java面试实战:基于电商场景的Spring Boot与微服务技术问答
java·spring boot·微服务·面试·hibernate·电商场景·技术问答
七夜zippoe3 小时前
ORM框架下的SQL优化 N+1问题识别与解决方案
自动化·mybatis·jpa·n+1·batch fetching
Mr.Entropy18 小时前
JdbcTemplate 性能好,但 Hibernate 生产力高。 如何选择?
java·后端·hibernate
爬山算法2 天前
Hibernate(26)什么是Hibernate的透明持久化?
java·后端·hibernate
爬山算法2 天前
Hibernate(25)Hibernate的批量操作是什么?
java·后端·hibernate
七夜zippoe2 天前
Spring Data JPA原理与实战 Repository接口的魔法揭秘
java·ffmpeg·事务·jpa·repository
爬山算法3 天前
Hibernate(24)Hibernate如何实现乐观锁?
java·后端·hibernate
爬山算法7 天前
Hibernate(15)Hibernate中如何定义一个实体的主键?
java·后端·hibernate
爬山算法10 天前
Hibernate(9)什么是Hibernate的Transaction?
java·后端·hibernate
爬山算法12 天前
Hibernate(6) Hibernate支持哪些数据库?
java·数据库·hibernate