1. 概述

在本教程中,我们将介绍几种在 JPA 中处理 多对多关系(Many-to-Many Relationship) 的方式。

我们以学生(Student)和课程(Course)为例,来演示它们之间的各种关系。

为了简洁起见,在代码示例中我们只展示与多对多关系相关的属性和 JPA 配置。

2. 基础多对多关系

2.1. 多对多关系建模

关系是两个实体类型之间的连接。在多对多关系中,双方都可以与对方的多个实例相关联。

需要注意的是,实体类型之间也可以与自身形成关系。比如在家族树模型中,每个节点都是一个人,如果我们讨论的是父子关系,那么参与关系的双方都是 Person 类型。

不过,无论关系是发生在单个实体类型内部还是多个实体类型之间,建模思路基本一致。为了方便理解,我们以两个不同类型的实体为例进行说明。

以学生标记喜欢的课程为例:

一个学生可以喜欢 多个 课程,多个 学生也可以喜欢同一门课程:

simple-er

在关系型数据库中,我们可以使用外键来创建关系。由于双方都需要引用对方,我们需要创建一个单独的表来存储这些外键

simple-model-updated

这样的表称为 连接表(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 类中设置 @ManyToManymappedBy 属性:

@ManyToMany(mappedBy = "likedCourses")
Set<Student> likes;

✅ 提示:虽然数据库中多对多关系没有真正的“拥有方”,但我们可以在 Course 类中配置连接表,并从 Student 类中引用它。

3. 使用复合主键的多对多关系

3.1. 为关系建模附加属性

假设我们想让学生对课程进行评分。一个学生可以对任意数量的课程进行评分,同样,任意数量的学生也可以对同一门课程评分。因此,这仍然是一个典型的多对多关系。

但这个例子更复杂的地方在于:评分关系不仅仅表示“存在”,我们还需要存储学生对课程的评分值

这些评分数据应该存储在哪里?我们不能将其放在 Student 实体中,因为一个学生可能对不同课程给出不同的评分。同样,也不能放在 Course 实体中。

这正是 关系本身具有属性 的典型场景。

在 ER 图中,附加属性的关系如下所示:

relation-attribute-er

我们几乎可以像简单多对多关系一样建模,唯一的区别是我们需要为连接表添加一个新的属性

relation attribute model updated

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 注解标记 studentcourse 字段。

@MapsId 表示这些字段与主键的一部分绑定,它们是多对一关系的外键。

之后,我们可以在 StudentCourse 实体中像之前一样配置反向引用:

class Student {

    // ...

    @OneToMany(mappedBy = "student")
    Set<CourseRating> ratings;

    // ...
}

class Course {

    // ...

    @OneToMany(mappedBy = "course")
    Set<CourseRating> ratings;

    // ...
}

✅ 提示:还有一个替代方式是使用 @IdClass 注解。

3.4. 进一步说明

我们将 StudentCourse 类的关系配置为 @ManyToOne。这是可行的,因为通过引入新的实体,我们将多对多关系结构上拆解为了两个多对一关系。

为什么可以这样做?如果仔细查看之前的表结构,我们会发现它实际上包含了两个多对一关系。换句话说,关系型数据库中并不存在真正的多对多关系。我们称通过连接表建立的结构为多对多关系,是因为它建模的是这种关系。

此外,使用多对多关系描述更清晰,因为它更贴近我们的意图。而连接表只是一个实现细节,我们并不关心。

这种解决方案还有一个额外优势:我们可以将关系扩展到多个实体。例如,当多个教师可以教授同一门课程时,学生可以对某个教师在某门课程中的教学进行评分。这样,评分就成为学生、课程和教师三者之间的关系

4. 引入新实体的多对多关系

4.1. 为关系建模附加属性

假设我们想让学生注册课程。此外,我们还需要存储学生注册课程的时间。在此基础上,我们还想存储学生在课程中获得的成绩。

在理想情况下,我们可以使用前面的复合主键方案来解决这个问题。但现实并不完美,学生并不总是一次性通过课程。

在这种情况下,同一个学生和课程之间可能存在多次注册记录,即存在多个 student_id-course_id 相同的记录。我们无法使用之前的方案,因为所有主键必须唯一。因此,我们需要使用单独的主键。

为此,我们可以引入一个新实体,用于存储注册的属性:

relation entity-er updated

在这种情况下,Registration 实体表示其他两个实体之间的关系

由于它是一个实体,它将拥有自己的主键。

回想之前的方案,我们使用两个外键创建了一个复合主键。

现在,这两个外键将不再是主键的一部分:

relation entity model updated

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
}

我们还需要在 StudentCourse 类中配置关系:

class Student {

    // ...

    @OneToMany(mappedBy = "student")
    Set<CourseRegistration> registrations;

    // ...
}

class Course {

    // ...

    @OneToMany(mappedBy = "course")
    Set<CourseRegistration> registrations;

    // ...
}

同样,我们之前已经配置了关系,因此只需告诉 JPA 在哪里可以找到该配置即可。

我们也可以使用这种方案来解决前面的学生评分课程问题。但除非必须,否则为关系单独设置主键显得有些多余。

此外,从数据库角度来看,将两个外键组合成复合主键更有意义,因为这样可以清晰表达关系的含义。

总的来说,这两种实现方式的选择往往取决于个人偏好。

5. 总结

在本文中,我们介绍了什么是多对多关系,以及如何使用 JPA 在关系型数据库中对其进行建模。

我们展示了三种在 JPA 中建模多对多关系的方式。它们在以下方面各有优劣:

  • 代码清晰度
  • 数据库清晰度
  • 是否支持为关系添加属性
  • 能否连接多个实体
  • 是否支持相同实体间的多次连接

一如既往,示例代码可以在 GitHub 上找到。


原始标题:Many-To-Many Relationship in JPA