在做一个系统之前,设计其数据库表、建立类图(数据模型),也是非常重要的一步。
一个复杂的系统,其各个数据之间也有着各种关系,这也给我们设计数据模型造成了困扰。今天我就来分享一下,数据库中的表的关系以及实际的操作方案。
1,概述
其实数据之间的关系,无外乎就这么几种:
其实一对一是最好理解和操作的,在实现上有一定的难度的就是多对多了。
那么今天我主要是分享,实际情况下我们怎么实现这些关系,以及在SSM框架中,我们怎么操作。文末会给出示例代码仓库。
今天我将以游戏《公主连结 Re:Dive》中的角色、公会和武器为例,来讲解这个关系的实现。
在公主连结游戏中,有很多的角色,其中每个角色属于一个公会(组织),其中一个角色还需要装备一些武器,且每个角色有她的专属武器。
这里可见:
- 角色和专属武器是一对一的关系,一个角色只能有一个专属武器,一个专属武器也只属于一个角色
角色“宫子”只有一个专属武器“灵甘幽灵布丁”
- 公会和角色是一对多的关系,一个角色只能属于一个公会,一个公会可以有多个角色

角色“美美”和“镜华”都属于公会“小小甜心”,且她们每个人只属于一个公会
- 武器和角色是多对多的关系,一个角色会装备多个武器,而一个武器也可能被多个角色使用
角色“未奏希”装备了“岚神风暴护手”等四件武器装备

角色“真步”和“茜里”都装备了武器“细冰姬的蝴蝶结”
一对一关系比较简单,所以今天就只讲解一对多、多对多这两个关系。
2,数据库表的设计
根据以上信息,我们一步一步地来设计数据库表。
首先是建立角色(character)、公会(guild)、武器(weapon) 三个表:

我们先来实现公会和角色的一对多关系,其实很简单,只需要在角色表中新增一个字段表示公会主键id即可:

也就是说,一对多关系中,只需要在“多”的那个表中,加入一个字段表示“一”的那个表的主键字段,作为“多”的表的外键,就实现了一对多的关系的构建和联系,可以说这个还是很简单的。
然后我们来实现角色和武器多对多的关系。多对多需要在两个表之间额外再建立一个表专门用于表示两个表的关系,这个表一般没有主键,只有外键,其外键就是两个“多”的表的主键:

可见weapon_character
表即为“武器-角色”关系表,它是用于建立武器和角色表的多对多的桥梁。
至于多对多为什么这么设计呢?我们举个例子,把几个表格放出来看看:

看见“角色-武器”表,我们就恍然大悟了,在这个表中,表示了:id为0的角色(镜华)使用id为0的武器(牺牲月法杖),id为0的角色(镜华)还使用id为1的武器(细冰姬的蝴蝶结)。
这样,角色和武器表只需要存储自己的信息即可,使用“角色-武器”表,以表示两者之间多对多的关系。
正是借助“角色-武器”表,通过MySQL关联查询,也可以很方便地双向查询彼此关系。
至此,我们的数据库表设计就完成了!
这里放上sql语句,这里除了建表之外,也会初始化一部分示例数据:
drop table if exists `guild`;
create table `guild`
(
`id` int not null,
`name` varchar(16) not null unique,
primary key (`id`)
) engine = InnoDB
default charset = utf8mb4;
drop table if exists `weapon`;
create table `weapon`
(
`id` int not null,
`name` varchar(16) not null unique,
primary key (`id`)
) engine = InnoDB
default charset = utf8mb4;
drop table if exists `character`;
create table `character`
(
`id` int not null,
`name` varchar(16) not null unique,
`type` varchar(8) not null,
`guild_id` int not null,
primary key (`id`),
foreign key (`guild_id`) references `guild` (`id`) on delete cascade on update cascade
) engine = InnoDB
default charset = utf8mb4;
drop table if exists `weapon_character`;
create table `weapon_character`
(
`character_id` int not null,
`weapon_id` int not null,
primary key (`character_id`, `weapon_id`),
foreign key (`character_id`) references `character` (`id`) on delete cascade on update cascade,
foreign key (`weapon_id`) references `weapon` (`id`) on delete cascade on update cascade
) engine = InnoDB
default charset = utf8mb4;
insert into `guild`
values (0, '小小甜心'),
(1, '恶魔伪王国军'),
(2, '自卫团');
insert into `character`
values (0, '镜华', '后卫法师输出', 0),
(1, '美美', '中卫物理输出', 0),
(2, '未奏希', '前卫物理辅助', 0),
(3, '宫子', '前卫物理坦克', 1),
(4, '茜里', '中卫法师辅助', 1),
(5, '伊莉亚', '中卫法师输出', 1),
(6, '真步', '后卫法师辅助', 2),
(7, '真琴', '前卫物理输出', 2),
(8, '香澄', '后卫法师辅助', 2);
insert into `weapon`
values (0, '牺牲月渊杖'),
(1, '细冰姬的蝴蝶结'),
(2, '星域天球杖'),
(3, '水之支配者神凑剑'),
(4, '焰火牡丹花簪'),
(5, '毁灭之伤冥神枪'),
(6, '海龙神的发饰'),
(7, '岚神风暴护手');
insert into `weapon_character`
values (0, 0),
(0, 1),
(1, 3),
(1, 4),
(2, 6),
(2, 7),
(3, 5),
(3, 6),
(4, 1),
(4, 2),
(5, 0),
(5, 1),
(6, 1),
(6, 2),
(7, 3),
(7, 4),
(8, 1),
(8, 2);
我们看到,上述在建立角色表中指定了其中guild_id
为外键,并指定了级联操作。在一个表创建之时指定外键,我们使用foreign key
语句,上述角色表中,我们指定公会id字段为外键,并关联公会表的主键;建立角色-武器关联表时,我们同时指定其中角色id、武器id字段为外键并关联角色、武器表的主键,这样就建立起来了几个表的外键约束关系。
通常,外键和被关联的键的数据类型、长度必须完全一致!如果说一个表的主键是无符号自增主键,那么对应关联它的外键也应该是无符号的整数,例如:
create table `a` (
`id` int unsigned auto_increment,
...,
primary key (`id`)
)...;
create table `b` (
`id` int unsigned auto_increment,
`a_id` int unsigned not null,
...,
foreign key (`a_id`) references `a` (`id`) on delete cascade on update cascade,
...
)...;
上述表b
中的外键是a_id
关联表a
的主键,由于表a
的主键是无符号自增主键,因此字段a_id
也应该设为整型无符号类型。如果只设定a_id
为整型就会在关联外键时报错。
语句在最后面的on delete cascade on update cascade
表示级联删除和更新,这样,譬如说我们删除一个角色的时候,在角色-武器关联表中关于这个角色的对应的记录也会被删除。
如果说创建表的时候没有添加外键约束,也可以在后续表创建好了后用alter table
语句进行外键约束:
alter table `character` add foreign key (`guild_id`) references `guild` (`id`) on delete cascade on update cascade;
ok,这里来几个简单的查询例子。
查询角色“宫子”属于哪个公会:
select `guild`.*
from `guild`
left join `character` on `guild`.id = `character`.guild_id
where `character`.name = '宫子';
结果:

查询公会“小小甜心”中的所有成员
select `character`.*
from `character`
left join `guild` on `character`.guild_id = `guild`.id
where `guild`.name = '小小甜心';
结果:

查询角色“镜华”使用了哪些武器
select `weapon`.*
from `weapon`
left join `weapon_character` on `weapon`.id = `weapon_character`.weapon_id
left join `character` on `weapon_character`.character_id = `character`.id
where `character`.name = '镜华';
结果:

查询武器“细冰姬的蝴蝶结”被哪些角色使用
select `character`.*
from `character`
left join `weapon_character` on `character`.id = `weapon_character`.character_id
left join `weapon` on `weapon_character`.weapon_id = `weapon`.id
where `weapon`.name = '细冰姬的蝴蝶结';
结果:

可见,通过上述的方式建立了表之间的一对一、一对多的关系,可以实现灵活地双向查询,主要也是通过左连接,实现各种关系查询。
一对一的关系就更简单了,只需要把两者任意一个的主键作为另一者的外键即可。
平常在SSM开发中都是通过id查询,并且会在select时指定查询字段且起别名,这里方便起见,就通过名字查询。这里方便起见也没有使用自增主键,但是平时开发过程中是要使用的。
3,SSM中的级联
那么这样的关系,在MyBatis中又应当如何实现呢?一般是通过级联来实现。
还是先设计类图,因为这里存在着互相关联的关系,因此这里设计的类,会和平常简单情况下有点小区别。也就是说,数据库表字段和类属性不再完全一一对应。
我们做类图如下:

大家发现,图中紫色的属性,和上述数据库表结构有一些差异,并且没有设计“武器-角色”类,因为在类中,属性可以是复杂的数据结构,但是数据库表字段不行。
也就是说,在这样的互相关联的数据中,我们将数据库表设计为Java的类时,需要做一定的“转换”:
在一对多情况下,需要将“多”的表中的表示“一”的主键字段,设计为“多”的类中类型为“一”属性:

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

好了,设计好了类,就需要编写MyBatis Mapper XML了,这里才是真正的难点。
为了看着方便,下面的示例XML文件我只贴出resultMap
节点和select
节点。
我们的DAO
层只写查询的方法,这里就不贴DAO
类的代码了。
先编写最简单的,公会的Mapper XML文件:
<resultMap id="guildResultMap" type="com.example.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>
这里不再多说,然后编写角色的:
<resultMap id="characterResultMap" type="com.example.relationmapping.dataobject.Character">
<id column="id" property="id"/>
<result column="name" property="name"/>
<result column="type" property="type"/>
<association property="guild" select="com.example.relationmapping.dao.GuildDAO.getById" column="guild_id" fetchType="lazy"/>
<collection property="weapons" ofType="com.example.relationmapping.dataobject.Weapon" fetchType="lazy">
<id column="weapon_id" property="id"/>
<result column="weapon_name" property="name"/>
</collection>
</resultMap>
<select id="getByName" resultMap="characterResultMap">
select `character`.*, `weapon`.id as weapon_id, `weapon`.name as weapon_name
from `character`
left join `weapon_character` on `character`.id = `weapon_character`.character_id
left join `weapon` on `weapon_character`.weapon_id = `weapon`.id
where `character`.name = #{name}
</select>
这里重点是resultMap
中的association
和collection
节点,我们一一来看:
association
表示一个复杂类型的关联,一般用于一对多中的“多”的resultMap
中,表示“多”的类(角色)中间那个“一”的属性(公会),其上面各个属性意义:
property
表示这个复杂类型字段对应的类中的属性名select
表示查询这个字段的数据库表方法,填写DAO中的方法全限定名column
查询参数,用<select>
节点语句查得的结果中某一个字段值作为参数,填入select指定的方法进行查询,多个参数使用逗号隔开fetchType
一般设定为lazy
表示懒查询,也就是说当查询了这个表但是没使用这个复杂字段时,就不会去查询这个字段,以提升性能,解决N+1问题
那么association
是怎么工作的呢?我们来看看。
我们知道,resultMap
的作用就是把数据库表和我们的Java类对应起来,在取出记录之后,把记录中的值赋给对应的类的相应属性。
上述例子中,角色表有id
、name
、type
、和guild_id
,那么<select>
节点也是会先将这些原始数据取出。
遇到了association
,其中设定了参数(column
)为guild_id
字段,设定了查询方法(select
)为GuildDAO
中的getById
,那么MyBatis会把取出记录中的guild_id
字段值作为参数使用getById
方法进行查询,查得一个Guild
实例,将其赋给这个Character
实例的guild
属性上。可见,这里其实进行了两次查询操作。
通过这个也知道了,select节点中一定要选择到guild_id
这个字段,否则无法成功关联。
collection
表示这个字段类型是个集合,多对多的类的resultMap
中就使用collection
表示自己的集合属性。其上面各个属性意义:
property
表示这个集合对应的类中的属性名,和上面类似ofType
表示这个集合中元素的类型fetchType
同上
在collection
中,我们定义了集合元素类的映射关系。
那么collection
又是怎么工作的呢?
首先,我们来执行一下这个<select>
节点中的语句看看会得到哪些结果:

我们应该只查询一个角色,但是出现了多个结果,且人物的信息是重复的,那么怎么把武器的信息合并为一个集合呢?很显然,collection
起了作用。
在上述resultMap
中,除了角色表的id
、type
和name
被赋给了角色实例的相应属性,guild_id
通过association
转换为了相应复杂属性,剩余的字段weapon_id
、weapon_name
很显然被放进了Weapon
类的实例,因为在collection
中定义了对武器类的映射。就这样MyBatis生成了多个武器实例并将每一条记录值放进去,这些武器实例构成一个集合,赋给了角色类中的使用武器字段。

仔细琢磨<select>
节点,也是通过多个表的连接,实现关联查询。也就是说,这里的collection
是基于关联查询的级联。
除此之外,在select
语句中,我们使用as
对武器的查询结果字段起了别名(把weapon
表的id
起别名为weapon_id
,把name
起别名为weapon_name
),这是因为武器表中的id
、name
字段和角色表的同名,若不起别名将其区分会导致resultMap
映射时发生错误(因为resultMap
中同时包含了角色表和武器表的映射)。
这里有人会问:武器类中也有角色列表这个属性,为什么不写进collection里面?因为这样没必要,并且会发生无限循环。
好了,这样就完成了角色的XML编写,反过来武器的也是差不多,这里就不贴代码了。
最后我们测试测试,在Test里面写两个查询方法并调用:
private void getCharacterInfo(String name) {
Character xcw = characterDAO.getByName(name);
System.out.println("角色名:" + xcw.getName());
System.out.println("角色类型:" + xcw.getType());
System.out.println("角色公会:" + xcw.getGuild().getName());
System.out.println("角色武器:");
for (Weapon weapon : xcw.getWeapons()) {
System.out.println(" - " + weapon.getName());
}
}
private void getWeaponInfo(String name) {
Weapon weapon = weaponDAO.getByName(name);
System.out.println("武器名:" + weapon.getName());
System.out.println("使用该武器角色:");
for (Character character : weapon.getCharacters()) {
System.out.println(" - " + character.getName() + " " + character.getType() + " " + character.getGuild().getName());
}
}
@Test
void contextLoads() {
getCharacterInfo("真步");
getWeaponInfo("水之支配者神凑剑");
}
结果:

其实collection
中还可以嵌套collection
或者association
,大家可以自行尝试。当然实际开发中不能将数据结构设计得过于复杂,影响数据库执行效率。
4,总结
数据库是抽象的,后端的数据建模也是抽象的,明确数据模型之间的关系,并实现它们的互相关联非常重要。这里尤其是MyBatis的级联还是有一定难度,需要大致理解其原理、各个参数对应的意义,以及关联查询等等。
示例仓库地址