Spring Data JPA 之 实体之间关联关系

Spring Data JPA 之 实体之间关联关系

实体与实体之间的关联关系⼀共分为四种,分别为 OneToOne、OneToMany、ManyToOne 和 ManyToMany;⽽实体之间的关联关系⼜分为双向的和单向的。实体之间的关联关系是在 JPA 使⽤中最容易发⽣问题的地⽅,接下来我将⼀⼀揭晓并解释。我们先看⼀下 OneToOne,即⼀对⼀的关联关系。

7.1 @OneToOne

@OneToOne ⼀般表示对象之间⼀对⼀的关联关系,它可以放在 field 上⾯,也可以放在 get/set ⽅法上⾯。其中 JPA 协议有规定,如果是配置双向关联,维护关联关系的是拥有外键的⼀⽅,⽽另⼀⽅必须配置 mappedBy;如果是单项关联,直接配置在拥有外键的⼀⽅即可。

举个例⼦:user 表是⽤户的主信息,user_info 是⽤户的扩展信息,两者之间是⼀对⼀的关系。user_info 表⾥⾯有⼀个 user_id 作为关联关系的外键,如果是单项关联,我们的写法如下:

@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 的代码如下:

@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;

我们跑任何⼀个测试⽤例,就会看到运⾏结果是⼀样的,还是上⾯三条 sql。那么我们再查看⼀下 @OneToOne 源码,看看其⽀持的配置都有哪些。

7.1.1 @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; 
}
7.1.2 mappedBy 的注意事项

只有关联关系的维护⽅才能操作两个实体之间外键的关系。被维护⽅即使设置了维护⽅属性进⾏存储也不会更新外键关联。

mappedBy 不能与 @JoinColumn 或者 @JoinTable 同时使⽤,因为没有意义,关联关系不在这⾥⾯维护。

此外,mappedBy 的值是指另⼀⽅的实体⾥⾯属性的字段,⽽不是数据库字段,也不是实体的对象的名字。也就是维护关联关系的⼀⽅属性字段名称,或者加了 @JoinColumn/@JoinTable 注解的属性字段名称。如上⾯的 User 例⼦ user ⾥⾯ mappedBy 的值,就是 UserInfo ⾥⾯的 user 字段的名字。

7.1.3 CascadeType 的用法

在 CascadeType 的⽤法中,CascadeType 的枚举值只有五个,分别如下:

  1. CascadeType.PERSIST 级联新建
  2. CascadeType.REMOVE 级联删除
  3. CascadeType.REFRESH 级联刷新
  4. CascadeType.MERGE 级联更新
  5. CascadeType.ALL 四项全选

其中,默认是没有级联操作的,关系表不会产⽣任何影响。此外,JPA 2.0 还新增了 CascadeType.DETACH,即级联实体到 Detach 状态。

了解了枚举值,下⾯我们来测试⼀下级联新建和级联删除。

⾸先,修改 UserInfo ⾥⾯的关键代码如下,并在 @OneToOne 上⾯添加 cascade ={CascadeType.PERSIST,CascadeType.REMOVE} ,如下:

@OneToOne(cascade = {CascadeType.PERSIST, CascadeType.REMOVE})
private User user;

其次,我们新增⼀个测试⽅法。

@Test
@Rollback(false)
public void testUserRelationships() {
    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);
}

最后看一下打印日志

Hibernate: insert into user (address, email, name, sex, id) values (?, ?, ?, ?, ?)
Hibernate: insert into user_info (ages, telephone, user_id, id) values (?, ?, ?, ?)
Hibernate: delete from user_info where id=?
Hibernate: delete from user where id=?

从上⾯的运⾏结果可以看到,上⾯的测试在执⾏了 insert 的时候,会执⾏两条 insert 的sql 和两条 delete 的 sql,这就体现出了 CascadeType.PERSIST 和 CascadeType.REMOVE 的作⽤。

上⾯讲了级联删除的场景,下⾯我们再说⼀下关联关系的删除场景该怎么做。

7.1.4 orphanRemoval 的属性用法

orphanRemoval 表示当关联关系被删除的时候,是否应⽤级联删除,默认 false。什么意思呢?测试⼀下你就会明⽩。

⾸先,还沿⽤上⾯的例⼦,当我们删除 userInfo 的时候,把 User 置空,作如下改动。

userInfo.setUser(null);
userInfoRepository.delete(userInfo);

其次,我们再运⾏测试,看看效果。

Hibernate: delete from user_info where id=?

这时候你就会发现,少了⼀条删除 user 的 sql,说明没有进⾏级联删除。那我们再把 UserInfo 做⼀下调整。

@OneToOne(cascade = {CascadeType.PERSIST},orphanRemoval = true)
private User user;

然后,我们把 CascadeType.Remove 删除了,不让它进⾏级联删除,但是我们把 orphanRemoval 设置成 true,即当关联关系变化的时候级联更新。我们看下完整的测试⽤例。

@Test
@Rollback(false)
public void testUserRelationships() {
    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.setUser(null);
    // 删除 userInfo
    userInfoRepository.delete(userInfo);
}

这个时候我们看⼀下运⾏结果。

Hibernate: insert into user (address, email, name, sex, id) values (?, ?, ?, ?, ?)
Hibernate: insert into user_info (ages, telephone, user_id, id) values (?, ?, ?, ?)
Hibernate: update user_info set ages=?, telephone=?, user_id=? where id=?
Hibernate: delete from user where id=? and version=?
Hibernate: delete from user_info where id=?

从中我们可以看到,结果依然是两个 inser 和两个 delete,但是中间多了⼀个 update。我来解释⼀下,因为去掉了 CascadeType.REMOVE,这个时候不会进⾏级联删除了。当我们把 user 对象更新成空的时候,就会执⾏⼀条 update 语句把关联关系去掉了。

7.1.5 主键和外键都是同一个字段

我们假设 user 表是主表,user_info 的主键是 user_id,并且 user_id=user 是表⾥⾯的 id,那我们应该怎么写?

继续沿⽤上⾯的例⼦,User 实体不变,我们看看 UserInfo 变成什么样了。

@Entity
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = "user")
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), version integer, 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 的其他信息,具体我们应该怎么做呢?

7.1.6 @OneToOne 延迟加载下只需要 ID 值

在 @OneToOne 延迟加载的情况下,我们假设只想查下 user_id,⽽不想查看 user 表其他的信息,因为当前⽤不到,可以有以下⼏种做法。

第⼀种做法:还是 User 实体不变,我们改⼀下 UserInfo 对象,如下所示:

@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 (?, ?, ?, ?, ?)
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=?

两条 insert 照旧,⽽只有⼀个 select,最后你会发现,打印的结果符合预期。

UserInfo(id=1, ages=12, telephone=12345678)
1

接下来介绍第⼆种做法,这种做法很简单,只要在 UserInfo 对象⾥⾯直接去掉 @OneToOne 关联关系,新增下⾯的字段即可。

@Column(name = "user_id")
private Long userId;

第三做法是利⽤ Hibernate,它给我们提供了⼀种字节码增强技术,通过编译器改变 class 解决了延迟加载问题。这种⽅式有点复杂,需要在编译器引⼊ hibernateEnhance 的相关 jar 包,以及编译器需要改变 class ⽂件并添加 lazy 代理来解决延迟加载。我不太推荐这种⽅式,因为太复杂,你知道有这回事就⾏了。

以上我们掌握了这么多⽤法,那么最佳实践是什么?双向关联更好还是单向关联更好?根据最近⼏年的应⽤,我总结出了⼀些最佳实践,我们来看⼀下。

7.1.7 @OneToOne 的最佳实践

第⼀,我要说⼀种 Java ⾯向对象的设计原则:开闭原则。

即对扩展开放,对修改关闭。如果我们⼀直使⽤双向关联,两个实体的对象耦合太严重了。想象⼀下,随着业务的发展,User 对象可能是原始对象,围绕着 User 可能会扩展出各种关联对象。难道 User ⾥⾯每次都要修改,去添加双向关联关系吗?肯定不是,否则时间⻓了,对象与对象之间的关联关系就是⼀团乱麻。

所以,我们尽量、甚⾄不要⽤双向关联,如果⾮要⽤关联关系的话,只⽤单向关联就够了。双向关联正是 JPA 的强⼤之处,但同时也是问题最多,最被⼈诟病之处。所以我们要⽤它的优点,⽽不是学会了就⼀定要使⽤。

第⼆,我想说 CascadeType 很强⼤,但是我也建议保持默认。

即没有级联更新动作,没有级联删除动作。还有 orphanRemoval 也要尽量保持默认 false,不做级联删除。因为这两个功能很强⼤,但是我个⼈觉得这违背了⾯向对象设计原则⾥⾯的“职责单⼀原则”,除⾮你⾮常⾮常熟悉,否则你在⽤的时候会时常感到惊讶——数据什么时间被更新了?数据被谁删除了?遇到这种问题查起来⾮常麻烦,因为是框架处理,有的时候并⾮预期的效果。⼀旦⽣产数据被莫名更新或者删除,那是⼀件⾮常糟糕的事情。因为这些级联操作会使你的⽅法名字没办法命名,⽽且它不是跟着业务逻辑变化的,⽽是跟着实体变化的,这就会使⽅法和对象的职责不单⼀。

第三,我想告诉你,所有⽤到关联关系的地⽅,能⽤ Lazy 的绝对不要⽤ EAGER,否则会有 SQL 性能问题,会出现不是预期的 SQL。

以上三点是我总结的避坑指南,有经验的同学这时候会有个疑问:外键约束不是不推荐使⽤的吗?如果我的外键字段名不是约定的怎么办?别着急,我们再看⼀下 @JoinColumn 注解和 @JoinColumns 注解。

7.2 @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 的时候也能正确 insert 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 的⽤法。

7.3 @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 ⾥⾯的基本⼀样,⽤法是⼀样的,不过需要注意以下⼏点:

  1. @ManyToOne ⼀定是维护外键关系的⼀⽅,所以没有 mappedBy 字段;
  2. @ManyToOne 删除的时候⼀定不能把 One 的⼀⽅删除了,所以也没有 orphanRemoval 的选项;
  3. @ManyToOne 的 Lazy 效果和 @OneToOne 的⼀样,所以和上⾯的⽤法基本⼀致;
  4. @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;
}

再新建⼀个测试⽤例,完整代码如下:

@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 关联关系操作
     */
    @Test
    @Rollback(false)
    public void testUserRelationships() {
        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 多对多关联关系的⽤法。

7.4 @ManyToMany

@ManyToMany 代表多对多的关联关系,这种关联关系任何⼀⽅都可以维护关联关系。我们还是先看个例⼦感受⼀下。

我们假设 user 表和 room 表是多对多的关系,看看两个实体怎么写。

@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 维护关联关系。

@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 ⾥⾯的内容,在 Room ⾥⾯添加了 @JoinTable 注解。

@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;
}

看⼀下 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等其他字段。

7.4.1 利用 @ManyToOne 和 @OneToMany 表达多对多的关联关系

我们修改⼀下上⾯的 Demo,来看⼀下通过 @ManyToOne 和 @OneToMany 如何表达多对多的关联关系。

我们新建⼀张表 user_room_relation 来存储双⽅的关联关系和额外字段,实体如下:

@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 {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;
    @OneToMany(mappedBy = "room")
    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 的最佳实践和你分享。

7.4.2 @ManyToMany 的最佳实践
  1. 上⾯我们介绍的 @OneToMany 的最佳实践同样适⽤,我为了说明⽅便,采⽤的是双向关联,⽽实际⽣产⼀般是在中间表对象⾥⾯做单向关联,这样会让实体之间的关联关系简单很多。
  2. 与 @OneToMany ⼀样的道理,不要⽤级联删除和 orphanRemoval=true。
  3. FetchType 采⽤默认⽅式:fetch = FetchType.LAZY 的⽅式。

7.5 本章小结

通过本课时内容,我们基本上能理解 @OneToOne、@ManyToOne、@OneToMany、@ManyToMany 分别表示的是什么关联关系,各⾃解决的应⽤场景是什么,以及⽣产中我们推荐的最佳实践是什么。我们所说的“如何才算正确使⽤”,重点是要将原理和解决的场景理解透彻,参考最佳实践,做出符合⾃⼰业务场景的最好办法。

其实细⼼的同学还会看出我分享的学习思路,即看协议规定、看源码,然后实际动⼿写个最⼩环境进⾏测试,⼀看就明⽩是怎么回事了。

  • 3
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值