1. 概述
在本教程中,我们将介绍几种在 JPA 中处理 多对多关系(Many-to-Many Relationship) 的方式。
我们以学生(Student)和课程(Course)为例,来演示它们之间的各种关系。
为了简洁起见,在代码示例中我们只展示与多对多关系相关的属性和 JPA 配置。
2. 基础多对多关系
2.1. 多对多关系建模
关系是两个实体类型之间的连接。在多对多关系中,双方都可以与对方的多个实例相关联。
需要注意的是,实体类型之间也可以与自身形成关系。比如在家族树模型中,每个节点都是一个人,如果我们讨论的是父子关系,那么参与关系的双方都是 Person 类型。
不过,无论关系是发生在单个实体类型内部还是多个实体类型之间,建模思路基本一致。为了方便理解,我们以两个不同类型的实体为例进行说明。
以学生标记喜欢的课程为例:
一个学生可以喜欢 多个 课程,多个 学生也可以喜欢同一门课程:
在关系型数据库中,我们可以使用外键来创建关系。由于双方都需要引用对方,我们需要创建一个单独的表来存储这些外键:
这样的表称为 连接表(Join Table)。在连接表中,外键的组合将成为其复合主键。
2.2. JPA 实现
使用 POJO 建模多对多关系很简单。我们只需要在两个类中分别包含一个 Collection,用于存储对方的实例。
然后,我们需要使用 @Entity
注解标记类,并使用 @Id
标记主键,使其成为合法的 JPA 实体。
接着,我们还需要配置关系类型。为此,我们使用 @ManyToMany
注解标记集合:
@Entity
class Student {
@Id
Long id;
@ManyToMany
Set<Course> likedCourses;
// 其他属性
// 标准构造函数、getter 和 setter
}
@Entity
class Course {
@Id
Long id;
@ManyToMany
Set<Student> likes;
// 其他属性
// 标准构造函数、getter 和 setter
}
此外,我们还需要配置关系在数据库中的表现形式。
关系的拥有方(owner side)是配置关系的地方。我们选择在 Student
类中进行配置。
我们可以在 Student
类中使用 @JoinTable
注解来实现这一点。我们提供连接表的名称(course_like
),并通过 @JoinColumn
注解指定外键。joinColumn
属性连接到关系的拥有方,而 inverseJoinColumn
连接到另一方:
@ManyToMany
@JoinTable(
name = "course_like",
joinColumns = @JoinColumn(name = "student_id"),
inverseJoinColumns = @JoinColumn(name = "course_id"))
Set<Course> likedCourses;
⚠️ 注意:使用 @JoinTable
或 @JoinColumn
并非必须。JPA 会自动为我们生成表名和列名。但 JPA 的命名策略可能与我们实际使用的命名规范不符,因此我们保留了手动配置的能力。
在被拥有方(target side),我们只需提供映射关系的字段名即可。
因此,我们在 Course
类中设置 @ManyToMany
的 mappedBy
属性:
@ManyToMany(mappedBy = "likedCourses")
Set<Student> likes;
✅ 提示:虽然数据库中多对多关系没有真正的“拥有方”,但我们可以在 Course
类中配置连接表,并从 Student
类中引用它。
3. 使用复合主键的多对多关系
3.1. 为关系建模附加属性
假设我们想让学生对课程进行评分。一个学生可以对任意数量的课程进行评分,同样,任意数量的学生也可以对同一门课程评分。因此,这仍然是一个典型的多对多关系。
但这个例子更复杂的地方在于:评分关系不仅仅表示“存在”,我们还需要存储学生对课程的评分值。
这些评分数据应该存储在哪里?我们不能将其放在 Student
实体中,因为一个学生可能对不同课程给出不同的评分。同样,也不能放在 Course
实体中。
这正是 关系本身具有属性 的典型场景。
在 ER 图中,附加属性的关系如下所示:
我们几乎可以像简单多对多关系一样建模,唯一的区别是我们需要为连接表添加一个新的属性:
3.2. 在 JPA 中创建复合主键
简单多对多关系的实现相对直接。但问题在于我们无法通过这种方式向关系添加属性,因为我们是直接连接两个实体的,没有为关系本身提供存储空间。
由于 JPA 中数据库字段映射到类属性,我们需要为关系创建一个新的实体类。
当然,每个 JPA 实体都需要主键。由于我们的主键是复合主键,我们必须创建一个新的类来表示主键的各个部分:
@Embeddable
class CourseRatingKey implements Serializable {
@Column(name = "student_id")
Long studentId;
@Column(name = "course_id")
Long courseId;
// 标准构造函数、getter 和 setter
// hashCode 和 equals 实现
}
⚠️ 复合主键类必须满足以下要求:
- 使用
@Embeddable
注解标记; - 实现
java.io.Serializable
接口; - 提供
hashCode()
和equals()
方法的实现。
3.3. 在 JPA 中使用复合主键
使用这个复合主键类,我们可以创建一个实体类来建模连接表:
@Entity
class CourseRating {
@EmbeddedId
CourseRatingKey id;
@ManyToOne
@MapsId("studentId")
@JoinColumn(name = "student_id")
Student student;
@ManyToOne
@MapsId("courseId")
@JoinColumn(name = "course_id")
Course course;
int rating;
// 标准构造函数、getter 和 setter
}
这段代码与普通实体的实现非常相似,但有几个关键区别:
- 使用
@EmbeddedId
注解标记主键,该主键是CourseRatingKey
类的实例; - 使用
@MapsId
注解标记student
和course
字段。
@MapsId
表示这些字段与主键的一部分绑定,它们是多对一关系的外键。
之后,我们可以在 Student
和 Course
实体中像之前一样配置反向引用:
class Student {
// ...
@OneToMany(mappedBy = "student")
Set<CourseRating> ratings;
// ...
}
class Course {
// ...
@OneToMany(mappedBy = "course")
Set<CourseRating> ratings;
// ...
}
✅ 提示:还有一个替代方式是使用 @IdClass
注解。
3.4. 进一步说明
我们将 Student
和 Course
类的关系配置为 @ManyToOne
。这是可行的,因为通过引入新的实体,我们将多对多关系结构上拆解为了两个多对一关系。
为什么可以这样做?如果仔细查看之前的表结构,我们会发现它实际上包含了两个多对一关系。换句话说,关系型数据库中并不存在真正的多对多关系。我们称通过连接表建立的结构为多对多关系,是因为它建模的是这种关系。
此外,使用多对多关系描述更清晰,因为它更贴近我们的意图。而连接表只是一个实现细节,我们并不关心。
这种解决方案还有一个额外优势:我们可以将关系扩展到多个实体。例如,当多个教师可以教授同一门课程时,学生可以对某个教师在某门课程中的教学进行评分。这样,评分就成为学生、课程和教师三者之间的关系。
4. 引入新实体的多对多关系
4.1. 为关系建模附加属性
假设我们想让学生注册课程。此外,我们还需要存储学生注册课程的时间。在此基础上,我们还想存储学生在课程中获得的成绩。
在理想情况下,我们可以使用前面的复合主键方案来解决这个问题。但现实并不完美,学生并不总是一次性通过课程。
在这种情况下,同一个学生和课程之间可能存在多次注册记录,即存在多个 student_id-course_id
相同的记录。我们无法使用之前的方案,因为所有主键必须唯一。因此,我们需要使用单独的主键。
为此,我们可以引入一个新实体,用于存储注册的属性:
在这种情况下,Registration 实体表示其他两个实体之间的关系。
由于它是一个实体,它将拥有自己的主键。
回想之前的方案,我们使用两个外键创建了一个复合主键。
现在,这两个外键将不再是主键的一部分:
4.2. JPA 实现
由于 course_registration
现在是一个普通表,我们可以创建一个普通的 JPA 实体来建模它:
@Entity
class CourseRegistration {
@Id
Long id;
@ManyToOne
@JoinColumn(name = "student_id")
Student student;
@ManyToOne
@JoinColumn(name = "course_id")
Course course;
LocalDateTime registeredAt;
int grade;
// 其他属性
// 标准构造函数、getter 和 setter
}
我们还需要在 Student
和 Course
类中配置关系:
class Student {
// ...
@OneToMany(mappedBy = "student")
Set<CourseRegistration> registrations;
// ...
}
class Course {
// ...
@OneToMany(mappedBy = "course")
Set<CourseRegistration> registrations;
// ...
}
同样,我们之前已经配置了关系,因此只需告诉 JPA 在哪里可以找到该配置即可。
我们也可以使用这种方案来解决前面的学生评分课程问题。但除非必须,否则为关系单独设置主键显得有些多余。
此外,从数据库角度来看,将两个外键组合成复合主键更有意义,因为这样可以清晰表达关系的含义。
总的来说,这两种实现方式的选择往往取决于个人偏好。
5. 总结
在本文中,我们介绍了什么是多对多关系,以及如何使用 JPA 在关系型数据库中对其进行建模。
我们展示了三种在 JPA 中建模多对多关系的方式。它们在以下方面各有优劣:
- 代码清晰度
- 数据库清晰度
- 是否支持为关系添加属性
- 能否连接多个实体
- 是否支持相同实体间的多次连接
一如既往,示例代码可以在 GitHub 上找到。