跳到主要内容

ManyToManyView

经典ORM关联的不足

基础映射/关联映射中,我们学习了ORM中经典的关联映射,包括一对一、多对一、一对多和多对多。

然而,有一种场景,映射模式的选择让人很纠结。为了展示这种场景,从熟悉的场景开始。

无争议的多对多关联

让我们来看一段DDL

create table book(
......
)engine=innodb;;

create table author(
......
) engine=innodb;

/* highlight-next-line */
create table book_author_mapping(
book_id bigint unsigned not null,
author_id bigint unsigned not null
) engine=innodb;
alter table book_author_mapping
add constraint pk_book_author_mapping
primary key(book_id, author_id)
;
alter table book_author_mapping
add constraint fk_book_author_mapping__book
foreign key(book_id)
references book(id)
on delete cascade
;
alter table book_author_mapping
add constraint fk_book_author_mapping__author
foreign key(author_id)
references author(id)
on delete cascade
;

这段DDL中,book_author_mapping表很特殊,只有两个外键,一个指向book表,一个指向author表。这种只有两个外键的子表用于表达两个父表之间的多对多关联。

ORM的多对多映射会隐藏中间表,即,中间表没有对应的Java/Kotlin实体类型。因此,中间表并不需要独立主键,而是直接使用两个外键作为联合主键。

信息

除了两个关联外键外,中间表不得具备其他任何字段,这是ORM中多对多关联的限制

ORM中对应的多对多关联如下:

  • 主动方:Book.authors

    Book.java
    @Entity
    public interface Book {

    @ManyToMany
    List<Author> authors();

    ...省略其他代码...
    }
  • 从动方 (可选): Author.books

    Author.java
    @Entity
    public interface Author {

    @ManyToMany(mappedBy = "authors")
    List<Book> books();

    ...省略其他代码...
    }

无争议的双重多对一关联

让我们再来看第二个场景,首先,还是看一段DDL

create table order_(
......
) engine=innodb;

create table product(
......
) engine=innodb;

/* highlight-next-line */
create table order_item(
id bigint unsigned not null auto_increment primary key,
order_id bigint unsigned not null,
product_id bigint unsigned not null,
/* highlight-next-line */
quantity int not null,
/* highlight-next-line */
unit_price numeric(10, 2) not null
) engine=innodb;
alter table order_item
add constraint business_key_order_item
unique(order_id, product_id)
;
alter table order_item
add constraint fk_order_item__order
foreign key(order_id)
references order_(id)
;
alter table order_item
add constraint fk_order_item__product
foreign key(product_id)
references product(id)
;

这是一个经典的订单-订单明细-产品关联。

其中,order-item看起来有点像多对多的中间表,因为它存在两个外键:指向order_表的order_id和指向product表的product_id

然而,order-item并不是中间表,因为它具备其他业务字段,表示商品数量的quantity,以及表示下单这一刻商品的价格快照的unit_price

幸运的是,对于经典的订单-订单明细-产品关联,更适合人们直觉的思维方式是认为order_item是一个独立的实体,持有分别指向order_product的两个多对一关联; 而不是将order-item视为中间表,认为order_product之间存在多对多关系。

正是因为把order_item作为一个独立的实体来看待,所以order_item采用了独立主键。

我们可以在ORM中两个多对一关联来映射这三张表

  • OrderItem.orderOrderItem.product

    OrderItem.java
    @Entity
    public interface OrderItem {

    @ManyToOne
    Order order();

    @ManyToOne
    Product product();

    int quantity();

    BigDecimal unitPrice();

    ...省略其他代码...
    }
  • Order.items

    这类系统中,人们常常需要根据订单获取其明细列表,所以我们定义一个一对多属性Order.items,作为OrderItem.order的镜像。

    Order.java
    @Entity
    @Table(name = "ORDER_")
    public interface Order {

    @OneToMany(mappedBy = "order")
    List<OrderItem> items();

    ...省略其他代码...
    }
  • 不提供Product.items

    这类系统中,人们基本不会需要根据产品获取其明细列表 (如果从产品端开始分析,一般都是复杂的查询,而非简单的关联),所以并不提供Product.items,一个单向的OrderItem.product关联足够。

    因此,不必展示Product实体的代码。

有争议的、让人纠结的场景

前文中,我们展示了两个业务场景

  • 场景1:中间表book_author_mapping很干净,只有两个外键字段,顺理成章地被映射成了多对多关联

  • 场景2:order_item看起来像中间表但并不是中间表,因为它除了两个外键外还要有其他业务字段。 幸运的是,人们会选择把OrderItem视为独立实体,并用两个多对一关联来把三个实体类型串到一起。

接下来,我们来看看场景3,先看DDL

create table student(
......
)engine=innodb;;

create table course(
......
) engine=innodb;

/* highlight-next-line */
create table learning_link (
id bigint unsigned not null auto_increment primary key,
student_id bigint unsigned not null,
course_id bigint unsigned not null,
/* highlight-next-line */
score int
) engine=innodb;
alter table learning_link
add constraint pk_student_course_mapping
primary key(student_id, course_id)
;
alter table learning_link
add constraint fk_student_course_mapping__student
foreign key(student_id)
references student(id)
on delete cascade
;
alter table learning_link
add constraint fk_student_course_mapping__course
foreign key(course_id)
references course(id)
on delete cascade
;

这段DDL表示学校的选修课系统,学生和选修课之间存在多对多关系。

  • 对学生而言,知道自己选修了那些课程自然非常重要

  • 对学校而言,知道每一门课程被哪些学生选修也非常重要,因为需要根据这些信息安排师资力量和教学场所

即,对于学生实体和课程实体而言,彼此关联对方和查询对方是重要且高频的操作。因此,在studentcourse之间抽象出双向多对多关联是一个很好的选择。

不幸的是,选修关系表learning_link中有一个可以为null的score字段,null表示还未考试,非null表示考试后的成绩。

  • 因为这个字段的存在,中间表不再干净,无法简单地映射为经典的ORM多对多关联。即,这并非简单的场景1

  • 当然,我们用场景2的方法来处理这个问题,把learning_link视为一个独立实体,用两个多对一关联把三个实体类型串起来。

    然而,对于很大一部分上层业务而言,其实只关心studentcourse之间的彼此关联,并不关心learning_link表的score字段 (即,中间表的非外键业务字段learning_link.score远没场景2中的相关字段重要)。 在这种情况下,场景2的解决方式会带来较高的心智负担,很显然,场景1那种思维方式更简单。

信息

这个场景其实是经典ORM中最让人纠结的场景,既无法简单地映射为多对多关联,又期望部分上层业务可以采用多对多的思维方式简化问题。

@ManyToManyView就是为这类场景设计的强力工具。

初识ManyToManyView

针对前面讨论过的场景3,Jimmer给出了两步解决方案。

  1. 基础关联映射:

    先按照场景2的办法,把learning_link视为一个独立实体,提供两个分别指向studentcourse的多对一关联; 反过来,studentcourse也使用一对多关联引用learning_link。即,用两个双向一对多关联把三个实体类型串在一起。

  2. 使用@ManyToManyView

    然后,在第一步的基础上,采用@ManyToManyView快速模拟出场景1的效果。

基础关系映射

  • LearningLink.studentLearningLink.course

    LearningLink.java
    @Entity
    public interface LearningLink {

    @ManyToOne
    Student student();

    @ManyToOne
    Course course();

    Integer score();

    ...省略其他代码...
    }

    其中,①和②会被后续代码引用

  • Student.learningLinks

    Student.java
    @Entity
    public interface Student {

    @OneToMany(mappedBy = "student")
    List<LearningLink> learningLinks();

    ...省略其他代码...
    }

    其中,③会被后续代码引用

  • Course.learningLinks

    Course.java
    @Entity
    public interface Course {

    @OneToMany(mappedBy = "course")
    List<LearningLink> learningLinks();

    ...省略其他代码...
    }

    其中,④会被后续代码引用

使用@ManyToManyView

  • 模拟多对多关联:Student.courses

    Student.java
    @Entity
    public interface Student {

    @ManyToManyView(
    prop = "learningLinks",->
    deeperProp = "course"->
    )
    List<Course> courses();

    // 第一步中,已经声明了一对多关联`learningLinks`
    @OneToMany(mappedBy = "student")
    List<LearningLink> learningLinks();

    ...省略其他代码...
    }

    当前属性Student.courses可以分两步获取

    • prop = "learningLinks"

      首先,通过当前实体的另一个属性Student.learningLinks③ 获得所有的LearningLink对象

    • deeperProp = "course"

      对于上一步得到的每一个LearningLink对象,可以进一步通过属性LearningLink.course② 得到Course

      提示

      由于LearningLink实体只有一个指向Course实体的多对一关联,没有二义性,因此这里的deeperProp = "course"可以省略

  • 模拟多对多关联:Course.students

    Course.java
    @Entity
    public interface Course {

    @ManyToManyView(
    prop = "learningLinks",->
    deeperProp = "student"->
    )
    List<Student> students();

    // 第一步中已经声明了一对多关联`learningLinks`
    @OneToMany(mappedBy = "course")
    List<LearningLink> learningLinks();

    ...省略其他代码...
    }

    当前属性Course.students可以分两步获取

    • prop = "learningLinks"

      首先,通过当前实体的另一个属性Course.learningLinks④ 获得所有的LearningLink对象

    • deeperProp = "student"

      对于上一步得到的每一个LearningLink对象,可以进一步通过属性LearningLink.student① 得到Student

      提示

      由于LearningLink实体只有一个指向Student实体的多对一关联,没有二义性,因此这里的deeperProp = "student"可以省略

@ManyToManyView的本质

  • @ManyToManyView声明的属性并没有维护自己的数据,它只是原始属性的视图,将原始属性返回的集合进行一层代理包装而已。

    以本文Java例子中Student端为例 (Course端一样)

    • 原始关联属性:List<LearningLink> learningLinks();

    • 视图关联属性:List<Course> courses();

    你可以这段伪码来理解ManyToManyView视图属性

    @Override
    public List<Course> courses() {
    return new ListProxy<>(
    this.learningLinks(),
    LearningLink::course
    );
    }

    视图关联属性返回了一个的代理集合,代理集合包装了原始属性的集合,并对原始集合元素进行了转换。

    很明显,二者本质一样,共享相同的数据。

  • 构建实体对象时,只能设置原始属性,不能设置视图属性。

    这和@IdView不同,对于@IdView而言,原始属性和视图属性都可以设置

  • 无论原始属性,还是视图属性,预编译器都会生成与之配套的代码,所以二者都可以在对象抓取器和强类型SQL DSL中使用