图源:
-
一对一
-
一对多
-
多对多
这篇文章将介绍如何在 JPA(Hibernate)中实现一对一关系。
其余的关联关系会在之后介绍。
通过外键关联
即使都是一对一的关系,也会因为你建模的不同而有不同的实现方式,这里先介绍最常见的——两个表之间通过外键进行关联。
假设我们的项目中需要两张表,学生和附加的一些额外学生信息,两张表的关系可以用以下模型表示:
student
表保存基本信息,student_info
表保存一些额外的附加信息。所以两张表的数据是一对一的关系,这体现在student
表中用外键student_info_id
关联student_info
表中的主键id
。
表设计中通常会将表按照模块划分,并使用前缀命名的方式进行区分。所以这里的
user_
表示两张表属于user
模块。之所以将一张表就可以表示的信息进行拆分,可能的因素有很多,其中一个可能的原因是性能优化需要,通过将频繁会发生变化的字段从基本表中拆出,可以缩小数据变更会影响的数据规模,进而提升性能。
下面我们看怎么在 JPA 中实现这个模型。
先实现表对应的两个实体类:
name = "user_student")
(public class Student {
strategy = GenerationType.IDENTITY)
( private Long id;
max = 45)
( private String name;
private LocalDate birthDay;
private Long studentInfoId;
}
name = "user_student_info")
(public class StudentInfo {
strategy = GenerationType.IDENTITY)
( private Long id;
private Boolean loveMusic;
private Boolean loveDraw;
max = 255)
( name = "description")
( private String desc;
}
这里
desc
属性没有使用属性名称作为 DDL 的字段名,是因为在 MySQL 中desc
是一个保留字。
下面实现实体中的一对一映射关系:
public class Student {
// ...
name = "student_info_id", referencedColumnName = "id")
( private StudentInfo studentInfo;
}
一对一的关系可以是双向关联,也可以是单向关联。在这个示例中仅是单向关联,所以只要在Student
中说明关联关系,而无需修改StudentInfo
实体。
因为一个实体对应一张表,一个实体实例对应表中的一行数据,所以很自然的,当前的Student
中会唯一对应一个StudentInfo
,因此这里用StudentInfo
属性取代了原始代表外键的studentInfoId
属性。
此外,@OneToOne
说明了这是一个一对一关联,@JoinColumn
则指明了关联关系是由当前表的name
字段(student_info_id)指向被关联表的referencedColumnName
字段(id)。
@JoinColumn
的referencedColumnName
属性可以缺省,此时 Hibernate 会使用被关联实体的主键对应的表字段。
最终,Hibernate 自动生成的 DDL 如下:
CREATE TABLE `user_student` (
`id` bigint NOT NULL AUTO_INCREMENT,
`birth_day` date NOT NULL,
`name` varchar(45) NOT NULL,
`student_info_id` bigint DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `UK_5hyl4mptebabeuk4gfh59jwql` (`student_info_id`),
CONSTRAINT `FK24w6cbvyfnc718wueavv7355l` FOREIGN KEY (`student_info_id`) REFERENCES `user_student_info` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
CREATE TABLE `user_student_info` (
`id` bigint NOT NULL AUTO_INCREMENT,
`description` varchar(255) NOT NULL,
`love_draw` bit(1) NOT NULL,
`love_music` bit(1) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=17 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
注意,这里作为外键的student_info_id
是可以为null
的,且用一个唯一索引确保了student_info
中的一条数据只会对应student
表中的唯一一条数据。
可以用测试用例证明这种关联关系,具体可以查看。
消除 NULL
这种关联是最简单和最符合直觉的,但这样做有一个缺陷,有的学生是缺乏更多信息的,因此我们要让作为外键的student_info_id
可以是null
。一般来说这没有什么问题,但字段可以为null
会带来一些其他问题,比如查询条件编写时要额外考虑是否为null
,以及索引效率的降低等。
因此我们可以考虑用另一种方式建模:
这个模型和上边的模型存在一些细节差异,这里体现外键关系的是student_info
表中的student_id
字段。在我们的设计中,student
是基本表,其中的数据是必然存在的,而student_info
只是一种“附加表”,可能存在一条对应student
的数据,也可能不存在。因此这里作为外键的student_id
是NOT NULL
的。
用 JPA 的实体表示就是:
name = "Student2")
(name = "user_student2")
(public class Student {
strategy = GenerationType.IDENTITY)
( private Long id;
// ...
}
name = "StudentInfo2")
(name = "user_student_info2")
(public class StudentInfo {
strategy = GenerationType.IDENTITY)
( private Long id;
// ...
name = "student_id")
( private Student student;
}
这里只展示关键代码。
显式指定实体名称(
@Entity(name=xxx)
)是因为 Hibernate 不允许出现同名实体。
注意,示例中作为关联关系的StudentInfo.student
属性被@NotNull
注解标记,这正是我们做出改变的原因。
Hibernate 自动生成的 DDL:
CREATE TABLE `user_student2` (
`id` bigint NOT NULL AUTO_INCREMENT,
`birth_day` date NOT NULL,
`name` varchar(45) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
CREATE TABLE `user_student_info2` (
`id` bigint NOT NULL AUTO_INCREMENT,
`description` varchar(255) NOT NULL,
`love_draw` bit(1) NOT NULL,
`love_music` bit(1) NOT NULL,
`student_id` bigint NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `UK_5vepc7w68k2kicuug0wdsnms9` (`student_id`),
CONSTRAINT `FKrmikf3q8o2m9xfc7lmuewuhej` FOREIGN KEY (`student_id`) REFERENCES `user_student2` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
双向关联
上面展示的示例都是单向的一对一关联,这在查询的时候会不方便,比如:
var findsis = studentInfoRepository.findAll();
findsis.forEach(sis -> {
System.out.println(sis.getStudent());
});
可以很容易地查询student_info
表,并获取关联地student
信息。但因为是单向关联,反过来就很难了,需要单独进行查询匹配。
因此,有时候建立双向的一对一关联会很有用。
name = "Student3")
(name = "user_student3")
(public class Student {
// ...
mappedBy = "student",
( fetch = FetchType.LAZY,
cascade = CascadeType.ALL,
orphanRemoval = true)
private StudentInfo studentInfo;
}
name = "StudentInfo3")
(name = "user_student_info3")
(public class StudentInfo {
// ...
fetch = FetchType.LAZY)
( name = "student_id")
( private Student student;
}
之前的示例我们已经建立了从StudentInfo
到Student
实体的关联关系,因此这里只需要在Student
中添加一个StudentInfo
类型的属性,并用@OneToOne
声明这是一个一对一关系。此外,还需要使用mappedBy
属性表示这个关联关系是由StudentInfo
实体的student
属性拥有的。
因为真实的外键字段只存在于
student_info
表,所以在这里不需要使用@JoinColumn
注解。
这里使用了一些@OneToOne
的额外属性,它们的作用是:
-
fetch
,从数据库加载数据到实体时,对于关联的实体是立即加载还是延迟加载。默认为立即加载(FetchType.EAGER
),可以修改为延迟加载(FetchType.LAZY
)。 -
cascade
,会级联操作到关联表的操作,默认为没有。CascadeType.ALL
表示所有操作都级联影响关联表。 -
orphanRemoval
,是否将删除操作应用于关联表。
使用了双向关联的实体,在新增实体时会有一些麻烦:
void testAddOneError() {
var s = Student.builder()
.name("icexmoon")
.birthDay(LocalDate.of(1998, 10, 1))
.studentInfo(StudentInfo.builder()
.loveMusic(true)
.loveDraw(false)
.desc("")
.build())
.build();
Assertions.assertThrows(ConstraintViolationException.class, () -> {
studentRepository.save(s);
});
}
这里会抛出一个ConstraintViolationException
异常,因为StudentInfo.student
属性为null
,但该属性我们设置了@NotNull
注解。实际上即使删除该注解,依然会抛出一个异常,因为StudentInfo
实体实例没有关联Student
实体实例。
换言之,我们要对要添加的新的实体实例处理双向关联关系后才能成功添加到数据库:
@Test
void testAddOneCorrect() {
StudentInfo studentInfo = StudentInfo.builder()
.loveMusic(true)
.loveDraw(false)
.desc("")
.build();
var s = Student.builder()
.name("icexmoon")
.birthDay(LocalDate.of(1998, 10, 1))
.studentInfo(studentInfo)
.build();
s.setStudentInfo(studentInfo);
studentInfo.setStudent(s);
studentRepository.save(s);
}
当然,可以创建一个“便捷方法”来稍微简化一下这种关系绑定:
public class Student {
// ...
public Student addStudentInfoAssociation(StudentInfo studentInfo){
this.setStudentInfo(studentInfo);
studentInfo.setStudent(this);
return this;
}
}
调用:
@Test
void testAddOneCorrectWithConvenientMethod() {
// ...
s.addStudentInfoAssociation(studentInfo);
studentRepository.save(s);
}
通过共享主键关联
两个表可以用共享主键的方式进行一对一关联。
具体来说,就是让附表不拥有自己的自增主键,而是使用主表的主键作为自己的主键(用外键约束来体现)。
可以用下边的数据库模型表示:
JPA 实体的实现如下:
@Entity(name = "Student4")
@Table(name = "user_student4")
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// ...
@OneToOne(mappedBy = "student",
cascade = CascadeType.ALL,
orphanRemoval = true)
@PrimaryKeyJoinColumn
private StudentInfo studentInfo;
// ...
}
@Entity(name = "StudentInfo4")
@Table(name = "user_student_info4")
public class StudentInfo {
@Id
@Column(name = "student_id")
private Long id;
// ...
@OneToOne
@MapsId
@JoinColumn(name = "student_id")
private Student student;
}
这里的写法和之前的示例是相似的,只不过在副表(student_info
)对应的实体中,主键不再是自增的,同时一对一关系中作为外键的字段和主键是同一个表字段(student_id
)。此外,还使用@MapsId
注解标记了关联关系,这个注解的用途是将主表的主键映射到副表的主键,也就是所谓的“共享主键”。
Hibernate 生成的 DDL:
CREATE TABLE `user_student4` (
`id` bigint NOT NULL AUTO_INCREMENT,
`birth_day` date NOT NULL,
`name` varchar(45) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
CREATE TABLE `user_student_info4` (
`student_id` bigint NOT NULL,
`description` varchar(255) NOT NULL,
`love_draw` bit(1) NOT NULL,
`love_music` bit(1) NOT NULL,
PRIMARY KEY (`student_id`),
CONSTRAINT `FKitgvro3kqjc37b1vgnw7auvjl` FOREIGN KEY (`student_id`) REFERENCES `user_student4` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
使用关联表
通常我们使用关联表来表示多对多关系,但实际上同样可以用这种方式表示一对一关系。
注意,这里的关系表和两个基本表之间都是一对一的关系,并非常见的多对一关系。这意味着在关系表中,要么只存在一条某个学生对应的某条学生信息的记录,要么就不存在该学生的任何的记录。
用 JPA 实体可以表示为:
@Entity(name = "Student5")
@Table(name = "user_student5")
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// ...
@OneToOne(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinTable(name = "user_student_info_relationship",
joinColumns = {
@JoinColumn(name = "student_id", referencedColumnName = "id"),
},
inverseJoinColumns = {
@JoinColumn(name = "student_info_id", referencedColumnName = "id")
}
)
private StudentInfo studentInfo;
// ...
}
@Entity(name = "StudentInfo5")
@Table(name = "user_student_info5")
public class StudentInfo {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// ...
@NotNull
@OneToOne(mappedBy = "studentInfo")
private Student student;
}
这里不需要为关联表创建单独的实体,而是在主表的关系属性studentInfo
上用@JoinTable
注解表明具体的关联关系是通过关联表实现的(相对于之前的关联字段@JoinColumn
)。
这里使用了@JoinTable
的三个属性:
-
name
,指定关联表的表名。 -
joinColumns
,主表和关联表的关联关系。 -
inverseJoinColumns
,副表和关联表的关联关系。
生成的 DDL :
CREATE TABLE `user_student5` (
`id` bigint NOT NULL AUTO_INCREMENT,
`birth_day` date NOT NULL,
`name` varchar(45) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
CREATE TABLE `user_student_info5` (
`id` bigint NOT NULL AUTO_INCREMENT,
`description` varchar(255) NOT NULL,
`love_draw` bit(1) NOT NULL,
`love_music` bit(1) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
CREATE TABLE `user_student_info_relationship` (
`student_info_id` bigint DEFAULT NULL,
`student_id` bigint NOT NULL,
PRIMARY KEY (`student_id`),
UNIQUE KEY `UK_8b1bjp7fmdrtqku06imgtdhvu` (`student_info_id`),
CONSTRAINT `FK91ba54r543lovb2wch2b6pun6` FOREIGN KEY (`student_info_id`) REFERENCES `user_student_info5` (`id`),
CONSTRAINT `FKsrhfbf9o3t85k2yf6i5403j3c` FOREIGN KEY (`student_id`) REFERENCES `user_student5` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
可以看到,关联表的主键使用了对主表的外键(student_id
),对副表的外键则用唯一索引来约束。这和通常关联表使用多个外键做联合主键的方式是不同的,因为这里关联表表示的一对一关系,所以这样做并没有问题。
注意事项
在编写测试用例的时候需要注意,作为存储库接口类型的 bean 注入,必须具有唯一的名称,否则就无法正常生成相应的 bean 实例,此时可以通过@Repository(...)
的方式为其命名,比如:
package com.example.manytomany.v1;
// ...
@Repository
public interface StudentRepository extends JpaRepository<Student, Long> {
}
package com.example.manytomany.v2;
// ...
@Repository("StudentRepository2")
public interface StudentRepository extends JpaRepository<Student, Long> {
}
虽然这两个表示 JPA 存储库的接口位于两个不同的包下,但依然要确保其 bean 名称唯一,所以就不能都保留其默认的命名方式,对于第二个要手动指定一个不同的 bean 名称(@Repository("StudentRepository2")
)。
最后,还存在一种“特殊的一对一关系”,即将一个表的数据拆分成多张表,关于这个问题,可以阅读我的。
The End,谢谢阅读。
本文的所有示例代码可以从获取。
参考资料
文章评论