blog/source/_posts/jpa-relation.md

50 KiB
Raw Blame History

title tags categories keywords date
Spring Data JPA中的实体定义与关联
JVM
Java
Spring
Spring Data JPA
实体定义
实体关联
JVM
Java
JVM
Spring
JVM,Java,Spring,Spring Data JPA,Entity,实体,定义,关联 2022-02-11 09:08:14

Spring Data JPA是整个Spring Data系列框架中比较核心而且强大的ORM框架它主要基于Hibernate Core实现了JPAJava持久化接口标准并同时做了一些增强。所以在应用中使用Spring Data JPA的时候主要还是使用JPA所规定的一些规范。例如使用注解定义数据实体与数据库表之间的关联以及数据实体之间的关联关系。

在使用Spring Data JPA的过程中灵活准确的使用定义实体的注解是十分必要的。一套定义灵活完善的注解不仅可以使整个数据实体的体系感更强更可以使数据库的查询得到进一步的优化。所以要灵活准确的定义Spring Data JPA所使用的实体首先就需要了解JPA都提供使用了哪些注解而这一篇文章也主要就是记各种常用注解的使用的。

这里将以一个经典的复杂关联关系数据库结构作为示例来记录和说明JPA中各个常用注解的使用。用作示例的这个数据库结构见以下ER图。

{% oss_image spring-jpa/example-er.svg "示例数据库实体关系图" 750 %}

基本映射注解

在JPA中比较基本的映射注解都是用于直接定义数据实体属性与数据库表字段之间的对应和转换关系的。

@Entity

主要用于标识实体类通知Spring Data JPA将被标识的类作为数据实体对待。可以设置一个名为name的参数用于指定实体类在编写JQL查询语句时的名称如果不设置name那么编写JQL查询的时可以直接使用实体类的类名。

@Table

主要用于设置实体类与数据库表之间的对应关系,@Table注解中常用的参数有以下几个。

  • name,用于指定需要映射到的数据库表名。
  • catalog,用于指定需要映射到的数据库编目名称。
  • schema,用于指定需要映射到的数据库模式名称。
  • uniqueConstraints用于指定数据库表中需要定义的唯一索引这通常在使用Code First方式设计数据库结构的时候会用到。
  • indexes,用于指定数据库表中需要定义的索引,与uniqueConstraints一样也是在使用Code First方式设计数据库的时候会用到。

@Column

主要用于设置实体类属性与数据库表字段之间的映射关系,@Column注解是实体类中最常用到的注解,其中比较常用的参数有以下几个。

  • name设置实体类属性映射的数据库表中的字段名如果省略映射字段名的设置那么JPA将根据程序框架的配置策略进行自动的映射在默认情况下将采用snake_case与camelCase相互转换的策略进行映射。
  • table如果实体类指定了需要使用额外的扩展数据表Secondary Table那么就需要在来自其他数据表的字段映射上显式声明字段所在的数据表否则JPA会默认字段位于主表中。
  • length,字段的长度,一般只在映射字符串类型字段时用到。
  • precision,数值字段的精度位数。
  • scale,数值字段的小数位数。
  • unique,字段是否是一个唯一索引。
  • nullable,字段是否可以保存空值(NULL)。
  • insertable,字段是否可以出现在INSERT语句中,也就是这个字段的值是否可以被新建。
  • updatable,字段是否可以出现在UPDATE语句中,也就是这个字段的值是否可以被更新。

这样结合之前的几个注解,就可以定义以下这个比较简单的实体类了。

@Entity
@Table(name = "Grade")
public class Grade implements Serializable {
  @Id
  @Column(length = 200, nullable = false)
  private String id;

  @Column(nullable = false)
  private LocalDateTime createdAt;

  @Column
  private LocalDateTime modifiedAt;

  @Column
  private LocalDateTime deletedAt

  @Column(length = 50, nullable = false)
  private String name;

  @Column(length = 5, nullable = false)
  private String year;

  @Column(length = 200)
  private String chiefTeacher;
}

!!! caution "" 注意在这个示例中,chiefTeacher属性所对应的数据库表字段实际上是一个外键,也就是说它是需要关联到其他数据表的,但是在这个示例中,暂时先将其当作普通字段来对待。

@Embeddable@Embedded

@Embeddable注解用于定义一个可以共享使用的可嵌入实体片段,即一个可嵌入类,这个可嵌入类可以在多个实体类中使用。如果一个实体类中需要使用到可嵌入类,那么可嵌入类属性必须使用@Embedded注解进行标注。

@EmbeddableEmbedded两个注解不接受任何参数,直接在相应的位置使用即可。

@AttributeOverrides@AuttributeOverride

由于使用@Embeddable注解定义的可嵌入类中已经使用了@Column注解来定义可嵌入类中属性与数据库表字段之间的映射关系,那么在一些团队合作或者应用框架重构升级等情况下,使用相同可嵌入类的实体类所对应的数据库表结构部分可能并不相同。这时就需要使用@AttributeOverrides注解和@AttributeOverride注解来对可嵌入类中属性映射的数据库表字段进行修改。

@AttributeOverrides注解只接受一个AttributeOverride数组作为参数,其中每一条AttributeOverride声明都用于修改可嵌入类中的一个属性。@AttributeOverride注解可以接受一个name参数,用于指定要修改的可嵌入类属性名,还可以接受一个column参数,其值使用@Column注解定义,用于定义属性所要映射的新数据库表字段特征。

对于@AttributeOverrides@AttributeOverride的使用,可以参考以下示例。

// 假定有以下可嵌入类
@Embeddable
@Data
@NoArgsConstructor
public class EntityTiming implements Serializable {
  @Column(nullable = false)
  private LocalDateTime createdAt;

  @Column
  private LocalDateTime modifiedAt;

  @Column
  private LocalDateTime deletedAt;
}

// 那么在使用这个可嵌入类的实体类中可以这样来修改可嵌入类的属性映射
@Entity
@Table(name = "test_table")
public class OldTable implements Serializable {
  @Embedded
  @AttributeOverrides({
    @AttributeOverride(name = "createdAt", column = @Column(name = "create_time", nullable = true)),
    @AttributeOverride(name = "modifiedAt", column = @Column(name = "modify_time")),
    @AttributeOverride(name = "deletedAt", column = @Column(name = "delete_time"))
  })
  private EntityTiming timing;
}

!!! note "" @AttributeOverrides注解和@AttributeOverride注解除了可以使用在可嵌入类属性上以外,还可以直接使用在实体类上。在这种用法中,主要是用来修改实体类从其他实体类处继承来的属性。

@Id

用于标记实体类中映射数据库表中主键的属性,不接受任何参数,而且只可以用于标记数据库表中的单一主键,不能用于标记复合主键。

@IdClass

这个注解需要使用在实体类上,而不是实体类中的属性上。用于指示使用复合主键的实体类其复合主键的组成。如果使用@IdClass设定复合主键的映射,那么定义复合主键的类与实体类中的属性名和属性类型就必须完全一致,并且实体类中的主键列应该使用@Id进行标注。

以前面ER图中的数据库表Work_Record为例,其复合主键可以像以下示例中这样定义。

// 首先定义复合主键类
@Data
@NoArgsConstructor
public class WordRecordId implements Serializable {
  private String workId;
  private String studentId;
}

// 再定义实体类
@IdClass(xyz.archgrid.demo.entity.WorkRecordId.class)
@Entity
@Table(name = "Work_Record")
public class WorkRecord implements Serializable {
  @Id
  @Column(name = "work_id", length = 200, nullable = false)
  private String workId;

  @Id
  @Column(name = "student_id", length = 200, nullable = false)
  private String studentId;

  @Column(nullable = false)
  private LocalDateTime createdAt;

  @Column
  private LocalDateTime modifiedAt;
}

!!! note "" 被用作复合主键定义的类必须重写hashCodeequals两个方法,还需要是可被序列化的,并且需要提供一个公共的无参构造函数。

@EmbeddedId

这个注解需要使用在实体类的属性上,用于指示实体类中整合进来的使用@Embeddable标注的共享实体组成。上面使用@IdClass定义复合主键实体的示例,使用@EmbeddedId注解可以重构成下面这个样子。

// 首先定义嵌入类
@Embeddable
@Data
@NoArgsConstructor
public class WorkRecordId implements Serializable {
  @Column(name = "work_id", length = 200, nullable = false)
  private String workId;

  @Column(name = "student_id", length = 200, nullable = false)
  private Stirng studentId;
}

// 再定义实体类
@Entity
@Table(name = "Work_Record")
public class WorkRecord implements Serializable {
  @EmbeddedId
  private WorkRecordId id;

  @Column(nullable = false)
  private LocalDateTime createdAt;

  @Column
  private LocalDateTime modifiedAt;
}

!!! note "" 用于定义复合主键的可嵌入类在定义的时候同样需要重写hashCodeequals方法,提供一个公共的无参构造函数,并且也需要是可被序列化的。

!!! note "" 与使用@IdClass注解定义复合主键的方法相比,使用@EmbeddedId注解定义复合主键会少定义一次主键列。可以从之前的示例中看出来,使用@IdClass的时候,需要在复合主键类和实体类中重复输入两次主键列对应的属性。而@EmbeddedId注解的缺点,则是带有复合主键的实体实例在使用的时候,因为属性类型的嵌套而使需要敲入的字符数量变多不少。但是总起来说,两种方法相比较而言,还是更加推荐使用@EmbeddedId注解定义使用复合主键的实体。

@GeneratedValue

用于使用@Id注解标记的主键列上,指定主键的生成策略或者主键值生成器。@GeneratedValue注解接受以下两个参数。

  • strategy,设置主键列所要使用的主键生成策略,这个参数所指定的策略与所使用的数据库相关。这个参数只接受枚举类型GenerationType中提供的值,可使用的值有以下这些。
    • AUTO,主键值由程序控制生成。
    • TABLE,主键值由数据库中专用的数据表控制生成。
    • SEQUENCE,利用数据库底层的支持来生成主键值,需要数据库支持序列。
    • IDENTITY,主键值由数据库自动生成,主要将采用自增长策略。
  • generator,设置主键列所要使用的主键值生成器的名称。这里所使用的主键值生成器是由@SequenceGenerator注解标识的生成器类定义的,在设定时只需要使用@SequenceGenerator注解中name参数指定的生成器名称即可。另外generator参数在使用的时候可以搭配Hibernate提供的主键值生成器来使用具体可参考其他相关文章。

@Transient

@Transient标注的属性将被JPA忽略而不会被拼合到数据库查询中也不会被保存到数据库中。

@Temporal

用于标注日期事件类型属性,定义数据库中保存的是何种类型的日期时间值。@Temporal注解通常只用来将java.sql包中的日期时间类型转化到java.util包中的类型,例如java.util.Date,但并不包括新引入的java.time包中的日期时间类型。@Temporal只接受一个类型为TemporalType类型的参数,用于指示从数据库中读取到的数据是java.sql包中的哪一个类。

  • TemporalType.DATE,对应java.sql.Date
  • TemporalType.TIME,对应java.sql.Time
  • TemporalType.TIMESTAMP,对应java.sql.Timpstamp

!!! caution "" 如果在程序中使用java.time包中的新日期时间类来处理日期和时间,那么就无需使用@Temporal注解来指示日期时间的转换,直接定义数据库表字段与实体类属性之间的关系即可。

@Enumerated

用于标注实体类中的枚举类型属性,被标注的枚举类型属性在从数据库读取或者写入数据库的时候,其值将会按照@Enumerated注解接受的EnumType类型的参数决定数据库中值的形态。

EnumType枚举类型提供了两种枚举类型值在数据库中的形态,分别为:

  • ORDINAL,枚举值的索引数字形态,即整型值。
  • STRING,枚举值名称的字符串值。

如果需要在数据库表字段与枚举类型属性之间建立自定义的对应关系,需要利用@Converter注解标注值转换器类,并在枚举类型属性上使用@Convert注解标记属性所需要使用的转换器类。如果在@Converter注解上设置了参数autoApply = true那么JPA将会自动将所有实体类中出现的指定类型都自动应用这个转换器此时需要注意的就是这些属性所对应的数据库表字段的类型是否都一致。

!!! note "" 如果需要自定义转换器类,那么这个转换器类就需要实现AttributeConverter<X, Y>接口。其中X表示实体中所使用的数据类型,Y表示数据库中所使用的数据类型。

继承与分表

继承是面向对象的程序设计的重要特征之一在数据库设计中对于存在继承扩展关系的实体通常可以有两种方法来设计实现。第一种方法是将拥有相同从属关系的实体分别定义到多个表中然后在JPA中通过一个不关联到实际数据表的实体类进行扩展。另一种是将所有相似的实体都保存在一张数据表中但是通过一个或者几个分类列可以在逻辑上将一张表内的数据分开。

在JPA中针对这两种继承的实现方法分别提供了不同的注解。

@MappedSuperclass

@MappedSuperclass注解主要用于继承自相同实体的不同子代实体都保存在不同的数据表中,而且父级实体并不关联到实际的数据库表。@MappedSuperclass注解在使用的时候需要标注在父级实体上。

以下来借助前面ER图中的MemberTeacherStudent三个数据表来说明如果采用不同的数据表保存各自的数据在JPA中需要怎样来定义。首先是定义Member实体类。

@MappedSuperclass
public class Member implements Serializable {
  @Id
  @Column(length = 200 nullable = false)
  protected String id;

  @Embedded
  protected EntityTiming timing;

  @Column
  protected LocalDateTime disabledAt;

  @Column(length = 50, nullable = false)
  protected String name;

  @Column(length = 3, nullable = false)
  protected String role;

  @Column(length = 50, nullable = false)
  protected String username;

  @Column(length = 200, nullable = false)
  protected String password;
}

!!! caution "" 注意,在使用@MappedSuperclass注解定义父级实体类的时候,不需要使用@Entity注解和@Table注解,因为父级实体类不是一个完整的实体,而且也不对应数据库中的任何一张表。

然后利用继承,定义各个子代实体类。

@Entity
@Table(name = "Teacher")
public class Teacher extends Member {
  @Column(length = 200, nullable = false)
  private String subjectId;
}

@Entity
@Table(name = "Student")
public class Student extends Member { }

从这两个子代实体类可以看出,使用继承的方法,可以大大降低子代实体类的编码压力。子代实体类只需要在父级实体类的基础上,编写自身不同的代码即可。但是这种方法的缺点是拥有相同数据结构的实体被分别放在了多个数据表内,这在数据库设计看来,有那么一些不够内聚。

@DiscriminatorColumn

@DiscriminatorColumn注解是标记在子代实体类上的,而且这种使用分类列的实体类继承定义方法,正好与前面的@MappedSuperclass注解相反。使用分类列定义实体类的时候,所有的数据实体是保存在同一张数据表中的,不同类别的数据相互之间仅通过分类列进行区分。

这里依旧使用前面ER图中的MemberTeacherStudent三个数据表来说明。首先还是定义父级实体类。

@Entity
@Table(name = "Member")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "role", discriminatorType=DiscriminatorType.STRING, length = 3)
public class Member implements Serializable {
  @Id
  @Column(length = 200 nullable = false)
  protected String id;

  @Embedded
  protected EntityTiming timing;

  @Column
  protected LocalDateTime disabledAt;

  @Column(length = 50, nullable = false)
  protected String name;

  @Column(length = 3, nullable = false)
  protected String role;

  @Column(length = 50, nullable = false)
  protected String username;

  @Column(length = 200, nullable = false)
  protected String password;
}

现在这个父级实体类看起来就跟之前的不一样了,既有了@Entity注解,也有了@Table,这就说明Member实体类在数据库中是有其对应的数据表的。然后再来看看子代实体类是怎样定义的。

@Entity
@Table(name = "Member")
@DiscriminatorValue("tea")
public class Teacher extends Member {
  @Column(length = 200, nullable = false)
  private String subjectId;
}

@Entity
@Table(name = "Member")
@DiscriminatorValue("stu")
public class Student extends Member { }

从这两个子代实体类的定义可以看出,子代实体类也是具有@Entity注解和@Table注解的,而且@Table注解的参数与父级实体类的相同,这就说明子代实体类的数据将保存到父级实体类定义的数据表中。不同类型的子代实体类,是通过@DiscriminatorValue注解来区分的,这个注解会在实体类被使用的时候,将给定的值与父级实体类中@DiscriminatorColumn注解定义的分类列组成查询条件,从而对子代实体类进行区分。

在这个示例中还能够注意到一个新出现的注解:@Inheritance,这个注解用于声明子代实体类是如何被保存放置的。在这个示例中,这个注解被给定了InheritanceType.SINGLE_TABLE的值,这就说明所有的子代实体类数据都将保存在一张数据表内。那么另外的,这个注解还可以使用另一个值:InheritanceType.JOINED这个值会在子代实体类的数据表中放置一个外键外键映射到父级实体类数据表的主键上然后将子代实体类的内容保存到子代实体类自己专用的数据表中父级实体类数据表与子代实体类数据表之间采用父级实体类的ID进行关联查询。

!!! note "" 如果需要自定义子代实体类中外键的字段名称,可以在子代实体类上使用@PrimaryKeyJoinColumn注解来自定义一个名称。

选择不同的@Inheritance注解的值,将会给实体的查询带来不同的性能影响。如果所有的实体类都保存在一张数据表中,那么将会面临的问题是,这张数据表中将需要包罗万象,要设置足够多的字段来存放所有子代实体类所拥有的不同的字段。而如果将所有的子代实体类的个性字段都放置在各自独立的数据表中时,多表之间的关联操作又会降低数据查询的速度。但是通过分类列来区分不同实体类的数据存储方法,在数据量级比较大的时候,数据的检索性能将会变成一个主要的优化目标。

实体关联

在实际的数据库操作中实体从来都不只是单独使用的实体与实体之间往往存在着复杂的关联关系。这也是现实世界通过数据结构描述出来的必然。在传统研究讨论数据库设计的时候实体间的关联关系通常会被分类为一对一、一对多、多对一和多对多四种。JPA也针对这四种关联关系设计了一系列的注解。

@JoinCloumns@JoinCloumn

@JoinColumns@JoinColumn注解是用来定义实体之间连接列的,一般只要是出现需要配置实体之间连接的地方,都可以看到这两个注解的出现。@JoinColumns注解比较简单如果不使用Code First方式设计数据库话那么@JoinColumns注解就只接受一个类型为JoinColumn数组的参数,用于定义两个实体之间多于一个的连接列,每个连接列都需要使用一个@JoinColumn注解定义。

两个实体之间的连接列是使用@JoinColumn配置的,@JoinColumn注解一般都会配合@OneToOne@OneToMany等注解一起使用。@JoinColumn注解通常可以接受以下参数。

  • name,配置连接一方连接实体中的数据表列名,此处所需要书写的数据表列名选择与@JoinColumn搭配的声明关联关系的注解有关。
  • referencedColumnName,配置连接的另外一方连接实体中的数据表列明,同样与声明关联关系的注解有关。
  • unique,连接列是否是唯一的,通常只在对一映射中有用。
  • nullable,配置外键列是否可以为空。
  • insertable,配置连接列是否可以出现在插入语句中。
  • updatable,配置连接列是否可以出现在更新语句中。
  • table,配置外键列所在的数据表,同样与声明关联关系的注解有关。

实际上在日常实体关联关系声明中,使用最多的是namereferencedColumnName两个参数,具体@JoinColumn注解的使用,将后面声明实体关联关系的注解中配套说明。

@JoinTable

@JoinTable注解是用来描述数据库设计中的另外一种常用的关联关系的设计方式连接表。连接表中每一条记录放置的都是两个实体的主键两个实体通过连接表中的记录描述其各自数据记录对对方数据表中数据记录的关联关系。连接表在数据库中是一个实实在在的物理数据表并不是一个虚拟的表。因为ORM框架一般需要对每个物理数据表构建映射实体所以连接表也不例外但是连接表因为其功能非常单一为其创建一个独立的实体其实不论是从实体类设计还是未来的数据操作上都是十分不划算的。@JoinTable注解就免去了为连接表创建独立实体的需要,它可以直接在实体类中声明关联关系的属性上使用。

@JoinTable注解在使用的时候主要接受以下几个参数。

  • name,用于声明连接表的名称。
  • catalog,用于声明连接表所在的数据库编目名称。
  • schema,用于声明连接表所在的数据库模式名称。
  • joinColumns,用于声明拥有关联的连接列。
  • inverseJoinColumns,用于声明不拥有关联的连接列。

!!! note "" 几乎所有使用一个或者多个连接列就可以完成数据表关联关系定义的地方,都可以改成使用一个额外的关联表来定义关联关系,只是定义变得更加啰嗦了而已。

@ForeignKey

外键是一种保存在数据库中的约束关系持有关联到其他数据表的外键可以使两个数据表之间的数据建立一一对应的约束关系并在数据发生变化的时候自动关联的发生跟随的变化。但是在目前的数据库设计中已经很少再采用外键定义数据表之间的约束关系了这主要是因为大量存在的外键虽然可以使不同数据表之间的数据发生自动的联动但是在数据库整体的维护上却制造了相当大的麻烦尤其是外键带来的数据表之间的依赖关系在数据库迁移的时候就必须考虑数据表迁移的先后顺序。所以虽然在大部分数据库ER图中依旧使用<<FK>>标记外键但是在实际数据库构建和ORM实体构建的时候已经使用逻辑外键代替了物理外键。

逻辑外键在构建数据库和ORM实体的时候无需任何特殊的标记和声明在JPA中也只需要使用@JoinColumn声明如何使用即可。但如果在JPA中使用到了物理外键那么就需要在ORM中使用@ForeignKey来标记。

@OneToOne

一对一关联是实体关联关系中最简单的一种,一对一关联关系使用@OneToOne注解进行标注。在@OneToOne注解中,常用的参数主要有以下这些,但是需要注意的是,这里大部分参数在其他的关联关系注解中也是同样用得到且含义相同的。

  • targetEntity定义所需要关联到的目标实体类。这个参数一般不必显式设置JPA会根据被标注的属性类型找到目标实体类。
  • cascade,定义级联操作的级别,与数据库中的级联操作级别含义和功能是相同的,使用CascadeType枚举类中的值设置,可取的值有以下这些:
    • CascadeType.ALL,级联所有的操作。
    • CascadeType.PERSIST,级联保存保存操作。
    • CascadeType.MERGE,级联合并操作。
    • CascadeType.REMOVE,级联删除操作。
    • CascadeType.REFRESH,级联更新操作。
    • CascadeType.DETACH,不进行任何级联操作。
  • fetch设定JPA获取关联实体内容的策略使用FetchType枚举类中的值设置,可取的值有以下这些:
    • FetchType.EAGER,积极加载关联实体内容。积极加载策略在使用的时候需要十分小心,因为在复杂实体关联条件下,数据库需要运行大量的关联语句来获取数据,这样将会使数据库的性能受到比较大的影响。
    • FetchType.LAZY,惰性加载关联实体内容。惰性加载策略在使用的时候具有比较优秀的数据库压力,但是在程序中需要关注获取关联实体内容时程序所处的数据库事务边界。惰性加载策略最经常出现的问题就是数据库事务不一致或者数据库事务不存在。
  • optional,设定关联实体是否必须始终存在,如果关联列是可空的,那么关联实体就不一定必须存在了。
  • mappedBy,设定定义实体关联关系的注解放置在被关联实体类的哪个属性上。

!!! note "" 如果在实体中需要构建与其他实体的关联,那么一般不需要在实体中再额外单独定义映射到连接列的属性,直接定义所关联到的实体的属性即可。

这里依旧利用前面ER图中TeacherTeacher_Detail之间的一对一关联关系来说明@OneToOne注解的使用。

// 这里采用@MappedSuperclass注解的方式定义Member实体Member实体的内容与前面的示例中一致
@Entity
@Table(name = "Teacher")
public class Teacher extends Member {
  @Column(length = 200, nullable = false)
  private String subjectId;

  @OneToOne(mappedBy = "teacher")
  private TeacherDetail details;
}

@Entity
@Table(name = "Teacher_Detail")
public class TeacherDetail {
  @Id
  @Column(length = 200, nullable = false)
  private String teacherId;

  @Column(length = 3)
  private String sex;

  @Column
  private LocalDate birth;

  @Column
  private LocalDate entryTime;

  @Column(length = 20)
  private String phone;

  @OneToOne
  @JoinColumn(name = "teacher_id", referencedColumnName = "id")
  private Teacher teacher;
}

其实任何实体之间的关联关系都可以是双向的,也可以是单向的。上面示例中使用了一种十分不必要的方式演示了双向一对一关联关系的定义方法。在一对一关联中,@JoinColumn注解一般放置在主动发起关联的一方,对于双向一对一关联来说,可以放置在任意一方。在一对一的关联定义中,@JoinColumn注解的name参数中需要设定当前注解所在实体所映射的数据表的字段名称,referencedColumnName参数设定被关联实体所映射的数据表字段的名称。例如在这个示例中,name参数设定的就是Teacher_Detail表中的teacher_id字段,referencedColumnName参数设定的就是Teacher表中的id字段。

在双向关联中,只需要在其中一方实体中设置连接列即可,另外一方可以直接通过关联关系注解中的mappedBy参数指示连接列的定义位置。mappedBy参数的值需要写被关联实体类中使用@JoinColumn注解标记的属性名称。如果@JoinColumn注解位于一个可嵌入类中,那么mappedBy中可以使用.来进行更深层次的访问,就如同操作一个对象的属性一般。

@PrimaryKeyJoinColumn

其实对于上面的这个示例来说,因为在Teacher_Detail表中直接复制了Teacher表的主键作为其主键以及关联列那么在JPA中就可以使用@PrimaryKeyJoinColumn来简化关联列的声明。这样一来,上面的这个示例就可以简化成下面的样子。

@Entity
@Table(name = "Teacher")
public class Teacher extends Member {
  @Column(length = 200, nullable = false)
  private String subjectId;

  @OneToOne(mappedBy = "teacher")
  @PrimaryKeyJoinColumn
  private TeacherDetail details;
}

@Entity
@Table(name = "Teacher_Detail")
public class TeacherDetail {
  @Id
  @Column(length = 200, nullable = false)
  private String teacherId;

  @Column(length = 3)
  private String sex;

  @Column
  private LocalDate birth;

  @Column
  private LocalDate entryTime;

  @Column(length = 20)
  private String phone;

  @OneToOne
  @MapsId
  @JoinColumn(name = "teacher_id")
  private Teacher teacher;
}

@PrimaryKeyJoinColumn注解用在值被复制的主键列所在的实体中的关联属性上,用于声明当前实体的主键列是要被用作实体关联和连接列的值来源的。在Teacher_Detail实体中,则使用@MapsId注解来声明对方实体的连接列是主键列,这样在@JoinColumn注解中,就不需要再使用referencedColumnName参数来声明对方实体的连接列了。

!!! caution "" 在单向一对一连接中,还是老老实实的用@JoinColumn定义连接吧。

@SecondaryTables@SecondaryTable

将一个实体的内容分散到多个数据表中保存是数据库设计中一种分表的优化手段。进行分表存储的实体其数据在使用的时候也是需要通过关联合并在一起的所以在设计使用饿时候同样需要注意性能问题。因为分表是一种比较特殊的关联关系所以在JPA中对这种设计手段直接提供了特殊的支持。

通过在实体类上标注@SecondaryTables@SecondaryTable注解可以使JPA知道实体都分散在哪些数据表中并且可以通过关联获取使其形成一个统一的实体。这里可以参考一下之前的示例如果需要从Teacher类实例中访问Teacher_Detail类实例的内容,就需要通过其中的details属性来访问,这样看起来虽然Teacher_Detail类中的内容也是Teacher类的组成部分,但是看起来还是比较的割裂。使用@SecondaryTable注解来声明两个数据表之间的关系,就可以使其整合起来。

@SecondaryTables比较简单,其主要用于接受一个由@SecondaryTable注解定义组成的数组,表示实体的内容都分布在了哪些数据表(从表)上。@SecondaryTable注解才是定义数据表的主力,其中常用的参数主要有以下这几个。

  • name,用于声明数据表的名称。
  • catalog,用于声明数据表所在的数据库编目名称。
  • schema,用于声明数据表所在的数据库模式名称。
  • pkJoinColumns,用于声明数据表中关联主表主键列的连接列名称。

使用@SecondaryTable注解建立两个数据表之间的关联,要求从属的数据表必须复制主表主键的值作为其主键的值,也就是前面@PrimaryKeyJoinColumn注解示例中的效果。

使用@SecondaryTable注解,可以使之前的示例变得更加简单。

@Entity
@Table(name = "Teacher")
@SecondaryTable(name = "Teacher_Detail", pkJoinColumns = @PrimaryKeyJoinColumn(name = "teacher_id"))
public class Teacher extends Member {
  @Column(length = 200, nullable = false)
  private String subjectId;

  @Column(length = 3, table = "Teacher_Detail")
  private String sex;

  @Column(table = "Teacher_Detail")
  private LocalDate birth;

  @Column(table = "Teacher_Detail")
  private LocalDate entryTime;

  @Column(length = 20, table = "Teacher_Detail")
  private String phone;
}

!!! caution "" 使用@SecondaryTable注解的时候,映射放置在从表中的字段,必须在@Column注解中使用table参数设置其所在的从表表名。

!!! note "" 使用分表的设计策略其实在实际使用中也会遇到因为关联导致的数据库性能问题,一个比较好的解决方法是根据所使用到的数据内容,定义包含不同从表关联的实体,每次在获取数据的时候,都选择使用获取数据量最小的实体,这样就可以在一定程度上优化数据库性能。

@OneToMany

一对多关联关系通常是表示批量数据从属关系的关联关系例如前面ER图中Subject表与Work表之间的关系,一条Subject记录可以对应数条Work记录。这种一对多的关联关系,是使用@OneToMany注解来定义的。

但是在定义一对多关联关系之前,需要先进行一个特别的说明。一对多的关联关系,其单向关联和双向关联的定义是不一样的。在双向关联关系定义的时候,关联列通常都是定义在“多”的一方,例如Subject实体和Work实体之间的关联,其关联列是在Work实体中定义的,而Subject实体中只需要使用mappedBy指示关联关系定义属性即可。

@OneToMany注解所接受的参数与@OneToOne注解相同,功能也相同,这里不再重复说明。

以下通过Subject实体与Work实体的关联关系,说明双向一对多关联关系的定义方法。

@Entity
@Table(name = "Subject")
public class Subject implements Serializable {
  @Id
  @Column(length = 200, nullable = false)
  private String id;

  @Column(length = 50)
  private String name;

  // 注意Subject这边是“一”一方不是“多”的一方
  @OneToMany(mappedBy = "subject")
  private List<Work> works;
}

@Entity
@Table(name = "Work")
public class Work implements Serializable {
  @Id
  @Column(length = 200, nullable = false)
  private Stirng id;

  @Embedded
  private EntityTiming timing;

  @Column
  private LocalDateTime publishedAt;

  @Column
  private String publishedBy;

  // 如果对方的连接列是主键那么这里的referencedColumnName可以省略
  @ManyToOne
  @JoinColumn(name = "subject", nullable = false)
  private Subject subject;
}

如果所要定义的关联关系是单向一对多关系,例如仅从Subject表获取关联的Work表内容,那么连接列的定义就只需要出现在“一”的一方,但是这会儿@JoinColumn的设置就变得不一样了,具体可见以下示例。

@Entity
@Table(name = "Subject")
public class Subject implements Serializable {
  @Id
  @Column(length = 200, nullable = false)
  private String id;

  @Column(length = 50)
  private String name;

  @OneToMany
  @JoinColumn(name = "subject", referencedColumName = "id")
  private List<Work> works;
}

在使用@OneToMany定义单向一对多关联关系的时候,@JoinColumn中的name参数需要写目标实体类所映射的数据表中的外键列名称,referencedColumnName参数需要写本实体类所映射的数据表中的主键列。这与之前一对一关联关系和双向一对多关联关系中的@JoinColumn中的参数书写是相反的。

!!! note "" 可以这样理解和记忆,在定义一对多的关联关系时,连接列一般都在多的一方定义,所以不管定义在哪个实体里,name参数所声明的都是“多”的一方数据表中的外键列名称,referencedColumnName参数都是声明“一”的一方主键列的名称。

@ManyToOne

@ManyToOne注解比较简单,一般都是以@OneToMany注解的对向注解身份出现的,用来在“多”的一方定义关联关系。单独使用@ManyToOne注解定义单向多对一关联关系实际上是没有什么意义的。

@ElementCollection@CollectionTable

用于标注实体类中的基本类型集合与可嵌入类型集合属性。与定义一对多关系的@OneToMany注解不同的是,@ElementCollection注解所映射的目标类型不是实体类,而@OneToMany注解所映射的目标类型是一个实体类。

使用@ElementCollection注解标注的属性,其内容依旧还是要保存到一个独立的数据库表中的,默认情况下这个数据库表将使用实体类的名称加上属性名称作为表名。但是如果需要自定义表名,就需要使用@CollectionTable注解来指定一个数据库表名称了。而用于保存集合内容的数据表中的字段名,默认也是使用实体的主键和集合属性的名称(可以使用@Column注解指定集合属性对应的字段名称),如果也同样需要自定义,那么可以使用@AttributeOverrides注解和@AttributeOverride注解来修改。

@ElementCollection注解可以接受以下两个参数,但这两个参数都不是必需的。

  • targetClass,当@ElementCollection注解用来映射一个可嵌入类的时候,就需要使用这个参数来指定目标可嵌入类。
  • fetch,接受一个类型为FetchType类型的值用于指示JPA在何时获取这个属性的值。

@CollectionTable注解除了可以接受声明数据表名的namecatalogschema以外,还可以接受一个用于定义关联连接列数组的joinColumns参数。

这里使用前面ER图中Subject表和Work表之间的单向关联关系来举例说明@ElementCollection的使用。注意,在这个示例中,Work表中的内容不能以实体的形式出现,只能以可嵌入类的形式出现,并且还需要舍弃其中的id字段。

@Embeddable
public class Work implements Serializable {
  @Embedded
  private EntityTiming timing;

  @Column
  private LocalDateTime publishedAt;

  @Column
  private String publishedBy;
}

@Entity
@Table(name = "Subject")
public class Subject implements Serializable {
  @Id
  @Column(length = 200, nullable = false)
  private String id;

  @Column(length = 50)
  private String name;

  @ElementCollection
  @CollectionTable(name = "Work", joinColumns = @JoinColumn(name = "subject"))
  private List<Work> works;
}

!!! caution "" 注意,这里所使用的@JoinColumn注解中的name参数,依旧书写的是“多”的一方的外键列名称。

@ManyToMany

多对多关联是实体关联关系中最复杂的。多对多关联关系一般都需要借助一个独立的关联表来记录两个数据表之间的关联关系。所以用于定义多对多关联关系的@ManyToMany注解通常都是与@JoinTable注解搭配使用的。@ManyToMany注解的使用与之前的@OneToOne@OneToMany注解在传递的参数上没有什么区别。

由于多对多关联关系的双方都是“多”的一方,所以连接表的定义可以放置在任何一方实体属性上。在习惯上会选择关联关系的被连接方来放置,因为一般关联关系的主动发起方实体中,可能会存在多个关联关系定义,如果连接列和连接表的定义都放置在关联主动发起方,那么关联关系主动发起方的实体可能会变得更加难以阅读。

这里以Student实体与Class实体之间的多对多关联关系为例来说明多对多关联关系的定义方法。

@Entity
@Table(name = "Member")
@DiscriminatorValue("stu")
public class Student extends Member {
  // @JoinTable注解中joinColumns中需要书写@JoinTable注解所在实体在关联表中对应的外键列
  // inverseJoinColumn中书写目标实体在关联表中的外键列。
  @ManyToMany
  @JoinTable(
    name = "Class_Student",
    joinColumns = @JoinColumn(name = "student_id"),
    inverseJoinColumns= @JoinColumn(name = "class_id")
  )
  private Set<Class> class;
}

@Entity
@Table(name = "Class")
public class Class implements Serializable {
  @Id
  @Column(length = 200, nullable = false)
  private String id;

  @Embedded
  private EntityTiming timing;

  @Column(length = 50, nullable = false)
  private String name;

  @ManyToOne
  @JoinColumn(name = "grade_id")
  private Grade grade;

  @Column(length = 3, nullable = false)
  private String type;

  @OneToOne
  @JoinColumn(name = "chief_teacher", referencedColumnName = "id")
  private Teacher chiefTeacher;

  @ManyToMany(mappedBy = "classes")
  private Set<Student> students;
}

@AssociationOverrides@AssociationOverride

@AossociationOverrides@AssociationOverride注解的功能与之前的@AttributeOverrides@AttributeOverride注解十分类似,只是其修改的目标是实体之间的关联关系。这种修改也主要是来自可嵌入类中定义的关联关系,但是在实际实体中使用的时候,数据表中的字段名称可能会跟可嵌入类中所声明的不同。

@AssociationOverride注解在使用的时候主要使用其中的三个参数。

  • name,指定注解所需要修改的目标属性名称。
  • joinColumns,指定新的关联关系列。
  • joinTable,指定新的关联关系表。

定义Map类型的属性

在之前的集合属性映射中,都是将其映射成了一个List或者Set,但是这种映射方法其实并不利于在一个集合中对一个特定内容的快速定位。实际上,如果要实现对集合中内容的快速定位,最好的解决方案就是使用Map,将用于索引的内容设置为Map的键。

其实将关联的实体集合改成Map的形式的关键是定义每一个实体元素对应的键值。JPA提供了以下几个注解来指定如何从实体中提取键值。

  • @MapKey,指定实体中的属性作为实体元素的键值,如果省略参数,则默认将实体元素的主键作为键值使用。
  • @MapKeyColumn,指定实体所映射的数据表中的某一个字段的值作为实体元素的键值。
  • @MapKeyJoinColumn,通过指定一个连接列来使用一个关联的实体类作为实体元素的键值。
  • @MapKeyJoinColumns,跟@MapKeyJoinColumn的功能相同,但是用于指定多个连接列来关联一个实体类。
  • @MapKeyEnumerated,使用一个枚举类来所谓实体元素的键值。

保持集合内容的排序

对于@OneToMany@ManyToMany@ElementCollection这三个注解标注的集合属性来说其中元素的顺序往往会被打乱。为了保持集合中元素的顺序JPA提供了两个注解@OrderBy@OrderColumn来定义集合元素在从数据库中取出的时候,要如何对其进行排序。

@OrderBy注解默认情况下使用被关联实体中的主键进行排序如果需要使用其他的字段进行排序可以按照SQL语句中ORDER BY子句的语法来编写@OrderBy注解的参数内容。

@OrderColumn注解则是通过name参数来指定排序使用的字段,默认情况下采用字段的升序排序,如果需要采用显式的顺序定义,那么就需要使用字段名_顺序的格式来指定。

比较完整的示例

之前各个章节中所出现的示例都是比较零碎的有些也并没有完全按照ER图的设计实现。这里将完全按照ER图的设计一一实现。由于前面的ER图中是存在复交负载的关联关系和依赖关系的所以以下所有的实体类的定义不分先后均平行存在。

这里为了精简示例代码都省略了使用Lombok标记以及全部自定义的方法。

// 定义所有实体中共用的记录实体操作时间的可嵌入类
@Embeddable
public class EntityTiming implements Serializable {
  @Column(nullable = false)
  protected LocalDateTime createdAt;

  @Column
  protected LocalDateTime modifiedAt;

  @Column
  protected LocalDateTime deletedAt;
}

// 定义学校中的年级
@Entity
@Table(name = "Grade")
public class Grade implements Serializable {
  @Id
  @Column(length = 200, nullable = false)
  private String id;

  @Embedded
  private EntityTiming timing;

  @Column(length = 50, nullable = false)
  private String name;

  @Column(length = 5, nullable = false)
  private String year;

  @OneToOne
  @JoinColumn(name = "chief_teacher", referencedColumnName = "id")
  private Teacher chiefTeacher;

  @OneToMany(mappedBy = "grade")
  private Set<Class> classes;
}

// 定义不同的班级类型(行政班、教学班)
// 这里存在一个概念,每个班级都有一个负责教师,
// 每个学生必须隶属于一个行政班,但每个学生可以同时隶属于零个或者多个教学班
public enum ClassType {
  ADMINISTRATIVE("adm"), TEACHING("tea");

  private String type;

  private ClassType(String type) {
    this.type = type;
  }
}

// 定义年级中的班级
@Entity
@Table(name = "Class")
public class Class implements Serializable {
  @Id
  @Column(length = 200, nullable = false)
  private Stirng id;

  @Embedded
  private EntityTiming timing;

  @Column(length = 50, nullable = false)
  private String name;

  @ManyToOne
  @JoinColumn(name = "grade_id", referencedColumnName = "id")
  private Grade grade;

  @Column(length = 3, nullable = false)
  private ClassType type;

  @OneToOne
  @JoinColumn(name = "chief_teacher", referencedColumnName = "id")
  private Teacher chiefTeacher;

  @ManyToMany(mappedBy = "classes")
  private Set<Student> students;
}

// 教师与学生都保存在Member表中故这里定义其公共实体
@Entity
@Table(name = "Member")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "role", discriminatorType=DiscriminatorType.STRING, length = 3)
public class Member implements Serializable {
  @Id
  @Column(length = 200 nullable = false)
  protected String id;

  @Embedded
  protected EntityTiming timing;

  @Column
  protected LocalDateTime disabledAt;

  @Column(length = 50, nullable = false)
  protected String name;

  @Column(length = 50, nullable = false)
  protected String username;

  @Column(length = 200, nullable = false)
  protected String password;
}

// 定义教师信息表,同时连入教师详细信息表
@Entity
@Table(name = "Member")
@DiscriminatorValue("tea")
@SecondaryTable(name = "Teacher_Detail", pkJoinColumns = @PrimaryKeyJoinColumn(name = "teacher_id"))
public class Teacher extends Member {
  @Column(length = 200, nullable = false)
  private String subjectId;

  @Column(length = 3, table = "Teacher_Detail")
  private String sex;

  @Column(table = "Teacher_Detail")
  private LocalDate birth;

  @Column(table = "Teacher_Detail")
  private LocalDate entryTime;

  @Column(length = 20, table = "Teacher_Detail")
  private String phone;

  @ManyToOne
  @JoinColumn(name = "subject_id")
  private Subject subject;

  @OneToMany(mappedBy = "publishedBy")
  @OrderBy("timing.createdAt ASC")
  private List<Work> publishedWorks;
}

// 定义学生信息表,同时连入学生详细信息表
@Entity
@Table(name = "Member")
@DiscriminatorValue("stu")
@SecondaryTable(name = "Student_Detail", pkJoinColumn = @PrimaryKeyJoinColumn(name = "student_id"))
public class Student extends Member {
  @Column(length = 3, table = "Student_Detail")
  private String sex;

  @Column(table = "Student_Detail")
  private LocalDate birth;

  @Column(table = "Student_Detail")
  private LocalDate enrolledAt;

  @Column(table = "Student_Detail")
  private LocalDate graduatedAt;

  @Column(length = 50, table = "Student_Detail")
  private String guardianName;

  @Column(length = 20, table = "Student_Detail")
  private String guardianPhone;

  @ManyToMany
  @JoinTable(
    name = "Class_Student",
    joinColumns = @JoinColumn(name = "student_id"),
    inverseJoinColumns = @JoinColumn(name = "class_id")
  )
  private Set<Class> classes;

  @OneToMany
  @JoinColumn(name = "student_id", referencedColumnName = "id")
  private List<WorkRecord> workRecords;
}

// 定义学科信息,其中可以查询任课教师和已经布置的本学科下的作业
@Entity
@Table(name = "Subject")
public class Subject implements Serializable {
  @Id
  @Column(length = 200, nullable = false)
  private String id;

  @Column(length = 50, nullable = false)
  private String name;

  @OneToMany(mappedBy = "subject")
  private Set<Teacher> teachers;

  @OneToMany(mappedBy = "subject")
  @OrderBy("timing.createdAt ASC")
  private List<Work> works;
}

// 定义作业基本信息
@Entity
@Table(name = "Work")
public class Work implements Serializable {
  @Id
  @Column(length = 200, nullable = false)
  private String id;

  @Embedded
  private EntityTiming timing;

  @Column
  private LocalDateTime publishedAt;

  @ManyToOne
  @JoinColumn(name = "published_by")
  private Teacher publishedBy;

  @ManyToOne
  @JoinColumn(name = "subject")
  private Subject subject;

  // 利用指定的列,对获取到的集合进行分组
  @OneToMany(mappedBy = "work")
  @MapKeyColumn(name = "student_id")
  private Map<String, WorkRecord> records;
}

// 定义寻胜完成作业的记录信息实体使用的复合主键
@Embeddable
public class WorkRecordId implements Serializable {
  @Column(length = 200, nullable = false)
  private String workId;

  @Column(length = 200, nullable = false)
  private String studentId;
}

// 定义学生完成作业的记录信息
@Entity
@Table(name = "Work_Record")
public class WorkRecord implements Serializable {
  @EmbeddedId
  private WorkRecordId id;

  @Embedded
  private EntityTiming timing;

  @ManyToOne
  @MapsId("studentId")
  private Student student;

  // 直接利用复合主键中的属性定义关联
  @ManyToOne
  @MapsId("workId")
  private Work work;
}

!!! caution "" 类似于这样定义的实体类在使用的时候尽量不要将其直接转换为JSON因为实体类中存在的双向关联关系可能会在转译成JSON时形成无限循环引用的情况。此时可以使用一些JSON控制注解例如Jackson库提供的@JsonBackReference,来避免这种循环引用的情况。

!!! note "需要注意的一些事情" 如果想要定义一个包罗万象的大而全的实体类,那么其中的关联属性一定要使用惰性加载的获取模式,否则你的数据库在每次使用这个实体类的时候都会刮起一阵查询风暴;而且在操作实体类的过程中,一定不能让这个实体类的实例超出数据库事务边界,否则就会有极大的概率获得一个事务不一致或者没有事务可用的异常。一个比较好的解决方法是使用代理对象将其转换为不与EntityManager关联的DTO对象。