06 @Entity 里面的 JPA 注解有哪些?在 Java 多态场景下如何使用?
前几课时我为你介绍了 Repository 的用法,其中我经常会提到“实体类”(即我们前面的 User 类),它是对我们数据库中表的 Metadata 映射,那么具体如何映射呢?这一课时我们来讲解。
我们先看一下 Java Persistence API 里面都有哪些重要规定;再通过讲解基本注解,重点介绍一下联合主键和实体之间的继承关系,然后你就会知道 JPA 的实体里面常见的注解有哪些。话不多说,看一下 Entity 的相关规定。
JPA 协议中关于 Entity 的相关规定
我们先看一下 JPA 协议里面关于实体做了哪些规定。(这里推荐一个查看 JPA 协议的官方地址:https://download.oracle.com/otn-pub/jcp/persistence-2_2-mrel-spec/JavaPersistence.pdf)
-
实体是直接进行数据库持久化操作的领域对象(即一个简单的 POJO,可以按照业务领域划分),必须通过 @Entity 注解进行标示。
-
实体必须有一个 public 或者 protected 的无参数构造方法。
-
持久化映射的注解可以标示在 Entity 的字段 field 上,如下所示:
@Column(length = 20, nullable = false)
private String userName;
除此之外,也可以将持久化注解运用在 Entity 里面的 get/set 方法上,通常我们是放在 get 方法中,如下所示:
@Column(length = 20, nullable = false)
public String getUserName(){
return userName;
}
概括起来,就是 Entity 里面的注解生效只有两种方式:将注解写在字段上或者将注解写在方法上(JPA 里面称 Property)。
但是需要注意的是,在同一个 Entity 里面只能有一种方式生效,也就是说,注解要么全部写在 field 上面,要么就全部写在 Property 上面,因为我经常会看到有的同事分别在两种方式中加了注解后说:“哎呀,我的注解怎么没有生效呀!”因此这一点需要特别注意。
-
只要是在 @Entity 的实体里面被注解标注的字段,都会被映射到数据库中,除了使用 @Transient 注解的字段之外。
-
实体里面必须要有一个主键,主键标示的字段可以是单个字段,也可以是复合主键字段。
以上我只挑选了最关键的几条进行了介绍,如果你有兴趣可以读一读 Java Persistence API 协议,这样我们在做 JPA 开发的时候就会顺手很多,可以理解很多 Hibernate 里面实现方法。
这也为你提供了一条解决疑难杂症的思路,也就是当我们遇到解决不了的问题时,就去看协议、阅读官方文档,深入挖掘一下,可能就会找到答案。那么接下来我们看看实例里面常用的注解有哪些。
详细的注解都有哪些?
我们先通过源码看看 JPA 里面支持的注解有哪些。
首先,我们利用 IEDA 工具,打开 @Entity 所在的包,就可以看到 JPA 里面支持的注解有哪些。如下所示:
我们可以看到,在 jakarta.persistence-api 的包路径下面大概有一百多个注解,你在没事的时候可以到这里面一个一个地看,也可以到 JPA 的协议里面对照查看文档。
我在这里只提及一些最常见的,包括 @Entity、@Table、@Access、@Id、@GeneratedValue、@Enumerated、@Basic、@Column、@Transient、@Lob、@Temporal 等。
1.@Entity 用于定义对象将会成为被 JPA 管理的实体,必填,将字段映射到指定的数据库表中,使用起来很简单,直接用在实体类上面即可,通过源码表达的语法如下:
@Target(TYPE) //表示此注解只能用在class上面
public @interface Entity {
//可选,默认是实体类的名字,整个应用里面全局唯一。
String name() default "";
}
2.@Table 用于指定数据库的表名,表示此实体对应的数据库里面的表名,非必填,默认表名和 entity 名字一样。
@Target(TYPE) //一样只能用在类上面
public @interface Table {
//表的名字,可选。如果不填写,系统认为好实体的名字一样为表名。
String name() default "";
//此表所在schema,可选
String schema() default "";
//唯一性约束,在创建表的时候有用,表创建之后后面就不需要了。
UniqueConstraint[] uniqueConstraints() default { };
//索引,在创建表的时候使用,表创建之后后面就不需要了。
Index[] indexes() default {};
}
3.@Access 用于指定 entity 里面的注解是写在字段上面,还是 get/set 方法上面生效,非必填。在默认不填写的情况下,当实体里面的第一个注解出现在字段上或者 get/set 方法上面,就以第一次出现的方式为准;也就是说,一个实体里面的注解既有用在 field 上面,又有用在 properties 上面的时候,看下面的代码你就会明白。
@Id
private Long id;
@Column(length = 20, nullable = false)
public String getUserName(){
return userName;
}
那么由于 @Id 是实体里面第一个出现的注解,并且作用在字段上面,所以所有写在 get/set 方法上面的注解就会失效。而 @Access 可以干预默认值,指定是在 fileds 上面生效还是在 properties 上面生效。我们通过源码看下语法:
@Target( { TYPE, METHOD, FIELD })//表示此注解可以运用在class上(那么这个时候就可以指定此实体的默认注解生效策略了),也可以用在方法上或者字段上(表示可以独立设置某一个字段或者方法的生效策略);
@Retention(RUNTIME)
public @interface Access {
//指定是字段上面生效还是方法上面生效
AccessType value();
}
public enum AccessType {
FIELD,
PROPERTY
}
4.@Id 定义属性为数据库的主键,一个实体里面必须有一个主键,但不一定是这个注解,可以和 @GeneratedValue 配合使用或成对出现。
5.@GeneratedValue 主键生成策略,如下所示:
public @interface GeneratedValue {
//Id的生成策略
GenerationType strategy() default AUTO;
//通过Sequences生成Id,常见的是Orcale数据库ID生成规则,这个时候需要配合@SequenceGenerator使用
String generator() default "";
}
其中,GenerationType 一共有以下四个值:
public enum GenerationType {
//通过表产生主键,框架借由表模拟序列产生主键,使用该策略可以使应用更易于数据库移植。
TABLE,
//通过序列产生主键,通过 @SequenceGenerator 注解指定序列名, MySql 不支持这种方式;
SEQUENCE,
//采用数据库ID自增长, 一般用于mysql数据库
IDENTITY,
//JPA 自动选择合适的策略,是默认选项;
AUTO
}
6.@Enumerated 这个注解很好用,因为它对 enum 提供了下标和 name 两种方式,用法直接映射在 enum 枚举类型的字段上。请看下面源码。
@Target({METHOD, FIELD}) //作用在方法和字段上
public @interface Enumerated {
//枚举映射的类型,默认是ORDINAL(即枚举字段的下标)。
EnumType value() default ORDINAL;
}
public enum EnumType {
//映射枚举字段的下标
ORDINAL,
//映射枚举的Name
STRING
}
再来看一个 User 里面关于性别枚举的例子,你就会知道 @Enumerated 在这里没什么作用了,如下所示:
//有一个枚举类,用户的性别
public enum Gender {
MAIL("男性"), FMAIL("女性");
private String value;
private Gender(String value) {
this.value = value;
}
}
//实体类@Enumerated的写法如下
@Entity
@Table(name = "tb_user")
public class User implements Serializable {
@Enumerated(EnumType.STRING)
@Column(name = "user_gender")
private Gender gender;
.......................
}
这时候插入两条数据,数据库里面的值会变成 MAIL/FMAIL,而不是“男性” / 女性。
经验分享: 如果我们用 @Enumerated(EnumType.ORDINAL),这时候数据库里面的值是 0、1。但是实际工作中,不建议用数字下标,因为枚举里面的属性值是会不断新增的,如果新增一个,位置变化了就惨了。并且 0、1、2 这种下标在数据库里面看着非常痛苦,时间长了就会一点也看不懂了。
7.@Basic 表示属性是到数据库表的字段的映射。如果实体的字段上没有任何注解,默认即为 @Basic。也就是说默认所有的字段肯定是和数据库进行映射的,并且默认为 Eager 类型。
public @interface Basic {
//可选,EAGER(默认):立即加载;LAZY:延迟加载。(LAZY主要应用在大字段上面)
FetchType fetch() default EAGER;
//可选。这个字段是否可以为null,默认是true。
boolean optional() default true;
}
8.@Transient 表示该属性并非一个到数据库表的字段的映射,表示非持久化属性。JPA 映射数据库的时候忽略它,与 @Basic 有相反的作用。也就是每个字段上面 @Transient 和 @Basic 必须二选一,而什么都不指定的话,默认是 @Basic。
9.@Column 定义该属性对应数据库中的列名。
public @interface Column {
//数据库中的表的列名;可选,如果不填写认为字段名和实体属性名一样。
String name() default "";
//是否唯一。默认flase,可选。
boolean unique() default false;
//数据字段是否允许空。可选,默认true。
boolean nullable() default true;
//执行insert操作的时候是否包含此字段,默认,true,可选。
boolean insertable() default true;
//执行update的时候是否包含此字段,默认,true,可选。
boolean updatable() default true;
//表示该字段在数据库中的实际类型。
String columnDefinition() default "";
//数据库字段的长度,可选,默认255
int length() default 255;
}
10.@Temporal 用来设置 Date 类型的属性映射到对应精度的字段,存在以下三种情况:
-
@Temporal(TemporalType.DATE)映射为日期 // date (只有日期)
-
@Temporal(TemporalType.TIME)映射为日期 // time (只有时间)
-
@Temporal(TemporalType.TIMESTAMP)映射为日期 // date time (日期+时间)
我们看一个完整的例子,感受一下上面提到的注解的完整用法,如下:
package com.example.jpa.example1;
import lombok.Data;
import javax.persistence.*;
import java.util.Date;
@Entity
@Table(name = "user_topic")
@Access(AccessType.FIELD)
@Data
public class UserTopic {
@Id
@Column(name = "id", nullable = false)
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "title", nullable = true, length = 200)
private String title;
@Basic
@Column(name = "create_user_id", nullable = true)
private Integer createUserId;
@Basic(fetch = FetchType.LAZY)
@Column(name = "content", nullable = true, length = -1)
@Lob
private String content;
@Basic(fetch = FetchType.LAZY)
@Column(name = "image", nullable = true)
@Lob
private byte[] image;
@Basic
@Column(name = "create_time", nullable = true)
@Temporal(TemporalType.TIMESTAMP)
private Date createTime;
@Basic
@Column(name = "create_date", nullable = true)
@Temporal(TemporalType.DATE)
private Date createDate;
@Enumerated(EnumType.STRING)
@Column(name = "topic_type")
private Type type;
@Transient
private String transientSimple;
//非数据库映射字段,业务类型的字段
public String getTransientSimple() {
return title + "auto:jack" + type;
}
//有一个枚举类,主题的类型
public enum Type {
EN("英文"), CN("中文");
private final String des;
Type(String des) {
this.des = des;
}
}
}
细心的同学就会发现,我们在一开始的 demo 里面没有这么多注解呀,其实这里面的很多注解都可以省略,直接使用默认的就可以。如 @Basic、@Column 名字有一定的映射策略(我们在第 17 课时讲 DataSource 的时候会详细讲解映射策略),所以可以省略。
此外,@Access 也可以省略,我们只要在这些类里面保持一致就可以了。可能你会有疑问了,这么多注解都要手动一个一个配置吗?老师介绍一种简单的做法——利用工具去生成 Entity 类,将会节省很多时间。
生成这些注解的小技巧
有时候老的 Table 非常多,我们一个一个去写 entity 会特别累,因此我们可以利用 IEDA 工具直接帮我们生成 Entity 类。关键步骤如下。
首先,打开 Persistence 视图,点击 Generate Persistence Mapping>,接着点击选中数据源,如下图所示:
然后,选择表和字段,并点击 OK。
这样就可以生成我们想要的实体了,多简单。如果是新库、新表,我们也可以先定义好实体,通过实体配置JPA的spring.jpa.generate-ddl=true,反向直接生成 DDL 操作数据库生成表结构。
但是需要注意的是,在生产环境中我们要把外间关联关系关闭,不然会出现意想不到的 ERROR,毕竟生产环境不同开发环境,我们可以通过在开发环境生成的表导出 DDL 到生产执行。我经常会利用生成 DDL 来做测试和写案例, 这样省去了创建表的时间,只需要关注我的代码就行了。
接下来我们再把工作中最常见的联合 ID 字段的场景详细讲解一下。
联合主键
在实际的工作中,我们会经常遇到联合主键的情况。所以在这里我们详细讲解一下,可以通过 javax.persistence.EmbeddedId 和 javax.persistence.IdClass 两个注解实现联合主键的效果。
如何通过 @IdClass 做到联合主键?
我们先看一下怎么通过 @IdClass 做到联合主键。
第一步:新建一个 UserInfoID 类里面是联合主键。
package com.example.jpa.example1;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class UserInfoID implements Serializable {
private String name,telephone;
}
第二步:再新建一个 UserInfo 的实体,采用 @IdClass 引用联合主键类。
@Entity
@Data
@Builder
@IdClass(UserInfoID.class)
@AllArgsConstructor
@NoArgsConstructor
public class UserInfo {
private Integer ages;
@Id
private String name;
@Id
private String telephone;
}
第三步:新增一个 UserInfoReposito 类来做 CRUD 操作。
package com.example.jpa.example1;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserInfoRepository extends JpaRepository<UserInfo,UserInfoID> {
}
第四步:写一个测试用例,测试一下。
package com.example.jpa.example1;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import java.util.Optional;
@DataJpaTest
public class UserInfoRepositoryTest {
@Autowired
private UserInfoRepository userInfoRepository;
@Test
public void testIdClass() {
userInfoRepository.save(UserInfo.builder().ages(1).name("jack").telephone("123456789").build());
Optional<UserInfo> userInfo = userInfoRepository.findById(UserInfoID.builder().name("jack").telephone("123456789").build());
System.out.println(userInfo.get());
}
}
Hibernate: create table user_info (name varchar(255) not null, telephone varchar(255) not null, ages integer, primary key (name, telephone))
Hibernate: select userinfo0_.name as name1_3_0_, userinfo0_.telephone as telephon2_3_0_, userinfo0_.ages as ages3_3_0_ from user_info userinfo0_ where userinfo0_.name=? and userinfo0_.telephone=?
UserInfo(ages=1, name=jack, telephone=123456789)
通过上面的例子我们可以发现,我们的表的主键是 primary key (name, telephone),而 Entity 里面不再是一个 @Id 字段了。那么我来介绍另外一个注解 @Embeddable,也能做到这一点。
@Embeddable 与 @EmbeddedId 注解使用
第一步:在我们上面例子中的 UserInfoID 里面添加 @Embeddable 注解。
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Embeddable
public class UserInfoID implements Serializable {
private String name,telephone;
}
第二步:改一下我们刚才的 User 对象,删除 @IdClass,添加 @EmbeddedId 注解,如下:
@Entity
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class UserInfo {
private Integer ages;
@EmbeddedId
private UserInfoID userInfoID;
@Column(unique = true)
private String uniqueNumber;
}
第三步:UserInfoRepository 不变,我们直接修改一下测试用例。
@Test
public void testIdClass() {
userInfoRepository.save(UserInfo.builder().ages(1).userInfoID(UserInfoID.builder().name("jack").telephone("123456789").build()).build());
Optional<UserInfo> userInfo = userInfoRepository.findById(UserInfoID.builder().name("jack").telephone("123456789").build());
System.out.println(userInfo.get());
}
运行完之后,你可以得到相同的结果。那么 @IdClass 和 @EmbeddedId 的区别是什么?有以下两个方面:
-
如上面测试用例,在使用的时候,Embedded 用的是对象,而 IdClass 用的是具体的某一个字段;
-
二者的JPQL 也会不一样:
① 用 @IdClass JPQL 的写法:SELECT u.name FROM UserInfo u
② 用 @EmbeddedId 的 JPQL 的写法:select u.userInfoId.name FROM UserInfo u
联合主键还有需要注意的就是,它与唯一性索引约束的区别是写法不同,如上面所讲,唯一性索引的写法如下:
@Column(unique = true)
private String uniqueNumber;
到这里,联合主键我们讲完了,那么在遇到联合主键的时候,利用 @IdClass、@EmbeddedId,你就可以应对联合主键了。
此外,Java 是面向对象的,肯定会用到多态的使用场景,那么场景都有哪些?公共父类又该如何写?我们来学习一下。
实体之间的继承关系如何实现?
在 Java 面向对象的语言环境中,@Entity 之间的关系多种多样,而根据 JPA 的规范,我们大致可以将其分为以下几种:
-
纯粹的继承,和表没关系,对象之间的字段共享。利用注解 @MappedSuperclass,协议规定父类不能是 @Entity。
-
单表多态问题,同一张 Table,表示了不同的对象,通过一个字段来进行区分。利用
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
注解完成,只有父类有 @Table。 -
多表多态,每一个子类一张表,父类的表拥有所有公用字段。通过
@Inheritance(strategy = InheritanceType.JOINED)
注解完成,父类和子类都是表,有公用的字段在父表里面。 -
Object 的继承,数据库里面每一张表是分开的,相互独立不受影响。通过
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
注解完成,父类(可以是一张表,也可以不是)和子类都是表,相互之间没有关系。
其中,第一种 @MappedSuperclass,我们暂时不多介绍,在第 12 课时讲解“JPA 的审计功能”时,再做详细介绍,我们先看一下第二种SINGLE_TABLE
。
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
父类实体对象与各个子实体对象共用一张表,通过一个字段的不同值代表不同的对象,我们看一个例子。
我们抽象一个 Book 对象,如下所示:
package com.example.jpa.example1.book;
import lombok.Data;
import javax.persistence.*;
@Entity(name="book")
@Data
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name="color", discriminatorType = DiscriminatorType.STRING)
public class Book {
@Id
@GeneratedValue(strategy= GenerationType.AUTO)
private Long id;
private String title;
}
再新建一个 BlueBook 对象,作为 Book 的子对象。
package com.example.jpa.example1.book;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;
@Entity
@Data
@EqualsAndHashCode(callSuper=false)
@DiscriminatorValue("blue")
public class BlueBook extends Book{
private String blueMark;
}
再新建一个 RedBook 对象,作为 Book 的另一子对象。
//红皮书
@Entity
@DiscriminatorValue("red")
@Data
@EqualsAndHashCode(callSuper=false)
public class RedBook extends Book {
private String redMark;
}
这时,我们一共新建了三个 Entity 对象,其实都是指 book 这一张表,通过 book 表里面的 color 字段来区分红书还是绿书。我们继续做一下测试看看结果。
我们再新建一个 RedBookRepositor 类,操作一下 RedBook 会看到如下结果:
package com.example.jpa.example1.book;
import org.springframework.data.jpa.repository.JpaRepository;
public interface RedBookRepository extends JpaRepository<RedBook,Long>{
}
然后再新建一个测试用例。
package com.example.jpa.example1;
import com.example.jpa.example1.book.RedBook;
import com.example.jpa.example1.book.RedBookRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
@DataJpaTest
public class RedBookRepositoryTest {
@Autowired
private RedBookRepository redBookRepository;
@Test
public void testRedBook() {
RedBook redBook = new RedBook();
redBook.setTitle("redbook");
redBook.setRedMark("redmark");
redBook.setId(1L);
redBookRepository.saveAndFlush(redBook);
RedBook r = redBookRepository.findById(1L).get();
System.out.println(r.getId()+":"+r.getTitle()+":"+r.getRedMark());
}
}
最后看一下执行结果。
Hibernate: create table book (color varchar(31) not null, id bigint not null, title varchar(255), blue_mark varchar(255), red_mark varchar(255), primary key (id))
你会发现,我们只创建了一张表,insert 了一条数据,但是我们发现 color 字段默认给的是 red。
Hibernate: insert into book (title, red_mark, color, id) values (?, ?, 'red', ?)
那么再看一下打印结果。
1:redbook:redmark
结果完全和预期的一样,这说明了 RedBook、BlueBook、Book,都是一张表,通过字段 color 的值不一样,来区分不同的实体。
那么接下来我们看一下 InheritanceType.JOINED,它的每个实体都是独立的表。
@Inheritance(strategy = InheritanceType.JOINED)
在这种映射策略里面,继承结构中的每一个实体(entity)类都会映射到数据库里一个单独的表中。也就是说,每个实体(entity)都会被映射到数据库中,一个实体(entity)类对应数据库中的一个表。
其中根实体(root entity)对应的表中定义了主键(primary key),所有的子类对应的数据库表都要共同使用 Book 里面的 @ID 这个主键。
首先,我们改一下上面的三个实体,测试一下InheritanceType.JOINED,改动如下:
package com.example.jpa.example1.book;
import lombok.Data;
import javax.persistence.*;
@Entity(name="book")
@Data
@Inheritance(strategy = InheritanceType.JOINED)
public class Book {
@Id
@GeneratedValue(strategy= GenerationType.AUTO)
private Long id;
private String title;
}
其次,我们 Book 父类、改变 Inheritance 策略、删除 DiscriminatorColumn,你会看到如下结果。
package com.example.jpa.example1.book;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.persistence.Entity;
import javax.persistence.PrimaryKeyJoinColumn;
@Entity
@Data
@EqualsAndHashCode(callSuper=false)
@PrimaryKeyJoinColumn(name = "book_id", referencedColumnName = "id")
public class BlueBook extends Book{
private String blueMark;
}
package com.example.jpa.example1.book;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.persistence.Entity;
import javax.persistence.PrimaryKeyJoinColumn;
@Entity
@PrimaryKeyJoinColumn(name = "book_id", referencedColumnName = "id")
@Data
@EqualsAndHashCode(callSuper=false)
public class RedBook extends Book {
private String redMark;
}
然后,BlueBook和RedBook也删除DiscriminatorColumn,新增@PrimaryKeyJoinColumn(name = "book_id", referencedColumnName = "id"),和 book 父类共用一个主键值,而 RedBookRepository 和测试用例不变,我们执行看一下结果。
Hibernate: create table blue_book (blue_mark varchar(255), book_id bigint not null, primary key (book_id))
Hibernate: create table book (id bigint not null, title varchar(255), primary key (id))
Hibernate: create table red_book (red_mark varchar(255), book_id bigint not null, primary key (book_id))
Hibernate: alter table blue_book add constraint FK9uuwgq7a924vtnys1rgiyrlk7 foreign key (book_id) references book
Hibernate: alter table red_book add constraint FKk8rvl61bjy9lgsr9nhxn5soq5 foreign key (book_id) references book
上述代码可以看到,我们一共创建了三张表,并且新增了两个外键约束;而我们 save 的时候也生成了两个 insert 语句,如下:
Hibernate: insert into book (title, id) values (?, ?)
Hibernate: insert into red_book (red_mark, book_id) values (?, ?)
而打印结果依然不变。
1:redbook:redmark
这就是 InheritanceType.JOINED 的例子,这个方法和上面的 InheritanceType.SINGLE_TABLE 区别在于表的数量和关系不一样,这是表设计的另一种方式。
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
我们在使用 @MappedSuperClass 主键的时候,如果不指定 @Inhertance,默认就是此种TABLE_PER_CLASS模式。当然了,我们也显示指定,要求继承基类的都是一张表,而父类不是表,是 java 对象的抽象类。我们看一个例子。
首先,还是改一下上面的三个实体。
package com.example.jpa.example1.book;
import lombok.Data;
import javax.persistence.*;
@Entity(name="book")
@Data
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public class Book {
@Id
@GeneratedValue(strategy= GenerationType.AUTO)
private Long id;
private String title;
}
其次,Book 表采用 TABLE_PER_CLASS 策略,其子实体类都代表各自的表,实体代码如下:
package com.example.jpa.example1.book;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.persistence.Entity;
@Entity
@Data
@EqualsAndHashCode(callSuper=false)
public class RedBook extends Book {
private String redMark;
}
package com.example.jpa.example1.book;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.persistence.Entity;
@Entity
@Data
@EqualsAndHashCode(callSuper=false)
public class BlueBook extends Book{
private String blueMark;
}
这时,从 RedBook 和 BlueBook 里面去掉 PrimaryKeyJoinColumn,而 RedBookRepository 和测试用例不变,我们执行看一下结果。
Hibernate: create table blue_book (id bigint not null, title varchar(255), blue_mark varchar(255), primary key (id))
Hibernate: create table book (id bigint not null, title varchar(255), primary key (id))
Hibernate: create table red_book (id bigint not null, title varchar(255), red_mark varchar(255), primary key (id))
这里可以看到,我们还是创建了三张表,但三张表什么关系也没有。而 insert 语句也只有一条,如下:
Hibernate: insert into red_book (title, red_mark, id) values (?, ?, ?)
打印结果还是不变。
1:redbook:redmark
这个方法与上面两个相比较,语义更加清晰,是比较常用的一种做法。
以上就是实体之间继承关系的实现方法,可以在涉及 java 多态的时候加以应用,不过要注意区分三种方式所表达的表的意思,再加以运用。
关于继承关系的经验之谈
从我的个人经验来看,@Inheritance 的这种使用方式会逐渐被淘汰,因为这样的表的设计很复杂,本应该在业务层面做的事情(多态),而在 datasoure 的表级别做了。所以在 JPA 中使用这个的时候你就会想:“这么复杂的东西,我直接用 Mybatis 算了。”我想告诉你,其实它们是一样的,只是我们使用的思路不对。
那么为什么行业内都不建议使用了,还要介绍这么详细呢?因为,如果你遇到的是老一点的项目,如果不是用 Java 语言写的,不一定有面向对象的思想。这个时候如果让你迁移成 Java 怎么办?如果你可以想到这种用法,就不至于束手无措。
此外,在互联网项目中,一旦有关表的业务对象过多了之后,就可以拆表拆库了,这个时候我们要想到我们的@Table 注解指定表名和 schema。
关于上面提到的方法中,最常用的是第一种 @MappedSuperclass,这个我们将在第 12 课时“JPA 的审计功能解决了哪些问题?”中详细介绍,到时候你可以体验一下它的不同之处。
总结
Entity 里面常用的基本注解我们就介绍到这里,因为注解太多没办法一一介绍,你可以掌握一下学习方法。先通过源码把大致注解看一下,有哪些不熟悉的可以看看源码里面的注释,再阅读 JPA 官方协议,还可以写一个测试用例试,跑一下看看 sql 输出和日志,这样很快就可以知道结果了。
这一课时我们提到的实体与实体之间的关联关系注解,我将在下一课时为你讲解。
点击下方链接查看源码(不定时更新)
https://github.com/zhangzhenhuajack/spring-boot-guide/tree/master/spring-data/spring-data-jpa
07 @Entity 之间的关联关系注解如何正确使用?
你好,欢迎来到第 07 课时的学习,这一课时我们讲一下实体与实体之间的关联关系,这和数据的表与表之间的外键关系类似,我们为之为映射。
实体与实体之间的关联关系一共分为四种,分别为 OneToOne、OneToMany、ManyToOne 和 ManyToMany;而实体之间的关联关系又分为双向的和单向的。实体之间的关联关系是在 JPA 使用中最容易发生问题的地方,接下来我将一一揭晓并解释。我们先看一下 OneToOne,即一对一的关联关系。
@OneToOne 关联关系
@OneToOne 一般表示对象之间一对一的关联关系,它可以放在 field 上面,也可以放在 get/set 方法上面。其中 JPA 协议有规定,如果是配置双向关联,维护关联关系的是拥有外键的一方,而另一方必须配置 mappedBy;如果是单项关联,直接配置在拥有外键的一方即可。
举个例子:user 表是用户的主信息,user_info 是用户的扩展信息,两者之间是一对一的关系。user_info 表里面有一个 user_id 作为关联关系的外键,如果是单项关联,我们的写法如下:
package com.example.jpa.example1;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.*;
@Entity
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class User {
@Id
@GeneratedValue(strategy= GenerationType.AUTO)
private Long id;
private String name;
private String email;
private String sex;
private String address;
}
User 实体里面什么都没变化,不需要添加 @OneToOne 注解。我们只需要在拥有外键的一方配置就可以,所以 UserInfo 的代码如下:
package com.example.jpa.example1;
import lombok.*;
import javax.persistence.*;
@Entity
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = "user")
public class UserInfo {
@Id
@GeneratedValue(strategy= GenerationType.AUTO)
private Long id;
private Integer ages;
private String telephone;
@OneToOne //维护user的外键关联关系,配置一对一
private User user;
}
我们看到,UserInfo 实体对象里面添加了 @OneToOne 注解,这时我们写一个测试用例跑一下看看有什么效果:
Hibernate: create table user (id bigint not null, address varchar(255), email varchar(255), name varchar(255), sex varchar(255), primary key (id))
Hibernate: create table user_info (id bigint not null, ages integer, telephone varchar(255), user_id bigint, primary key (id))
Hibernate: alter table user_info add constraint FKn8pl63y4abe7n0ls6topbqjh2 foreign key (user_id) references user
因为我们新建了两个实体,跑任何一个 @SpringDataTest 就会看到上面有三个 sql 在执行,分别创建了两张表,而在 user_info 表上面还创建了一个外键索引。
上面我们说了单项关联关系,那么双向关联应该怎么配置呢?我们保持 UserInfo 不变,在 User 实体对象里面添加这一段代码即可。
@OneToOne(mappedBy = "user")
private UserInfo userInfo;
完整的 User 实体对象就会变成如下模样。
@Entity
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class User {
@Id
@GeneratedValue(strategy= GenerationType.AUTO)
private Long id;
private String name;
private String email;
@OneToOne(mappedBy = "user")
private UserInfo userInfo;//变化之处
private String sex;
private String address;
}
我们跑任何一个测试用例,就会看到运行结果是一样的,还是上面三条 sql。那么我们再查看一下 @OneToOne 源码,看看其支持的配置都有哪些。
@interface OneToOne 源码解读
下面我列举了@OneToOne 的源码,并加以解读。通过这些你可以了解 @OneToOne 的用法。
public @interface OneToOne {
//表示关系目标实体,默认该注解标识的返回值的类型的类。
Class targetEntity() default void.class;
//cascade 级联操作策略,就是我们常说的级联操作
CascadeType[] cascade() default {};
//数据获取方式EAGER(立即加载)/LAZY(延迟加载)
FetchType fetch() default EAGER;
//是否允许为空,默认是可选的,也就表示可以为空;
boolean optional() default true;
//关联关系被谁维护的一方对象里面的属性名字。 双向关联的时候必填
String mappedBy() default "";
//当被标识的字段发生删除或者置空操作之后,是否同步到关联关系的一方,即进行通过删除操作,默认flase,注意与CascadeType.REMOVE 级联删除的区别
boolean orphanRemoval() default false;
}
mappedBy 注意事项
只有关联关系的维护方才能操作两个实体之间外键的关系。被维护方即使设置了维护方属性进行存储也不会更新外键关联。
mappedBy 不能与 @JoinColumn 或者 @JoinTable 同时使用,因为没有意义,关联关系不在这里面维护。
此外,mappedBy 的值是指另一方的实体里面属性的字段,而不是数据库字段,也不是实体的对象的名字。也就是维护关联关系的一方属性字段名称,或者加了 @JoinColumn / @JoinTable 注解的属性字段名称。如上面的 User 例子 user 里面 mappedBy 的值,就是 UserInfo 里面的 user 字段的名字。
CascadeType用法
在 CascadeType 的用法中,CascadeType 的枚举值只有五个,分别如下:
-
CascadeType.PERSIST 级联新建
-
CascadeType.REMOVE 级联删除
-
CascadeType.REFRESH 级联刷新
-
CascadeType.MERGE 级联更新
-
CascadeType.ALL 四项全选
其中,默认是没有级联操作的,关系表不会产生任何影响。此外,JPA 2.0 还新增了 CascadeType.DETACH,即级联实体到 Detach 状态。
了解了枚举值,下面我们来测试一下级联新建和级联删除。
首先,修改 UserInfo 里面的关键代码如下,并在 @OneToOne 上面添加
cascade ={CascadeType.PERSIST,CascadeType.REMOVE}
,如下:
其次,我们新增一个测试方法。
@Test
@Rollback(false)
public void testUserRelationships() throws JsonProcessingException {
User user = User.builder().name("jackxx").email("123456@126.com").build();
UserInfo userInfo = UserInfo.builder().ages(12).user(user).telephone("12345678").build();
//保存userInfo的同上也会保存User信息
userInfoRepository.saveAndFlush(userInfo);
//删除userInfo,同时也会级联的删除user记录
userInfoRepository.delete(userInfo);
}
最后,运行一下看看效果。
从上面的运行结果可以看到,上面的测试在执行了 insert 的时候,会执行两条 insert 的sql 和两条 delete 的 sql,这就体现出了 CascadeType.PERSIST 和 CascadeType.REMOVE 的作用。
上面讲了级联删除的场景,下面我们再说一下关联关系的删除场景该怎么做。
orphanRemoval 属性用法
orphanRemoval 表示当关联关系被删除的时候,是否应用级联删除,默认 false。什么意思呢?测试一下你就会明白。
首先,还沿用上面的例子,当我们删除 userInfo 的时候,把 User 置空,作如下改动。
userInfo.setUser(null);
userInfoRepository.delete(userInfo);
其次,我们再运行测试,看看效果。
Hibernate: delete from user_info where id=?
这时候你就会发现,少了一条删除 user 的 sql,说明没有进行级联删除。那我们再把 UserInfo 做一下调整。
public class UserInfo {
@OneToOne(cascade = {CascadeType.PERSIST},orphanRemoval = true)
private User user;
....其他没变的代码省了
}
然后,我们把 CascadeType.Remove 删除了,不让它进行级联删除,但是我们把 orphanRemoval 设置成 true,即当关联关系变化的时候级联更新。我们看下完整的测试用例。
@Test
public void testUserRelationships() throws JsonProcessingException {
User user = User.builder().name("jackxx").email("123456@126.com").build();
UserInfo userInfo = UserInfo.builder().ages(12).user(user).telephone("12345678").build();
userInfoRepository.saveAndFlush(userInfo);
userInfo.setAges(13);
userInfo.setUser(null);//还是通过这个设置user数据为空
userInfoRepository.delete(userInfo);
}
这个时候我们看一下运行结果。
从中我们可以看到,结果依然是两个 inser 和两个 delete,但是中间多了一个 update。我来解释一下,因为去掉了 CascadeType.REMOVE,这个时候不会进行级联删除了。当我们把 user 对象更新成空的时候,就会执行一条 update 语句把关联关系去掉了。
而为什么又出现了级联删除 user 呢?因为我们修改了集合关联关系,orphanRemoval 设置为 true,所以又执行了级联删除的操作。这一点你可以仔细体会一下 orphanRemoval 和 CascadeType.REMOVE 的区别。
到这里,@OneToOne 关联关系介绍完了,接下来我们看一下日常工作常见的场景,先看场景一:主键和外键都是同一个字段的情况。
主键和外键都是同一个字段
我们假设 user 表是主表,user_info 的主键是 user_id,并且 user_id=user 是表里面的 id,那我们应该怎么写?
继续沿用上面的例子,User 实体不变,我们看看 UserInfo 变成什么样了。
public class UserInfo implements Serializable {
@Id
private Long userId;
private Integer ages;
private String telephone;
@MapsId
@OneToOne(cascade = {CascadeType.PERSIST},orphanRemoval = true)
private User user;
}
这里的做法很简单,我们直接把 userId 设置为主键,在 @OneToOne 上面添加 @MapsId 注解即可。@MapsId 注解的作用是把关联关系实体里面的 ID(默认)值 copy 到 @MapsId 标注的字段上面(这里指的是 user_id 字段)。
接着,上面的测试用例我们跑一下,看一下效果。
Hibernate: create table user (id bigint not null, address varchar(255), email varchar(255), name varchar(255), sex varchar(255), primary key (id))
Hibernate: create table user_info (ages integer, telephone varchar(255), user_id bigint not null, primary key (user_id))
Hibernate: alter table user_info add constraint FKn8pl63y4abe7n0ls6topbqjh2 foreign key (user_id) references user
在启动的时候,我们直接创建了 user 表和 user_info 表,其中 user_info 的主键是 user_id,并且通过外键关联到了 user 表的 ID 字段,那么我们同时看一下 inser 的 sql,也发生了变化。
Hibernate: insert into user (address, email, name, sex, id) values (?, ?, ?, ?, ?)
Hibernate: insert into user_info (ages, telephone, user_id) values (?, ?, ?)
上面就是我们讲的实战场景一,主键和外键都是同一个字段。接下来我们再说一个场景,就是在查 user_info 的时候,我们只想知道 user_id 的值就行了,不需要查 user 的其他信息,具体我们应该怎么做呢?
@OneToOne 延迟加载,我们只需要 ID 值
在 @OneToOne 延迟加载的情况下,我们假设只想查下 user_id,而不想查看 user 表其他的信息,因为当前用不到,可以有以下几种做法。
第一种做法:还是 User 实体不变,我们改一下 UserInfo 对象,如下所示:
package com.example.jpa.example1;
import lombok.*;
import javax.persistence.*;
@Entity
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = "user")
public class UserInfo{
@Id
@GeneratedValue(strategy= GenerationType.AUTO)
private Long id;
private Integer ages;
private String telephone;
@MapsId
@OneToOne(cascade = {CascadeType.PERSIST},orphanRemoval = true,fetch = FetchType.LAZY)
private User user;
}
从上面这段代码中,可以看到做的更改如下:
-
id 字段我们先用原来的
-
@OneToOne 上面我们添加 @MapsId 注解
-
@OneToOne 里面的 fetch = FetchType.LAZY 设置延迟加载
接着,我们改造一下测试类,完整代码如下:
@DataJpaTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class UserInfoRepositoryTest {
@Autowired
private UserInfoRepository userInfoRepository;
@BeforeAll
@Rollback(false)
@Transactional
void init() {
User user = User.builder().name("jackxx").email("123456@126.com").build();
UserInfo userInfo = UserInfo.builder().ages(12).user(user).telephone("12345678").build();
userInfoRepository.saveAndFlush(userInfo);
}
/**
* 测试用User关联关系操作
*
* @throws JsonProcessingException
*/
@Test
@Rollback(false)
public void testUserRelationships() throws JsonProcessingException {
UserInfo userInfo1 = userInfoRepository.getOne(1L);
System.out.println(userInfo1);
System.out.println(userInfo1.getUser().getId());
}
然后,我们跑一下测试用例,看看测试结果。
Hibernate: insert into user (address, email, name, sex, id) values (?, ?, ?, ?, ?)
Hibernate: insert into user (address, email, name, sex, id) values (?, ?, ?, ?, ?)
两条inser照旧,而只有一个select
Hibernate: select userinfo0_.user_id as user_id3_6_0_, userinfo0_.ages as ages1_6_0_, userinfo0_.telephone as telephon2_6_0_ from user_info userinfo0_ where userinfo0_.user_id=?
最后你会发现,打印的结果符合预期。
UserInfo(id=1, ages=12, telephone=12345678)
1
接下来介绍第二种做法,这种做法很简单,只要在 UserInfo 对象里面直接去掉 @OneToOne 关联关系,新增下面的字段即可。
@Column(name = "user_id")
private Long userId;
第三做法是利用 Hibernate,它给我们提供了一种字节码增强技术,通过编译器改变 class 解决了延迟加载问题。这种方式有点复杂,需要在编译器引入 hibernateEnhance 的相关 jar 包,以及编译器需要改变 class 文件并添加 lazy 代理来解决延迟加载。我不太推荐这种方式,因为太复杂,你知道有这回事就行了。
以上我们掌握了这么多用法,那么最佳实践是什么?双向关联更好还是单向关联更好?根据最近几年的应用,我总结出了一些最佳实践,我们来看一下。
@OneToOne 的最佳实践是什么?
第一,我要说一种 Java 面向对象的设计原则:开闭原则。
即对扩展开放,对修改关闭。如果我们一直使用双向关联,两个实体的对象耦合太严重了。想象一下,随着业务的发展,User 对象可能是原始对象,围绕着 User 可能会扩展出各种关联对象。难道 User 里面每次都要修改,去添加双向关联关系吗?肯定不是,否则时间长了,对象与对象之间的关联关系就是一团乱麻。
所以,我们尽量、甚至不要用双向关联,如果非要用关联关系的话,只用单向关联就够了。双向关联正是 JPA 的强大之处,但同时也是问题最多,最被人诟病之处。所以我们要用它的优点,而不是学会了就一定要使用。
第二,我想说 CascadeType 很强大,但是我也建议保持默认。
即没有级联更新动作,没有级联删除动作。还有 orphanRemoval 也要尽量保持默认 false,不做级联删除。因为这两个功能很强大,但是我个人觉得这违背了面向对象设计原则里面的“职责单一原则”,除非你非常非常熟悉,否则你在用的时候会时常感到惊讶——数据什么时间被更新了?数据被谁删除了?遇到这种问题查起来非常麻烦,因为是框架处理,有的时候并非预期的效果。
一旦生产数据被莫名更新或者删除,那是一件非常糟糕的事情。因为这些级联操作会使你的方法名字没办法命名,而且它不是跟着业务逻辑变化的,而是跟着实体变化的,这就会使方法和对象的职责不单一。
第三,我想告诉你,所有用到关联关系的地方,能用 Lazy 的绝对不要用 EAGER,否则会有 SQL 性能问题,会出现不是预期的 SQL。
以上三点是我总结的避坑指南,有经验的同学这时候会有个疑问:外键约束不是不推荐使用的吗?如果我的外键字段名不是约定的怎么办?别着急,我们再看一下 @JoinColumn 注解和 @JoinColumns 注解。
@JoinCloumns & JoinColumn
这两个注解是集合关系,他们可以同时使用,@JoinColumn 表示单字段,@JoinCloumns 表示多个 @JoinColumn,我们来一一看一下。
我们还是先直接看一下 @JoinColumn 源码,了解下这一注解都有哪些配置项。
public @interface JoinColumn {
//关键的字段名,默认注解上的字段名,在@OneToOne代表本表的外键字段名字;
String name() default "";
//与name相反关联对象的字段,默认主键字段
String referencedColumnName() default "";
//外键字段是否唯一
boolean unique() default false;
//外键字段是否允许为空
boolean nullable() default true;
//是否跟随一起新增
boolean insertable() default true;
//是否跟随一起更新
boolean updatable() default true;
//JPA2.1新增,外键策略
ForeignKey foreignKey() default @ForeignKey(PROVIDER_DEFAULT);
}
其次,我们看一下 @ForeignKey(PROVIDER_DEFAULT) 里面枚举值有几个。
public enum ConstraintMode {
//创建外键约束
CONSTRAINT,
//不创建外键约束
NO_CONSTRAINT,
//采用默认行为
PROVIDER_DEFAULT
}
然后,我们看看这个注解的语法,就可以解答我们上面的两个问题。修改一下 UserInfo,如下所示:
public class UserInfo{
@Id
@GeneratedValue(strategy= GenerationType.AUTO)
private Long id;
private Integer ages;
private String telephone;
@OneToOne(cascade = {CascadeType.PERSIST},orphanRemoval = true,fetch = FetchType.LAZY)
@JoinColumn(foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT),name = "my_user_id")
private User user;
...其他不变}
可以看到,我们在其中指定了字段的名字:my_user_id,并且指定 NO_CONSTRAINT 不生成外键。而测试用例不变,我们看下运行结果。
Hibernate: create table user (id bigint not null, address varchar(255), email varchar(255), name varchar(255), sex varchar(255), primary key (id))
Hibernate: create table user_info (id bigint not null, ages integer, telephone varchar(255), my_user_id bigint, primary key (id))
这时我们看到 user_info 表里面新增了一个字段 my_user_id,insert 的时候也能正确 inser my_user_id 的值等于 user.id。
Hibernate: insert into user_info (ages, telephone, my_user_id, id) values (?, ?, ?, ?)
而 @JoinColumns 是 JoinColumns 的复数形式,就是通过两个字段进行的外键关联,这个不常用,我们看一个 demo 了解一下就好。
@Entity
public class CompanyOffice {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumns({
@JoinColumn(name="ADDR_ID", referencedColumnName="ID"),
@JoinColumn(name="ADDR_ZIP", referencedColumnName="ZIP")
})
private Address address;
}
上面的实例中,CompanyOffice 通过 ADDR_ID 和 ADDR_ZIP 两个字段对应一条 address 信息,解释了一下@JoinColumns的用法。
如果你了解了 @OneToOne 的详细用法,后面要讲的几个注解就很好理解了,因为他们有点类似,那么我们接下来看看 @ManyToOne 和 @OneToMany 的用法。
@ManyToOne& @OneToMany
@ManyToOne 代表多对一的关联关系,而 @OneToMany 代表一对多,一般两个成对使用表示双向关联关系。而 JPA 协议中也是明确规定:维护关联关系的是拥有外键的一方,而另一方必须配置 mappedBy。看下面的代码。
public @interface ManyToOne {
Class targetEntity() default void.class;
CascadeType[] cascade() default {};
FetchType fetch() default EAGER;
boolean optional() default true;
}
public @interface OneToMany {
Class targetEntity() default void.class;
//cascade 级联操作策略:(CascadeType.PERSIST、CascadeType.REMOVE、CascadeType.REFRESH、CascadeType.MERGE、CascadeType.ALL)
如果不填,默认关系表不会产生任何影响。
CascadeType[] cascade() default {};
//数据获取方式EAGER(立即加载)/LAZY(延迟加载)
FetchType fetch() default LAZY;
//关系被谁维护,单项的。注意:只有关系维护方才能操作两者的关系。
String mappedBy() default "";
//是否级联删除。和CascadeType.REMOVE的效果一样。两种配置了一个就会自动级联删除
boolean orphanRemoval() default false;
}
我们看到上面的字段和 @OneToOne 里面的基本一样,用法是一样的,不过需要注意以下几点:
-
@ManyToOne 一定是维护外键关系的一方,所以没有 mappedBy 字段;
-
@ManyToOne 删除的时候一定不能把 One 的一方删除了,所以也没有 orphanRemoval 的选项;
-
@ManyToOne 的 Lazy 效果和 @OneToOne 的一样,所以和上面的用法基本一致;
-
@OneToMany 的 Lazy 是有效果的。
我们看个例子,假设 User 有多个地址 Address,我们看看实体应该如何建立。
@Entity
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class User implements Serializable {
@Id
@GeneratedValue(strategy= GenerationType.AUTO)
private Long id;
private String name;
private String email;
private String sex;
@OneToMany(mappedBy = "user",fetch = FetchType.LAZY)
private List<UserAddress> address;
}
上述代码我们可以看到,@OneToMany 双向关联并且采用 LAZY 的机制;这时我们新建一个 UserAddress 实体维护关联关系如下:
@Entity
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = "user")
public class UserAddress {
@Id
@GeneratedValue(strategy= GenerationType.AUTO)
private Long id;
private String address;
@ManyToOne(cascade = CascadeType.ALL)
private User user;
}
再新建一个测试用例,完整代码如下:
package com.example.jpa.example1;
import com.fasterxml.jackson.core.JsonProcessingException;
import org.assertj.core.util.Lists;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.annotation.Rollback;
import javax.transaction.Transactional;
@DataJpaTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class UserAddressRepositoryTest {
@Autowired
private UserAddressRepository userAddressRepository;
@Autowired
private UserRepository userRepository;
/**
* 负责添加数据
*/
@BeforeAll
@Rollback(false)
@Transactional
void init() {
User user = User.builder().name("jackxx").email("123456@126.com").build();
UserAddress userAddress = UserAddress.builder().address("shanghai1").user(user).build();
UserAddress userAddress2 = UserAddress.builder().address("shanghai2").user(user).build();
userAddressRepository.saveAll(Lists.newArrayList(userAddress,userAddress2));
}
/**
* 测试用User关联关系操作
* @throws JsonProcessingException
*/
@Test
@Rollback(false)
public void testUserRelationships() throws JsonProcessingException {
User user = userRepository.getOne(2L);
System.out.println(user.getName());
System.out.println(user.getAddress());
}
}
然后,我们看一下运行结果。
Hibernate: create table user (id bigint not null, email varchar(255), name varchar(255), sex varchar(255), primary key (id))
Hibernate: create table user_address (id bigint not null, address varchar(255), user_id bigint, primary key (id))
Hibernate: alter table user_address add constraint FKk2ox3w9jm7yd6v1m5f68xibry foreign key (user_id) references user
接着我们创建两张表,并且创建外键。
Hibernate: insert into user (email, name, sex, id) values (?, ?, ?, ?)
Hibernate: insert into user_address (address, user_id, id) values (?, ?, ?)
Hibernate: insert into user_address (address, user_id, id) values (?, ?, ?)
这时我们得到了符合预期的三条 inser 语句,可以看到 lazy 起作用了,说明了只有用到 address 的时候才会取重新加载 SQL。
综上,@ManyToOne 的 lazy 机制和用法,与 @OneToOne 的一样,我们就不过多介绍了。而 @ManyToOne 和 @OneToMany 的最佳实践,与 @OneToOne 的完全一样,也是尽量避免双向关联,一切级联更新和 orphanRemoval 都保持默认规则,并且 fetch 采用 lazy 延迟加载。
以上就是关于 @ManyToOne 和 @OneToMan 的讲解,实际开发过程中可以详细体会一下上面老师讲的用法。接下来我们介绍一下 @ManyToMany 多对多关联关系的用法。
@ManyToMany
@ManyToMany 代表多对多的关联关系,这种关联关系任何一方都可以维护关联关系。我们还是先看个例子感受一下。
我们假设 user 表和 room 表是多对多的关系,看看两个实体怎么写。
package com.example.jpa.example1;
import lombok.*;
import javax.persistence.*;
import java.io.Serializable;
import java.util.List;
@Entity
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class User{
@Id
@GeneratedValue(strategy= GenerationType.AUTO)
private Long id;
private String name;
@ManyToMany(mappedBy = "users")
private List<Room> rooms;
}
接着,我们让 Room 维护关联关系。
package com.example.jpa.example1;
import lombok.*;
import javax.persistence.*;
import java.util.List;
@Entity
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = "users")
public class Room {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String title;
@ManyToMany
private List<User> users;
}
然后,我们跑一下测试用例,可以看到如下结果:
Hibernate: create table room (id bigint not null, title varchar(255), primary key (id))
Hibernate: create table room_users (rooms_id bigint not null, users_id bigint not null)
Hibernate: create table user (id bigint not null, email varchar(255), name varchar(255), sex varchar(255), primary key (id))
Hibernate: alter table room_users add constraint FKld9phr4qt71ve3gnen43qxxb8 foreign key (users_id) references user
Hibernate: alter table room_users add constraint FKtjvf84yquud59juxileusukvk foreign key (rooms_id) references room
从结果上我们看到 JPA 帮我们创建的三张表中,room_users 表维护了 user 和 room 的多对多关联关系。其实这个情况还告诉我们一个道理:当用到 @ManyToMany 的时候一定是三张表,不要想着建两张表,两张表肯定是违背表的设计原则的。
那么我们看下 @ManyToMany 的语法。
public @interface ManyToMany {
Class targetEntity() default void.class;
CascadeType[] cascade() default {};
FetchType fetch() default LAZY;
String mappedBy() default "";
}
源码里面字段就这么多,基本和上面雷同,我就不多介绍了。这个时候有的同学可能会问,我们怎么去掉外键索引?怎么改中间表的表名?怎么指定外键字段的名字呢?我们继续引入另外一个注解——@JoinTable。
我先看一下例子,修改一下 Room 里面的内容。
@Entity
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = "users")
public class Room {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String title;
@ManyToMany
@JoinTable(name = "user_room_ref",
joinColumns = @JoinColumn(name = "room_id_x"),
inverseJoinColumns = @JoinColumn(name = "user_id_x")
)
private List<User> users;
}
接着,我们在 Room 里面添加了 @JoinTable 注解,看一下 junit 的运行结果。
Hibernate: create table room (id bigint not null, title varchar(255), primary key (id))
Hibernate: create table user (id bigint not null, email varchar(255), name varchar(255), sex varchar(255), primary key (id))
Hibernate: create table user_room_ref (room_id_x bigint not null, user_id_x bigint not null)
Hibernate: alter table user_room_ref add constraint FKoxolr1eyfiu69o45jdb6xdule foreign key (user_id_x) references user
Hibernate: alter table user_room_ref add constraint FK2sl9rtuxo9w130d83e19f3dd9 foreign key (room_id_x) references room
到这里可以看到,我们创建了一张中间表,并且添加了两个在预想之内的外键关系。
public @interface JoinTable {
//中间关联关系表明
String name() default "";
//表的catalog
String catalog() default "";
//表的schema
String schema() default "";
//维护关联关系一方的外键字段的名字
JoinColumn[] joinColumns() default {};
//另一方的表外键字段
JoinColumn[] inverseJoinColumns() default {};
//指定维护关联关系一方的外键创建规则
ForeignKey foreignKey() default @ForeignKey(PROVIDER_DEFAULT);
//指定另一方的外键创建规则
ForeignKey inverseForeignKey() default @Forei gnKey(PROVIDER_DEFAULT);
}
那么通过上面的介绍,你知道了 @ManyToMany 的用法,然而实际开发者对 @ManyToMany 用得比较少,一般我们会用成对的 @ManyToOne 和 @OneToMany 代替,因为我们的中间表可能还有一些约定的公共字段,如 ID、update_time、create_time等其他字段。
利用 @ManyToOne 和 @OneToMany 表达多对多的关联关系
我们修改一下上面的 Demo,来看一下通过 @ManyToOne 和 @OneToMany 如何表达多对多的关联关系。
我们新建一张表 user_room_relation 来存储双方的关联关系和额外字段,实体如下:
package com.example.jpa.example1;
import lombok.*;
import javax.persistence.*;
import java.util.Date;
@Entity
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class UserRoomRelation {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private Date createTime,udpateTime;
@ManyToOne
private Room room;
@ManyToOne
private User user;
}
而 User 变化如下:
public class User implements Serializable {
@Id
@GeneratedValue(strategy= GenerationType.AUTO)
private Long id;
@OneToMany(mappedBy = "user")
private List<UserRoomRelation> userRoomRelations;
....}
Room 变化如下:
public class Room {
private Long id;
private List<UserRoomRelation> userRoomRelations;
…}
到这里我们再看一下 JUnit 运行结果。
Hibernate: create table user_room_relation (id bigint not null, create_time timestamp, udpate_time timestamp, room_id bigint, user_id bigint, primary key (id))
Hibernate: create table room (id bigint not null, title varchar(255), primary key (id))
Hibernate: create table user (id bigint not null, email varchar(255), name varchar(255), sex varchar(255), primary key (id))
可以看到,上面我们依然创建了三张表,唯一不同的是 user_room_relation 里面多了很多字段,而外键索引也是如约创建,如下所示:
Hibernate: alter table user_room_relation add constraint FKaesy2rg60vtaxxv73urprbuwb foreign key (room_id) references room
Hibernate: alter table user_room_relation add constraint FK45gha85x63026r8q8hs03uhwm foreign key (user_id) references user
好了,跑一下测试是不是就很容易理解了。下面我总结了关于 @ManyToMany 的最佳实践和你分享。
@ManyToMany 的最佳实践
-
上面我们介绍的 @OneToMany 的最佳实践同样适用,我为了说明方便,采用的是双向关联,而实际生产一般是在中间表对象里面做单向关联,这样会让实体之间的关联关系简单很多。
-
与 @OneToMany 一样的道理,不要用级联删除和 orphanRemoval=true。
-
FetchType 采用默认方式:fetch = FetchType.LAZY 的方式。
总结
通过本课时内容,我们基本上能理解 @OneToOne、@ManyToOne、@OneToMany、@ManyToMany 分别表示的是什么关联关系,各自解决的应用场景是什么,以及生产中我们推荐的最佳实践是什么。我们所说的“如何才算正确使用”,重点是要将原理和解决的场景理解透彻,参考最佳实践,做出符合自己业务场景的最好办法。
其实细心的同学还会看出我分享的学习思路,即看协议规定、看源码,然后实际动手写个最小环境进行测试,一看就明白是怎么回事了。在这节课中还涉及了 N+1SQL 的问题,我们在第 25 课时将会详细介绍。
此处给你留一道作业题:仔细去查一下 @OrderColumn 和 @OrderBy 这两个注解是干什么用的,他们的最佳实践是什么?
点击下方链接查看源码(不定时更新)https://github.com/zhangzhenhuajack/spring-boot-guide/tree/master/spring-data/spring-data-jpa