MyBatis实现一对多或者多对多级联

1,551 阅读33分钟

在平常后端开发的过程中,增删改查早已成为了我们的常规操作,而SSM架构(Spring Boot + MyBatis)也成为了我们的首选。不过一个复杂的系统,其各个数据之间也有着各种关系,甚至会存在多张表之间都存在关联。那么面对关系较为复杂的表,我们如何借助MyBatis对它们进行关联查询操作呢?

所以,在开发一个系统之前,设计其数据库表、建立类图(数据模型),理清思路,是非常重要的一步。

1,概述

其实数据之间的关系,无外乎就这么几种:

  • 一对一
  • 一对多(多对一也是一回事)
  • 多对多

其实一对一是最好理解和操作的,在实现上有一定的难度的就是多对多了。

接下来,我们来将以游戏《公主连结Re:Dive》中角色、武器、公会等关系为例,设计对应的数据库表格以及Java类,并使用SSM架构实现不同关系的关联查询操作。

《公主连结Re:Dive》是一款动画剧情手机游戏,由Cygames出品,并于2020年被bilibili游戏独家代理。

在公主连结游戏中,有很多的角色,其中每个角色属于一个公会(组织),其中一个角色还需要装备一些武器,且每个角色有她的专属武器,在游戏中,包含了丰富的剧情和动画,一个剧情通常会有多个角色“参与”,而一个角色也可能同时在多个剧情中出现。

这里可见:

  • 角色和专属武器是一对一的关系,一个角色只能有一个专属武器,一个专属武器也只属于一个角色
  • 公会和角色是一对多的关系,一个角色只能属于一个公会,一个公会可以有多个角色
  • 角色和武器也是一对多的关系,一个角色会装备多件武器,而一个武器只会被一个角色所装备
  • 剧情故事和角色是多对多的关系,一个剧情中通常会出现多个角色,而一个角色也可能出现在多个剧情故事之中

接下来,根据角色、武器、公会和剧情故事之间的关联关系,我们来设计数据库表格。

2,数据库表格设计

首先根据不同的关系,我们来设计数据库表格。

(1) 一对一

一对一关系可以说很简单了!以角色(figure)和专属武器(special_weapon)为例,我们先理清楚归属关系:是专属武器属于角色还是角色属于专属武器?很显然是前者。

也就是说一个专属武器记录对应有且仅有一个对应的角色记录,我们可以设计数据库表结构如下:

image-20241018201637066

可见在一对一关系中,我们将被拥有的对象(这里是专属武器)表中的主键直接作为其外键,关联所属对象(这里是角色)的主键id即可,可见专属武器的figure_id既是外键也是主键。

(2) 一对多

一对多关系通常表示一种从属关系或者是拥有关系,比如说一个角色通常属于某个公会,而一个角色也会拥有多个武器(普通武器不是专属武器)。

可见公会和角色、以及角色和武器之间都是一对多的关系,我们现在加入公会(guild)和武器(weapon)表,并使其和角色相关联:

image-20241018202913731

这里先仅关注角色与公会和武器的关系,所以上图先暂时不展示专属武器表,后面的图也类似。

可见:

  • 公会和角色是一对多关系,那么就在角色表中加入一个外键字段guild_id表示公会主键id即可
  • 角色和武器是一对多关系,那么同理在武器表中加入一个外键字段figure_id表示角色主键id即可

总而言之,在一对多关系中,只需要在“多”的那个表中,加入一个字段表示“一”的那个表的主键字段,作为“多”的表的外键,就实现了一对多的关系的构建和联系。

(3) 多对多

多对多关系就稍微复杂一点了!通常多对多需要在两个表之间额外再建立一个表专门用于表示两个表的关系,这个表一般没有主键,只有外键,其外键就是两个“多”的表的主键。

我们现在加入剧情故事(story)这张表,它和角色表构成多对多关系:

image-20241018203259532

可见上述story_figure即为“剧情-角色”关系表,它是用于建立角色和剧情故事表的多对多的桥梁,这样角色和剧情表只需要储存自己的信息,使用“剧情-角色”表,可以存储两者多对多关系,正是借助这个多对多关系表,我们就可以通过MySQL关联查询,也可以很方便地双向查询彼此关系。

(4) 总体

到此所有的表格结构以及表与表之间的关系我们就理清楚了!整体的数据库表如下图所示:

(5) 实践一下

设计好了数据库,我们现在就来编写一下SQL语句实现它们。

1. 创建数据库和表

首先连接MySQL数据库,创建一个库:

create database `pcr`;

然后是创建表:

-- 创建前先删除
drop table if exists `figure`, `special_weapon`, `guild`, `weapon`, `story`, `story_figure`;

-- 角色
create table `figure`
(
	-- 主键id
	`id`       int unsigned auto_increment,
	-- 角色名称
	`name`     varchar(16)  not null unique,
	-- 角色绰号
	`nickname` varchar(16)  not null,
	-- 角色定位
	`type`     varchar(8)   not null,
	-- 角色所属公会id(外键)
	`guild_id` int unsigned not null,
	primary key (`id`)
) engine = InnoDB
  default charset = utf8mb4;

-- 专属武器
create table `special_weapon`
(
	-- 主键id,同时也是外键关联角色
	`figure_id`   int unsigned,
	-- 专武名称
	`name`        varchar(16) not null unique,
	-- 专武描述
	`description` varchar(128),
	primary key (`figure_id`)
) engine = InnoDB
  default charset = utf8mb4;

-- 公会
create table `guild`
(
	-- 主键id
	`id`   int unsigned auto_increment,
	-- 公会名称
	`name` varchar(16) not null unique,
	primary key (`id`)
) engine = InnoDB
  default charset = utf8mb4;

-- 武器
create table `weapon`
(
	-- 主键id
	`id`        int unsigned auto_increment,
	-- 武器名称
	`name`      varchar(16)  not null unique,
	-- 拥有该武器角色id(外键)
	`figure_id` int unsigned not null,
	primary key (`id`)
) engine = InnoDB
  default charset = utf8mb4;

-- 剧情故事
create table `story`
(
	-- 主键id
	`id`   int unsigned auto_increment,
	-- 剧情名称
	`name` varchar(32) not null unique,
	primary key (`id`)
) engine = InnoDB
  default charset = utf8mb4;

-- 剧情-角色多对多关系表
create table `story_figure`
(
	-- 剧情id(外键)
	`story_id`  int unsigned not null,
	-- 角色id(外键)
	`figure_id` int unsigned not null,
	-- 构成复合主键
	primary key (`story_id`, `figure_id`)
) engine = InnoDB
  default charset = utf8mb4;

2. 指定外键约束

然后,我们需要指定一下表格的外键约束,执行下列SQL语句:

-- 初始化全部表外键约束

-- 将专属武器表中figure_id字段设定为外键,并关联角色表中的id字段
alter table `special_weapon`
	add foreign key (`figure_id`) references `figure` (`id`)
		on delete cascade on update cascade;

-- 将角色表中guild_id字段设定为外键,并关联公会表中的id字段
alter table `figure`
	add foreign key (`guild_id`) references `guild` (`id`)
		on delete cascade on update cascade;

-- 将武器表中的figure_id字段设定为外键,并关联角色表中的id字段
alter table `weapon`
	add foreign key (`figure_id`) references `figure` (`id`)
		on delete cascade on update cascade;

-- 将剧情-角色多对多关系表中两字段都建立外键约束
alter table `story_figure`
	add foreign key (`story_id`) references `story` (`id`)
		on delete cascade on update cascade;

alter table `story_figure`
	add foreign key (`figure_id`) references `figure` (`id`)
		on delete cascade on update cascade;

可见通过alter table xxx add foreign key语句,可以指定一个表的外键,并使其和另一个表的字段相关联。

虽然不建立外键约束,也不影响我们后续进行关联查询,但是建立外键约束通常还是有必要的,它确保了表之间的关系和引用的一致性,防止了在关联表中插入不一致的数据,从而提高了数据完整性。

上述建立外键约束有下列要点:

  • 外键字段和关联字段类型必须完全一致,例如上述公会表中的id是自增无符号int类型,那么角色表中关联它的外键guild_id也必须是无符号int类型,如果仅设置为int类型就会导致设定外键时出错
  • 语句在最后面的on delete cascade on update cascade表示级联删除和更新,这样譬如说我们删除一个角色的时候,在剧情-角色关联表中关于这个角色的对应的记录也会被删除,这个部分可以被省略

除了在创建完成表后指定外键约束,创建表的时候也可以指定外键约束,例如:

-- 角色
create table `figure`
(
	-- 主键id
	`id`       int unsigned auto_increment,
	-- 省略其它...
) engine = InnoDB
  default charset = utf8mb4;

-- 专属武器
create table `special_weapon`
(
	-- 主键id,同时也是外键关联角色
	`figure_id`   int unsigned,
	-- 省略其它...
	-- 在创建表同时指定外键
	foreign key (`figure_id`) references `figure` (`id`) on delete cascade on update cascade
) engine = InnoDB
  default charset = utf8mb4;

这个示例则是在创建专属武器表的同时,指定了其中的外键及其关联的字段。虽然这样可以一步到位,但是这也必须保证被关联的表被先创建,也就是说这样的话我们还需要注意表创建的顺序,这在关系复杂的时候是不太方便的。

如果说想在后续删除外键约束,我们可以首先执行show create table语句查看表的创建语句:

show create table `figure`;

结果:

image-20241018202643305

主要是看CONSTRAINT的行,上述示例表示figure中有名为figure_ibfk_1这个外键约束,如果要删除它们,则需要使用alter table xxx drop foreign key语句,指定要删除的外键约束名(而不是外键名):

alter table `figure` drop foreign key `figure_ibfk_1`;

这样,就删除了名为figure_ibfk_1的约束。

事实上,我们在创建外键约束时是可以指定其名称的,上述figure_ibfk_1是由于之前创建外键约束时未指定约束名称,系统自己生成的,如果要指定名称的话使用下列语句:

alter table `figure`
	add constraint `fk_guild_id`
		foreign key (`guild_id`) references `guild` (`id`)
			on delete cascade on update cascade;

可见这里多了一个add constraint子句,后面接的就是你自定义的这个约束的名称。

3. 初始化数据

接下来,就需要进行数据初始化了!我们继续执行下列SQL语句:

-- 初始化数据

-- 公会数据
insert into `guild` (`name`)
values ('小小甜心'), -- 自增主键,第一个id为1,后以此类推
	   ('恶魔伪王国军');

-- 角色数据
insert into `figure` (`name`, `nickname`, `type`, `guild_id`)
values ('镜华', 'xcw', '后卫法师输出', 1), -- 自增主键,第一个id为1,后以此类推
	   ('美美', '兔玛西亚正义', '中卫物理输出', 1),
	   ('未奏希', '炸弹人', '前卫物理辅助', 1),
	   ('宫子', '布丁', '前卫物理坦克', 2),
	   ('茜里', '妹法', '中卫法师辅助', 2),
	   ('伊莉亚', '那个女人', '中卫法师输出', 2);

-- 专武数据
insert into `special_weapon`
values (1, '宇宙蓝色魔杖', '镜华常用的魔杖,宝玉中蕴含着高阶水龙的力量,据说能根据不同的使用者来改变自己的形态'), -- 对应角色id
	   (2, '兔兔长剑', '美美常用的剑,美美十分喜欢它可爱的外观,但挥起来时,攻击范围却跟外观成反比,能让敌人尝到苦头'),
	   (3, '捣蛋礼物', '未奏希骄傲地将这个恶作剧盒子称为她的最高杰作,可不能把它当成是小孩子的玩具,其视觉效果和冲击的声音一定能让敌人吓破胆'),
	   (4, '灵甘幽灵布丁', '宫子的最爱的究极布丁,个头巨大且制作细致,材料也十分讲究的'),
	   (5, '恶魔三叉戟', '茜里十分珍爱的一把魔枪,蕴藏着强大魔力'),
	   (6, '夜之牙暗斧', '跟伊莉亚共同度过漫长时光的斧头,只要挥舞一下那闪烁着红色魔力的巨刃就能歼灭敌人');

-- 武器数据
insert into `weapon` (`name`, `figure_id`)
values ('棒棒糖手杖', 1),-- 自增主键,第一个id为1,后以此类推
	   ('白圣石钻石之星', 1),
	   ('伐樱斧', 2),
	   ('向日葵草帽', 2),
	   ('沙尘瀑布手套', 3),
	   ('爆米花项链', 3),
	   ('通天手甲·犀打', 4),
	   ('红宝石玫瑰项圈', 4),
	   ('三色球短杖', 5),
	   ('翠奏竖琴的胸针', 5),
	   ('苍晚夜响曲的胸针', 6),
	   ('黑蛇魔炎杖', 6);

-- 剧情数据
insert into `story` (`name`)
values ('小小甜心冒险家'),-- 自增主键,第一个id为1,后以此类推
	   ('不给布丁就捣蛋'),
	   ('吸血鬼猎人with伊莉雅'),
	   ('小小的勇气-万圣节之夜'),
	   ('圣诞布丁快乐!');

-- 剧情-角色关系表
insert into `story_figure`
values (1, 1),
	   (1, 2),
	   (1, 3),
	   (2, 4),
	   (2, 5),
	   (3, 6),
	   (4, 1),
	   (4, 2),
	   (4, 3),
	   (5, 4),
	   (5, 5);

到此,我们就完成了全部数据初始化了!注意一下其中的自增主键就可以了!

上述数据不保证100%的完整性以及和游戏中数据一致!仅供本示例学习使用!

4. 常见查询示例

在这里我们先使用SQL语句来实现一对多以及多对多的查询,先感受一下这些表是怎么连接起来的。

查询id2的角色及其专属武器信息

select `figure`.*, `special_weapon`.`name`, `special_weapon`.description
from `figure`
		 inner join `special_weapon` on `figure`.`id` = `special_weapon`.`figure_id`
where `figure`.`id` = 2;

结果:

image-20241018203721589

查询“宫子”属于哪个公会:

select `guild`.*
from `guild`
		 inner join `figure` on `guild`.`id` = `figure`.`guild_id`
where `figure`.`name` = '宫子';

结果:

image.png

查询公会“小小甜心”中的所有成员:

select `figure`.*
from `figure`
		 inner join `guild` on `figure`.`guild_id` = `guild`.`id`
where `guild`.`name` = '小小甜心';

结果:

image-20241018203808300

这两个示例都是一对多的查询示例,使用了inner join表连接的方式,可见借助外键连接了关联的表后,我们就可以查询出目的对象相关联的信息。

查询“镜华”出现在了哪些剧情当中:

select `story`.*
from `story`
		 inner join `story_figure` on `story`.`id` = `story_figure`.`story_id`
		 inner join `figure` on `story_figure`.`figure_id` = `figure`.`id`
where `figure`.`name` = '镜华';

结果:

image.png

查询剧情“小小的勇气-万圣节之夜”中有哪些角色参与:

select `figure`.*
from `figure`
		 inner join `story_figure` on `figure`.`id` = `story_figure`.`figure_id`
		 inner join `story` on `story_figure`.`story_id` = `story`.`id`
where `story`.`name` = '小小的勇气-万圣节之夜';

结果:

image-20241018203858773

可见这两个示例是针对多对多关系的查询,我们借助多对多关系表进行了两次连接操作,一共连接三张表,来查询出某个对象相关联的信息,可见有了多对多关系表,我们可以双向查询多对多两者的关联。

平常在SSM开发中都是通过id查询,这里为了更加直观就通过名字查询了!不过在平时的开发中,除非是业务需要,否则尽量使用主键id进行查询。

3,对应的Java类设计

在简单的情况下,数据库表的字段和Java类的字段是完全一一对应的,但是它们在任何情况下都是一一对应的吗?事实上并不是。

(1) 一对一

仍然是以角色和专属武器为例,这里设计出角色和专属武器的Java类结构如下:

image-20241018204034584

上述蓝色属性,和数据库表结构存在一部分差异,这是因为在Java类中属性可以是复杂的结构,而数据库中不行(关系型数据库的第一范式)。

在这里的示例中,我们直接在Figure类中,加入其关联的专属武器类型属性即可,这样查询角色时,可以一起将专属武器也给查出来,并将专属武器对象赋值给角色对象中的specialWeapon属性,此外专属武器类中表示主键id的字段也可以去掉了,因为这个字段值和角色的id相同。

可见在这里我们将数据库表设计为Java类时,需要做一定的转换,在这里一对一的情况下,我们需要将被拥有的类(专属武器)的表示主键的字段去掉,然后在拥有者类(角色)中添加一个字段表示其拥有的类型(专属武器):

image-20241018204621946

在这里我们先设计成这样即可,后续我们将借助MyBatis级联功能实现一起查询。

(2) 一对多

在一对多的情况下,我们就需要分两种情况讨论了!因为一对多的两类对象,可能出现下列关系:

  • 从属关系:角色通常属于某个公会
  • 拥有关系:角色通常拥有一些武器

这两种关系也通常是根据实际业务逻辑决定的:

  • 我们通常需要查询角色所属的公会,而不是查询公会所有的角色的,那就设计为角色属于公会的关系(而非公会拥有角色)
  • 我们通常需要查询一个角色装备了哪些武器,而非查询一个武器被哪个角色使用,那就设计为角色拥有武器(而非武器属于角色)

虽然说一对多中,从属关系和拥有关系本身就是同时存在的,但是在类的设计中,我们通常只能凸出它们的某一种关系,所以为了使得我们的类设计得更加直观,我们需要去考虑到底是凸出两者的从属关系还是拥有关系,并按照不同的方法设计类。

可能在这里大家还是有点迷糊,我们来设计一下对应的Java类就知道了!

1. 从属关系

我们首先设计一下角色和公会之间的关系,既然通常查询角色所属公会,那么就设计Java类如下:

image-20241018204735235

在表示一对多从属关系时,我们将“多”的表中的表示“一”的主键字段,设计为“多”的类中类型为“一”属性即可:

image-20241018223032876

所以,设计成这样的话,是不是我们只要查询角色,就会连带着其公会的信息一起查询出来呢?

这里我们先只关注角色和公会的关系,因此暂时在图中删除了其它外键,下面的示例也一样。

2. 拥有关系

既然角色和武器需要凸出角色拥有武器的关系,那么能不能实现查询角色的时候同时连带查询到角色装备的武器呢?我们设计类图如下:

image-20241018205052158

可见同样是一对多关系,对于角色和公会,以及角色和武器设计方式都是不同的,在一对多拥有关系中,设计成Java类时:

  • 对于“多”那个类,直接删除其外键,不将外键设计到Java类中
  • 对于“一”那个类,在其中添加一个存放“多”的类对象的列表属性

image-20241018205227518

也可见无论是凸出哪种关系,对于一对多来说,数据库表的设计总是一样的,只不过Java的类设计上会有点区别。

(3) 多对多

对于多对多情况下,我们无需再设计其关系表的类,只需在两个对应的类中,加入一个List类型属性,表示其中关联互相即可:

image-20241018205548568

(4) 总体

到此,我们就完成了全部关系的Java类的设计了!这里我就不粘贴代码到此了,具体的代码可以在文章末尾的示例程序仓库中找到,整体的类图如下:

大家可以先按照这个类图完成对应的Java类的代码编写。

4,MyBatis Mapper XML编写

接下来就是重点了!我们需要完成对应的DAO层接口编写和Mapper XML编写来实现对象之间一对多和多对多的级联关系,下面我们将通过部分resultMapselect部分片段,实现一对一、一对多和多对多级联查询操作。

(1) association实现一对一和一对多从属关系查询

1. 一对一级联

我们首先需要实现专属武器的根据角色id查询,创建接口SpecialWeaponDAO,编写代码如下:

package com.gitee.swsk33.relationmapping.dao;

import com.gitee.swsk33.relationmapping.dataobject.SpecialWeapon;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface SpecialWeaponDAO {

	/**
	 * 根据角色id查询
	 */
	SpecialWeapon getByFigureId(int figureId);

}

创建对应的Mapper XML文件SpecialWeaponDAO.xml,如下:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gitee.swsk33.relationmapping.dao.SpecialWeaponDAO">
	<resultMap id="specialWeaponResultMap" type="com.gitee.swsk33.relationmapping.dataobject.SpecialWeapon">
		<result column="name" property="name"/>
		<result column="description" property="description"/>
	</resultMap>

	<select id="getByFigureId" resultMap="specialWeaponResultMap">
		select *
		from special_weapon
		where figure_id = #{figureId}
	</select>
</mapper>

到这里还是很简单的,就是编写一个查询专属武器的方法,而角色id也就是专属武器的主键了,后续查询角色时,就可以拿角色的id查询其对应的专属武器。

那么在角色表中,我们需要实现一对一关系的关联查询实现,即当我们查询出角色的时候,同时也要查询出其关联的专属武器信息。

同样地创建FigureDAO接口表示角色查询接口:

package com.gitee.swsk33.relationmapping.dao;

import com.gitee.swsk33.relationmapping.dataobject.Figure;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface FigureDAO {

	/**
	 * 根据id查询
	 */
	Figure getById(int id);

}

然后编写对应的Mapper XML文件:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gitee.swsk33.relationmapping.dao.FigureDAO">
	<resultMap id="figureResultMap" type="com.gitee.swsk33.relationmapping.dataobject.Figure">
		<id column="id" property="id"/>
		<result column="name" property="name"/>
		<result column="nickname" property="nickname"/>
		<result column="type" property="type"/>
		<association property="specialWeapon" select="com.gitee.swsk33.relationmapping.dao.SpecialWeaponDAO.getByFigureId" column="id"/>
	</resultMap>

	<select id="getById" resultMap="figureResultMap">
		select * from figure where id = #{id}
	</select>
</mapper>

这里重点是resultMap中的association标签,它用于处理一个对象关联另一个对象的情况,通常这种关联关系可以通过数据库中的外键来表示,该标签各个属性意义如下:

  • property 表示这个复杂类型字段对应的类中的属性名
  • select 表示查询这个字段的数据库表方法,填写DAO结果中的方法全限定名
  • column 查询参数,用select节点语句查得的结果中某一个字段值作为参数,填入select指定的方法进行查询,多个参数使用逗号隔开

除此之外还可以设定一个fetchType属性为lazy表示懒查询,也就是说当查询了这个表但是没使用这个复杂字段时,就不会去查询这个字段以提升性能,解决N+1问题。

但是,如果你就是想一次性将数据返回给前端的话,就别设定这个属性了或者是设定为eager了!

那么association是怎么工作的呢?我们来看看。

我们知道,resultMap的作用就是把数据库表和我们的Java类对应起来,在取出记录之后,把记录中的值赋给对应的类的相应属性。

上述例子中,角色表有idnamenicknametype等字段,那么select节点也是会先将这些原始数据取出。

遇到了association,其中设定了参数(column属性)为id字段,设定了查询方法(select标签)为SpecialWeaponDAO中的getByFigureId,那么MyBatis会把取出记录中的id字段值作为参数使用getByFigureId方法进行查询,查得一个SpecialWeapon实例,将其赋给这个Figure实例的specialWeapon属性上,可见这里其实进行了两次查询操作

上述这个过程我们可以总结如下几步:

  • 主查询:
    • 查询figure表,并先根据resultMap中的idresult这些简单字段(基本类型)对应关系进行映射
    • 根据association中指定的属性,MyBatis将这个值传递给SpecialWeaponDAOgetByFigureId方法作为参数,该方法如何进行查询已经在SpecialWeaponDAO.xml中进行了实现
  • 内部查询:
    • 执行SpecialWeaponDAOgetByFigureId方法后,就从special_weapon表中查得了一条记录并转换成了SpecialWeapon对象
    • 将得到的SpecialWeapon对象赋值给主查询得到的Figure实例中的specialWeapon属性

image-20241018213010233

最终,一个完整的Figure对象就给组装好了!通过这个过程也知道了,原始SQL语句查询得到的字段,也是可以作为参数进行二次查询的。

2. 一对多从属级联

好的,学会了association怎么用,那么角色和公会的关联是不是也是一个道理呢?

我们来编写公会查询接口如下:

package com.gitee.swsk33.relationmapping.dao;

import com.gitee.swsk33.relationmapping.dataobject.Guild;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface GuildDAO {

	Guild getById(int id);

}

对应的Mapper XML如下:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gitee.swsk33.relationmapping.dao.GuildDAO">
	<resultMap id="guildResultMap" type="com.gitee.swsk33.relationmapping.dataobject.Guild">
		<id column="id" property="id"/>
		<result column="name" property="name"/>
	</resultMap>

	<select id="getById" resultMap="guildResultMap">
		select *
		from guild
		where id = #{id}
	</select>
</mapper>

然后修改角色查询的Mapper XML如下:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gitee.swsk33.relationmapping.dao.FigureDAO">
	<resultMap id="figureResultMap" type="com.gitee.swsk33.relationmapping.dataobject.Figure">
		<id column="id" property="id"/>
		<result column="name" property="name"/>
		<result column="nickname" property="nickname"/>
		<result column="type" property="type"/>
		<association property="specialWeapon" select="com.gitee.swsk33.relationmapping.dao.SpecialWeaponDAO.getByFigureId" column="id"/>
		<association property="guild" select="com.gitee.swsk33.relationmapping.dao.GuildDAO.getById" column="guild_id"/>
	</resultMap>

	<select id="getById" resultMap="figureResultMap">
		select * from figure where id = #{id}
	</select>
</mapper>

可见对于一对一和一对多从属关系,在一个Java对象关联另一个对象的情况下,借助association就可以实现查询得到一个对象的同时,连带查询出其关联的对象,并最终组装为完整对象。

(2) collection标签实现一对多拥有关系查询

接下来我们来认识一个新的标签collection,它和association标签类似,只不过它通常处理一个对象包含多个对象的情况。

例如一个角色拥有多个武器,那么武器就是以List形式表示在角色属性中,使用collection就可以把查询结果转换成List并组装到对应属性上了!

现在我们来实现一下角色和武器拥有关系查询,首先编写武器查询接口:

package com.gitee.swsk33.relationmapping.dao;

import com.gitee.swsk33.relationmapping.dataobject.Weapon;
import org.apache.ibatis.annotations.Mapper;

import java.util.List;

@Mapper
public interface WeaponDAO {

	/**
	 * 根据id获取
	 */
	Weapon getById(int id);

	/**
	 * 根据使用武器的角色id查询
	 */
	List<Weapon> getByFigureId(int figureId);

}

可见这里武器还多了个getByFigureId方法,就是后续用于根据角色id查询一个角色装备的武器,并组装至角色对象中去。

其对应的Mapper XML如下:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gitee.swsk33.relationmapping.dao.WeaponDAO">
	<resultMap id="weaponResultMap" type="com.gitee.swsk33.relationmapping.dataobject.Weapon">
		<id column="id" property="id"/>
		<result column="name" property="name"/>
	</resultMap>

	<select id="getById" resultMap="weaponResultMap">
		select id, name
		from weapon
		where id = #{id}
	</select>

	<select id="getByFigureId" resultMap="weaponResultMap">
		select id, name
		from weapon
		where figure_id = #{figureId}
	</select>
</mapper>

好的,现在我们修改一下角色的Mapper XML,添加一个collection标签:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gitee.swsk33.relationmapping.dao.FigureDAO">
	<resultMap id="figureResultMap" type="com.gitee.swsk33.relationmapping.dataobject.Figure">
		<id column="id" property="id"/>
		<result column="name" property="name"/>
		<result column="nickname" property="nickname"/>
		<result column="type" property="type"/>
		<association property="specialWeapon" select="com.gitee.swsk33.relationmapping.dao.SpecialWeaponDAO.getByFigureId" column="id"/>
		<association property="guild" select="com.gitee.swsk33.relationmapping.dao.GuildDAO.getById" column="guild_id"/>
		<collection property="weapons" select="com.gitee.swsk33.relationmapping.dao.WeaponDAO.getByFigureId" column="id"/>
	</resultMap>

	<select id="getById" resultMap="figureResultMap">
		select * from figure where id = #{id}
	</select>
</mapper>

可见在resultMap中,我们新增了一个collection标签,其中对应的属性意义和上述的association是完全相同的,根据上述collection的设定,使得MyBatis查询到结果时,能够将查得的角色id字段作为参数传递给查询武器的getByFigureId方法并查询出这个角色拥有的武器列表,最后把这个列表填充至角色对象的weapons属性中,完成组装,同样地这也是执行了两次查询操作。

image-20241018213715388

(3) collection实现多对多查询

上述我们使用的是collection的自闭合标签写法,事实上还有另一种写法,可以实现将关联查询的结果填充至我们的对象中去。

我们首先来实现一下查询角色时,关联查询出该角色参与的剧情故事,修改角色Mapper XML如下:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gitee.swsk33.relationmapping.dao.FigureDAO">
	<resultMap id="figureResultMap" type="com.gitee.swsk33.relationmapping.dataobject.Figure">
		<id column="id" property="id"/>
		<result column="name" property="name"/>
		<result column="nickname" property="nickname"/>
		<result column="type" property="type"/>
		<association property="specialWeapon" select="com.gitee.swsk33.relationmapping.dao.SpecialWeaponDAO.getByFigureId" column="id"/>
		<association property="guild" select="com.gitee.swsk33.relationmapping.dao.GuildDAO.getById" column="guild_id"/>
		<collection property="weapons" select="com.gitee.swsk33.relationmapping.dao.WeaponDAO.getByFigureId" column="id"/>
		<collection property="stories" ofType="com.gitee.swsk33.relationmapping.dataobject.Story">
			<id column="story_id" property="id"/>
			<result column="story_name" property="name"/>
		</collection>
	</resultMap>

	<select id="getById" resultMap="figureResultMap">
		select figure.*, story.id as story_id, story.name as story_name
		from figure
				 inner join story_figure on figure.id = story_figure.figure_id
				 inner join story on story_figure.story_id = story.id
		where figure.id = #{id}
	</select>
</mapper>

可见首先我们是修改了select节点,使得角色表和剧情故事表,通过角色和剧情关系表进行关联查询,得到角色及其参与的剧情故事信息。

然后我们又添加了一个collection标签,这个collection和上一个写法就不一样了!属性也不太相同,首先我们来看看这个属性:

  • property 表示这个集合对应的类中的属性名,和上面类似
  • ofType 表示这个集合中元素的类型

那么collection标签内部写法很类似于resultMap了!它定义了这个集合中元素的属性和查询的结果字段的对应关系,集合中的元素是剧情对象,因此上述collection中还定义了查询结果字段和故事对象的属性定义的关系。

至于collection是怎么工作的呢?我们首先手动执行一下select中的语句,来看一下得到的结果长啥样:

image-20241018214106693

我们应该只查询一个角色,但是出现了多个结果,且人物部分的信息是重复的,那么怎么把剧情的信息合并为一个集合呢?很显然,collection起了作用。

在上述resultMap中,除了角色表的idtypename等等简单类型字段被赋给了角色实例的相应属性,几个外键通过association转换成了复杂属性,剩余的字段story_idstory_name很显然被放进了Story类的实例,因为在collection中定义了对剧情故事类的映射。就这样MyBatis生成了多个剧情实例并将它们构成一个集合,赋给了角色类中的参与的剧情列表字段stories

image-20241018214430396

仔细琢磨select节点,是通过多个表的连接,实现关联查询。也就是说,这里的collection是基于关联查询的级联

除此之外,在select语句中,我们使用as对剧情故事的查询结果字段起了别名(把story表的id起别名为story_id,把name起别名为story_name),这是因为剧情表中的idname字段和角色表的同名,若不起别名将其区分会导致resultMap映射时发生错误(因为resultMap中同时包含了角色表和剧情故事表的映射)

这里有人会问:剧情类中也有角色列表这个属性,为什么不写进collection里面?因为这样没必要,并且会发生无限循环。

到此,整个角色类查询功能就实现了!其中包含了一对一、一对多两种关系和多对多的级联查询。

对于剧情表,反过来也是类似的,首先编写剧情对象查询接口:

package com.gitee.swsk33.relationmapping.dao;

import com.gitee.swsk33.relationmapping.dataobject.Story;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface StoryDAO {

	/**
	 * 根据id获取
	 */
	Story getById(int id);

}

对应的Mapper XML文件:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gitee.swsk33.relationmapping.dao.StoryDAO">
	<resultMap id="storyResultMap" type="com.gitee.swsk33.relationmapping.dataobject.Story">
		<id column="id" property="id"/>
		<result column="name" property="name"/>
		<collection property="figures" ofType="com.gitee.swsk33.relationmapping.dataobject.Figure">
			<id column="figure_id" property="id"/>
			<result column="figure_name" property="name"/>
			<result column="figure_nickname" property="nickname"/>
			<result column="figure_type" property="type"/>
		</collection>
	</resultMap>

	<select id="getById" resultMap="storyResultMap">
		select story.*, figure.id as figure_id, figure.name as figure_name, figure.nickname as figure_nickname, figure.type as figure_type
		from story
				 inner join story_figure on story.id = story_figure.story_id
				 inner join figure on story_figure.figure_id = figure.id
		where story.id = #{id}
	</select>
</mapper>

这里方便起见,collection中关联角色的时候,我们少写了几个角色的字段,大家可以按照同样地方式在collection中加入association实现将角色对象包含的复杂类型字段也加入进去。

事实上,association标签也存在和上述多对多情况下的collection标签一样的写法,大家可以自行在官方网站进行查阅。

(4) 一对一或者从属关系一对多插入记录

上述我们完成了对关联对象的查询,那么反过来,对于包含复杂属性的对象,又如何插入至数据库呢?

这其实也很简单,以角色为例,其中包含specialWeaponguild属性都是其关联的复杂字段,我们分别如下处理:

  • 对于专属武器(一对一),我们首先插入角色对象,并获取插入后的角色主键id,再插入专属武器对象,插入时把角色id作为专属武器的主键id,如果角色已存在,那么直接指定对应角色的id为新插入的专属武器对象的id
  • 对于公会(一对多从属),我们首先插入公会对象,并获取插入后的公会主键id,再插入角色对象,插入时把公会id赋值给角色的guild_id外键,如果公会已存在,则插入角色时直接把对应公会的id指定给角色guild_id外键即可

好的,我们首先是在角色DAO接口中添加一个方法表示插入:

/**
 * 插入一个角色数据
 */
int add(Figure figure);

然后在其Mapper XML中加入一个insert节点:

<insert id="add" parameterType="com.gitee.swsk33.relationmapping.dataobject.Figure" useGeneratedKeys="true" keyProperty="id">
	insert into figure (name, nickname, type, guild_id)
	values (#{name}, #{nickname}, #{type}, #{guild.id})
</insert>

可见插入语句的values后插值部分,我们使用guild.id表示角色对象中公会属性的id,也就是说要访问复杂字段的属性,通过.即可。

然后给专属武器的DAO也添加一个add方法并编写对应的Mapper XML语句,其DAO方法如下:

/**
 * 添加一个专属武器
 *
 * @param specialWeapon 添加的专属武器对象
 * @param figureId      对应的角色id
 */
int add(@Param("weapon") SpecialWeapon specialWeapon, @Param("figureId") int figureId);

可见除了传入要添加的专属武器之外,还需要指定一个角色id表示这个专属武器对应的角色,其Mapper XML如下:

<insert id="add" parameterType="com.gitee.swsk33.relationmapping.dataobject.SpecialWeapon">
	insert into special_weapon (figure_id, name, description)
	values (#{figureId}, #{weapon.name}, #{weapon.description})
</insert>

然后是公会的插入逻辑,就比较简单了,给公会的DAO新增一个add方法如下:

/**
 * 添加一个公会
 */
int add(Guild guild);

其对应Mapper XML如下:

<insert id="add" parameterType="com.gitee.swsk33.relationmapping.dataobject.Guild" useGeneratedKeys="true" keyProperty="id">
	insert into guild (name) values (#{name})
</insert>

需要注意的是,我们这里在角色和公会的Mapper XML中的insert节点上设定useGeneratedKeystrue以及keyProperty属性指定数据库主键对应的传入对象的字段,实现主键回填。

useGeneratedKeys可以打开主键回填,而keyProperty是用于指定生成的主键值应该赋给哪个对象属性的属性名。这样,比如说当我们传入一个Guild对象进行插入时,插入完成后,数据库自增主键得到的值会自动被MyBatis赋值给我们原来传入的Guild对象中的id属性。

好的,我们在测试类中编写一个插入新角色的方法试一下:

// 省略package和import...

@SpringBootTest
class RelationMappingApplicationTests {

	@Autowired
	private FigureDAO figureDAO;

	@Autowired
	private GuildDAO guildDAO;

	@Autowired
	private SpecialWeaponDAO specialWeaponDAO;

	/**
	 * 添加一个角色测试
	 */
	private void addFigure() {
		// 先新增一个公会
		Guild guild = new Guild();
		guild.setName("自卫团");
		// 虽然guild对象中未设定其id属性值,但是由于配置了主键回填,执行add方法后MyBatis会获取得到的自增主键并赋值给guild的id属性
		guildDAO.add(guild);
		// 组装角色并插入记录
		Figure figure = new Figure();
		figure.setName("真步");
		figure.setNickname("咕噜灵波");
		figure.setType("后卫法师辅助");
		figure.setGuild(guild);
		figureDAO.add(figure);
		// 最后插入专属武器,并关联角色id
		SpecialWeapon weapon = new SpecialWeapon();
		weapon.setName("纯洁童话权杖");
		weapon.setDescription("可爱又绚烂的魔法杖,魔杖上被赋予了真步真步王国的加护,只要轻轻一挥,就能在释放支援魔法的同时吹起一般童话般梦幻的风");
		specialWeaponDAO.add(weapon, figure.getId());
	}

	@Test
	void contextLoads() {
		addFigure();
	}

}

执行完成后,可以发现插入成功,我们可以查询到对应结果:

image-20241018221102618

image-20241018221222333

如果说是新增记录但是关联现有的其它表记录,那就更简单了!比如说在一个先有公会新增一个角色,根据insert节点中的语句我们也很容易知道只需要先新建一个Guild对象并只填写其id,再新建角色对象,将Guild对象填入后最后插入即可。

5,不使用关联查询怎么办?

可见通过关联查询我们可以很方便地实现一对一或者一对多,以及多对多查询,但是大家也常常会听说一些公司禁止使用关联查询,或者说是超过多少张表时就不允许使用关联查询了,这是为什么呢?

事实上,这和MySQL的一个底层运作原理有关,在数据量较大的情况下,使用关联查询会导致效率大幅降低,尤其是数据量大并且一次关联的表较多的情况下。

既然不使用关联查询,我们使用手动组装的方式不就行了吗?也就是说多次进行查询,然后在Java代码中组装对象。

事实上,上述涉及到的几个MyBatis查询示例,只有多对多是通过关联查询得到的,其它的级联操作事实上是由MyBatis进行多次查询并组装的。

那么我们可以优化一下上述多对多的查询,将其改为手动组装。

我们首先在StoryDAO接口中加入下列两个方法:

/**
 * 根据id列表查询多个剧情故事,但是仅查询剧情中简单字段(不级联查询组装复杂类型字段)并返回对象
 */
List<Story> getSimpleByIds(List<Integer> ids);

/**
 * 根据角色id,从多对多关联表中获取该角色参与的剧情故事的id列表
 */
List<Integer> getIdListByFigureId(int figureId);

编写它们对应的Mapper XML如下:

<select id="getSimpleByIds" resultMap="storyResultMap">
	select id, name from story where id in
	<foreach collection="ids" item="it" open="(" separator="," close=")">
		#{it}
	</foreach>
</select>

<select id="getIdListByFigureId">
	select story_id from story_figure where figure_id = #{figureId}
</select>

然后在FigureDAO中也新增一个查询方法表示只查询角色的基本字段:

/**
 * 根据id查询,但是只查询简单字段,不级联查询复杂类型字段
 */
Figure getSimpleById(int id);

对应Mapper XML如下:

<select id="getSimpleById" resultMap="figureResultMap">
	select id, name, nickname, type, guild_id
	from figure
	where id = #{id}
</select>

可见我们将多对多三表关联查询操作进一步地拆分,然后就可以进行多次查询,最后组装。

我们在测试类中尝试查询组装一下:

// 省略package和import...

@SpringBootTest
class RelationMappingApplicationTests {

	@Autowired
	private FigureDAO figureDAO;

	@Autowired
	private StoryDAO storyDAO;

	/**
	 * 手动查询组装实现级联查询
	 */
	private void queryUseAssemble() {
		// 查询角色
		int id = 2;
		Figure getFigure = figureDAO.getSimpleById(id);
		// 查询对应的剧情故事id列表
		List<Integer> storyIds = storyDAO.getIdListByFigureId(id);
		// 用这个列表查询剧情集合,并填充至角色对象中
		List<Story> stories = storyDAO.getSimpleByIds(storyIds);
		getFigure.setStories(stories);
		System.out.println(getFigure);
	}

	@Test
	void contextLoads() {
		queryUseAssemble();
	}

}

可以成功实现查询。

可见上述查询,对于一对一和一对多都是通过定义association和自闭合的collection使得MyBatis能够帮我们进行自动组装,而对于多张表连接如果不使用关联查询,就需要手动组装了!通常手动组装的逻辑也是在服务层(Service层)完成。

在数据量大的情况下,手动组装可以提升一些性能,但是这也会增加系统的复杂度,因此两种方式应当综合考虑。

6,总结

虽然我们常常调侃后端开发就是增删改查工程师,但是要想设计好一个数据模型,并能够紧密结合实际业务,很好地完成增删改查,也并非是很简单的一件事。至少一些基础性的知识,例如一对一、一对多和多对多表关系设计和处理等等,是需要我们熟练掌握的。

本文以游戏《公主连结Re:Dive》中角色、专属武器、公会、武器和剧情故事这几类对象的关系为例,讲述了如何设计一对一、一对多和多对多数据库表格,以及它们对应的Java类是什么样的,最后我们使用MyBatis实现了对象之间的关联查询,在这个过程中,一定要理清楚数据库表和Java对象的对应关系,以及MyBatis Mapper XML中的配置的对应关系,以及MyBatis进行级联查询的大致步骤,这样可以使得我们更加熟练地用好MyBatis实现增删改查。

示例仓库地址:传送门