高阶结果映射
在关系型数据库中,我们经常要处理一对一、一对多的关系。例如,一辆汽车需要有一个引擎,这是一对一的关系。一辆汽车有 4 个或更多个轮子,这是一对多的关系。
在 RBAC 权限系统中还存在着一个用户拥有多个角色、一个角色拥有多个权限这样复杂的嵌套关系。使用已经学会的 MyBatis 技巧都可以轻松地解决这种复杂的关系。在面对这种关系的时候,我们可能要写多个方法分别查询这些数据,然后再组合到一起。这种处理方式特别适合用在大型系统上,由于分库分表,这种用法可以减少表之间的关联查询,方便系统进行扩展。但是在一般的企业级应用中,使用 MyBatis 的高级结果映射便可以轻松地处理这种一对一、一对多的关系。本节将带领大家学习与高级结果映射相关的内容。
一对一映射
假设在 RBAC 权限系统中,一个用户只能拥有一个角色,为了举例,先把用户和角色之间的关系限制为一对一的关系。在 2.3 节中介绍了一个 selectRolesByUserId 方法,这个方法实际上就已经是一个一对一关系了。一对一映射因为不需要考虑是否存在重复数据,因此使用起来很简单,而且可以直接使用 MyBatis 的自动映射。此处参考 2.3 节中的方法,使用自动映射实现在查询用户信息的同时获取用户拥有的角色。
使用自动映射处理一对一关系
一个用户拥有一个角色,因此先在 SysUser 类中增加 SysRole 字段,代码如下。
/**
* 用户表
*/
public class SysUser {
/**
* 用户角色
*/
private SysRole role;
// 其它原有的 setter 和 getter 方法
public SysRole getRole() {
return this.role;
}
public void setRole(SysRole role) {
this.role = role;
}
}
使用自动映射就是通过别名让 MyBatis
自动将值匹配到对应的字段上,简单的别名映射如 user_name
对应 userName
。除此之外 MyBatis 还支持复杂的属性映射,可以多层嵌套,例如将 role.role_name 映射到 role.roleName 上。MyBatis 会先查找 role 属性,如果存在 role 属性就创建 role 对象,然后在 role 对象中继续查找 roleName,将 role_name 的值绑定到 role 对象的 roleName 属性上。
下面根据自动映射的规则,在 UserMapper.xml 中增加如下方法。
<select id="selectUserAndRoleById" resultType="cn.liaozh.mybatis2.ch6.query.model.SysUser">
SELECT
u.id,
u.user_name userName,
u.user_password,
userPassword,
u.user_email userEmail,
u.user_info userInfo,
u.head_img headImg,
u.create_time createTime,
r.id "role.id",
r.role_name "role.roleName",
r.enabled "role.enabled",
r.create_by "role.createBy",
r.create_time "role.createTime"
FROM sys_user u
INNER JOIN sys_user_role ur ON u.id = ur.user_id
INNER JOIN sys_role r ON ur.role_id = r.id
WHERE u.id = #{id}
</select>
注意上述方法中 sys_role 查询列的别名都是 “role.” 前缀,通过这种方式将 role 的属性都映射到了 SysUser 的 role 属性上。
在 UserMapper 的接口中添加对应的方法,代码如下。
/**
* 根据用户id获取用户信息和用户的角色信息
* @param id 用户ID
* @return 用户信息
*/
SysUser selectUserAndRoleById(Long id);
针对该方法编写测试代码如下。
@Test
public void testSelectUserAndRoleById(){
// 获取 sqlSession
SqlSession sqlSession = getSqlSession();
try {
// 获取 UserMapper 接口
UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
// 特别注意,在测试数据中,id=1L的用户有两个角色,不适合这个例子
// 用户ID只能有一个角色ID
SysUser user = userMapper.selectUserAndRoleById(1001L);
// user 不为空
Assert.assertNotNull(user);
// user.role也不为空
Assert.assertNotNull(user.getRole());
} finally {
// 不要忘记关闭 sqlSession
sqlSession.close();
}
}
执行该测试,在获取 user 后面的代码处设置断点,此时查询结果如图6-1所示。

通过上图可以很清楚地看到 user 对象中各个字段的状态,符合我们的预期。
该测试输出的日志如下。
通过 SQL 日志可以看到已经查询出的一条数据,MyBatis 将这条数据映射到了两个类中,像这种通过一次查询将结果映射到不同对象的方式,称之为关联的嵌套结果映射。
关联的嵌套结果映射需要关联多个表将所有需要的值一次性查询出来。这种方式的好处是减少数据库查询次数,减轻数据库的压力,缺点是要写很复杂的 SQL,并且当嵌套结果更复杂时,不容易一次写正确,由于要在应用服务器上将结果映射到不同的类上,因此也会增加应用服务器的压力。当一定会使用到嵌套结果,并且整个复杂的 SQL 执行速度很快时,建议使用关联的嵌套结果映射。
使用resultMap配置一对一映射
除了使用 MyBatis 的自动映射来处理一对一嵌套外,还可以在 XML 映射文件中配置结果映射。上一节中的复杂对象映射也可以使用相同效果的 resultMap 进行配置,使用 resultMap 实现和上一节中的例子相同的效果。
在 UserMapper.xml 中增加如下的 resultMap 配置。
<resultMap id="userRoleMap" type="tk.mybatis.simple.model.SysUser">
<id property="id" column="id" />
<result property="userName" column="user_name" />
<result property="userPassword" column="user_password" />
<result property="userEmail" column="user_email" />
<result property="userInfo" column="user_info" />
<result property="headImg" column="head_img" jdbcType="BLOB"/>
<result property="createTime" column="create_time" jdbcType="TIMESTAMP"/>
<!--role相关属性-->
<result property="role.id" column="role_id" />
<result property="role.roleName" column="role_name" />
<result property="role.enabled" column="enabled" />
<result property="role.createBy" column="create_by" />
<result property="role.createTime" column="role_create_time" jdbcType="TIMESTAMP"/>
</resultMap>
这种配置和上一节相似的地方在于,role 中的 property 配置部分使用 “role.” 前缀。在 column 部分,为了避免不同表中存在相同的列,所有可能重名的列都增加了 “role_” 前缀。使用这种方式配置的时候,还需要在查询时设置不同的别名。针对该方法在 UserMapper.xml 中增加一个 selectUserAndRoleById2 方法,代码如下。
<select id="selectUserAndRoleById2" resultMap="userRoleMap">
SELECT
u.id,
u.user_name,
u.user_password,
u.user_email,
u.user_info,
u.head_img,
u.create_time,
r.id role_id,
r.role_name,
r.enabled enabled,
r.create_by create_by,
r.create_time role_create_time
FROM sys_user u
INNER JOIN sys_user_role ur ON u.id = ur.user_id
INNER JOIN sys_role r ON ur.role_id = r.id
WHERE u.id = #{id}
</select>
注意这个方法使用 resultMap 配置映射,所以返回值不能用 resultType 来设置,而是需要使用 resultMap 属性将其配置为上面的 userRoleMap。注意 SQL 中只有 sys_role 部分列为了防止重名而增加了列命名,并且别名和 resultMap 中配置的 column 一致。在 UserMapper 接口中增加对应的方法,代码如下。
/**
* 根据用户id获取用户信息和用户的角色信息
* @param id 用户ID
* @return 用户信息
*/
SysUser selectUserAndRoleById2(Long id);
该接口的测试方法和 selectUserAndRoleById 一模一样,只需要把调用 selectUserAndRoleById 的方法改为 selectUserAndRoleById2 即可,测试代码以及日志输出都和 selectUserAndRoleById 方法一样,这里不在重复。
用过上一种写法后再看这一种写法就会发现,resultMap 非常烦琐,不仅没有方便使用反而增加了更多的工作量。MyBatis 是支持 resultMap 映射继承的,因此要先简化上面的 resultMap 配置。在这个映射文件中本就存在一个 userMap 的映射配置(虽然这个 userMap 是第 2 章中手写的,但是学过第 5 章后,使用 MyBatis 代码生成器生成的代码都包含基础的 resultMap 配置,这个配置不需要手写,所以很简单),因此 userRoleMap 只需要继承 userMap,然后添加 role 特有的配置即可,userRoleMap 修改后的代码如下。
<resultMap id="userRoleMap" extends="userMap" type="tk.mybatis.simple.model.SysUser">
<result property="role.id" column="role_id" />
<result property="role.roleName" column="role_name" />
<result property="role.enabled" column="enabled" />
<result property="role.createBy" column="create_by" />
<result property="role.createTime" column="role_create_time" jdbcType="TIMESTAMP" />
</resultMap>
使用继承不仅使配置更简单,而且当对主表 userMap 进行修改时也只需要修改一处。修改后的 resultMap 仍然不能算方便,但是至少没有那么麻烦。
使用resultMap的association标签配置一对一映射
在 resultMap 中,association 标签用于和一个复杂的类型进行关联,即用于一对一的关联配置。
在上面配置的基础上,再做修改,改成 association 标签的配置方式,代码如下。
<resultMap id="userRoleMap" extends="userMap" type="cn.liaozh.mybatis2.ch6.query.model.SysUser">
<association property="role" columnPrefix="role_"
javaType="tk.mybatis.simple.model.SysRole">
<id column="id" property="id"/>
<result column="roleName" property="role_name"/>
<result column="enabled" property="enabled"/>
<result column="createBy" property="create_by"/>
<result column="createTime" property="create_time" jdbcType="TIMESTAMP"/>
</association>
</resultMap>
association 标签包含以下属性。
-
property:对应实体类中的属性名,必填项。
-
javaType:属性对应的 Java 类型。
-
resultMap:可以直接使用现有的 resultMap,而不需要在这里配置。
-
columnPrefix:查询列的前缀,配置前缀后,在子标签配置 result 的 column 时可以省略前缀。
除了这些属性外,还有其他属性,此处不做介绍。
因为上面配置了属性 role,因此在 association 内部配置 result 的 property 属性时,直接按照 SysRole 对象中的属性名配置即可。另外我们还配置了 columnPrefix="role_",在写 SQL 的时候,和 sys_role 表相关的查询列的别名都要有 “role_” 前缀,在内部 result 配置 column 时,需要配置成去掉前缀的列名,MyBatis 在映射结果时会自动使用前缀和 column 值的组合去 SQL 查询的结果中取值。这种配置方式实际上是很方便的,但是目前此处的写法无法体现,后面改进的例子会让大家看到效果。
对于前面提到的修改后的 resultMap,因为配置了列的前缀,因此还需要修改 SQL,代码如下。
<select id="selectUserAndRoleById2" resultMap="userRoleMap">
SELECT
u.id,
u.user_name,
u.user_password,
u.user_email,
u.user_info,
u.head_img,
u.create_time,
r.id role_id,
r.role_name role_role_name,
r.enabled role_enabled,
r.create_by role_create_by,
r.create_time role_create_time
FROM sys_user u
INNER JOIN sys_user_role ur ON u.id = ur.user_id
INNER JOIN sys_role r ON ur.role_id = r.id
WHERE u.id = #{id}
</select>
注意和 sys_role 相关列的别名,都已经改成了 “role_” 前缀,特别注意 role_name 增加前缀后为 role_role_name。修改完成后,可以执行 selectUserAndRoleById2 方法的测试,该测试的结果仍然会和之前的结果一样。
使用 association 配置时还可以使用 resultMap 属性配置成一个已经存在的 resultMap 映射,一般情况下,如果使用 MyBatis 代码生成器,都会生成每个表对应实体的 resultMap 配置,也可以手写一个 resultMap,先把 sys_role 相关的映射提取出来,代码如下。
<resultMap id="roleMap" type="tk.mybatis.simple.model.SysRole">
<id column="id" property="id"/>
<result column="roleName" property="role_name"/>
<result column="enabled" property="enabled"/>
<result column="createBy" property="create_by"/>
<result column="createTime" property="create_time" jdbcType="TIMESTAMP"/>
</resultMap>
直接使用 roleMap 的时候,userRoleMap 配置如下。
<resultMap id="userRoleMap" extends="userMap" type="tk.mybatis.simple.model.SysUser">
<association property="role" columnPrefix="role_" resultMap="roleMap" />
</resultMap>
到这一步以后,是不是就没那么麻烦了。需要注意,目前的 roleMap 是写在 UserMapper.xml 中的,虽然目前只在这里用到了 roleMap,但其实更合理的使用位置是在 RoleMapper.xml 中。将 roleMap 移动到 RoleMapper.xml 中后,这里的 userRoleMap 就不能简单地指定为 roleMap 了,而是要修改为以下的样子。
<resultMap id="userRoleMap" extends="userMap" type="tk.mybatis.simple.model.SysUser">
<association property="role" columnPrefix="role_" resultMap="tk.mybatis.simple.mapper.RoleMapper.roleMap" />
</resultMap>
MyBatis 默认会给 roleMap 添加当前命名空间的前缀,代码如下。
tk.mybatis.simple.mapper.UserMapper.roleMap
在移动 roleMap 之前,这个完整的地址是正确的,移动后便找不到 resultMap 了,此时必须指定完整的名字才能找到。引用 resultMap 时一定要注意这一点。
写到这种程度已经很简单了,和最开始的方式相比少了主表的一部分别名,但从表仍然需要别名,另外还多了 resultMap 配置。
目前已经讲到的这 3 种情况都属于 “关联的嵌套结果映射”,即通过一次 SQL 查询根据表或指定的属性映射到不同的对象中。除了这种方式,还有一种 “关联的嵌套查询”,也就意味着还有额外的查询,下面来看第 4 种情况。
association标签的嵌套查询
除了前面 3 种通过复杂的 SQL 查询获取结果,还可以利用简单的 SQL 通过多次查询转换为我们需要的结果,这种方式与根据业务逻辑手动执行多次 SQL 的方式相像,最后会将结果组合成一个对象。
association 标签的嵌套查询常用的属性如下。
-
select:另一个映射查询的 id,MyBatis 会额外执行这个查询获取嵌套对象的结果。
-
column:列名(或别名),将主查询中列的结果作为嵌套查询的参数,配置方式如 column={prop1=col1,prop2=col2},prop1 和 prop2 将作为嵌套查询的参数。
-
fetchType:数据加载方式,可选值为 lazy 和 eager,分别为延迟加载和积极加载,这个配置会覆盖全局的 lazyLoadingEnabled 配置。
使用嵌套查询的方式配置一个和前面功能一样的方法,首先在 UserMapper.xml 中创建如下的 resultMap。
<resultMap id="userRoleMapSelect" extends="userMap" type="tk.mybatis.simple.model.SysUser">
<association property="role" column="{id=role_id}"
select="tk.mybatis.simple.mapper.RoleMapper.selectRoleById"/>
</resultMap>
然后创建一个使用 userRoleMapSelect 的查询方法,代码如下。
<select id="selectUserAndRoleByIdSelect" resultMap="userRoleMapSelect">
SELECT
u.id,
u.user_name,
u.user_password,
u.user_email,
u.user_info,
u.head_img,
u.create_time,
ur.role_id
FROM sys_user u
INNER JOIN sys_user_role ur ON u.id = ur.user_id
WHERE u.id = #{id}
</select>
注意表关联中已经没有 sys_role,因为我们不是通过一个 SQL 获取全部的信息,角色信息要通过配置的 selectRoleById 方法进行查询,这个方法写在 RoleMapper.xml 中,代码如下。
<select id="selectRoleById" resultMap="roleMap">
select * from sys_role where id = #{id}
</select>
注意,可用的参数是通过上面的 column="{id=role_id}" 进行配置的,因此在嵌套的 SQL 中只能使用 #{id} 参数,当需要多个参数时,可以配置多个,使用逗号隔开即可,例如 column="{id=role_id,name=role_name}"。
针对上面这个方法,在 UserMapperTest 中编写测试如下。
@Test
public void testSelectUserAndRoleByIdSelect(){
// 获取 sqlSession
SqlSession sqlSession = getSqlSession();
try {
// 获取 UserMapper 接口
UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
// 用户ID只能有一个角色ID
SysUser user = userMapper.selectUserAndRoleByIdSelect(1001L);
// user不为空
Assert.assertNotNull(user);
user.equals(null);
// user.role不为空
Assert.assertNotNull(user.getRole());
} finally {
// 不要忘记关闭 sqlSession
sqlSession.close();
}
}
测试代码和前面几个例子中的测试代码是一样的,只是调用的方法是新增的 selectUserAndRoleByIdSelect 方法,因为嵌套查询会多执行 SQL 查询,因此这个测试的输出日志是我们最关心的,该测试输出日志内容如下。
结果和我们想的一致,因为第一个 SQL 的查询结果只有一条,所以根据这一条数据的 role_id 关联了另一个查询,因此执行了两次 SQL。
这种配置方式符合开始时预期的结果,但是由于嵌套查询会多执行 SQL,所以还要考虑更多情况。在这个例子中,是否一定会用到 SysRole 呢?如果查询出来并没有使用,那不就白白浪费了一次查询吗?如果查询的不是 1 条数据,而是 N 条数据,那就会出现 N+1 问题,主 SQL 会查询一次,查询出 N 条结果,这 N 条结果要各自执行一次查询,那就需要进行 N 次查询。如何解决这个问题呢?
在上面介绍 association 标签的属性时,介绍了 fetchType 数据加载方式,这个方式可以帮我们实现延迟加载,解决 N+1 的问题。按照上面的介绍,需要把 fetchType 设置为 lazy,这样设置后,只有当调用 getRole() 方法获取 role 的时候,MyBatis 才会执行嵌套查询去获取数据。首先修改 userRoleMapSelect 方法,增加 fetchType 属性,代码如下。
<resultMap id="userRoleMapSelect" extends="userMap" type="tk.mybatis.simple.model.SysUser">
<association property="role" fetchType="lazy" column="{id=role_id}"
select="tk.mybatis.simple.mapper.RoleMapper.selectRoleById"/>
</resultMap>
然后修改测试代码,在 getRole() 之前增加一行输出,具体如下。
System.out.println("调用 user.getRole()");
Assert.assertNotNull(user.getRole());
在测试时,也可以在 getRole() 方法前设置断点查看日志,测试方法输出的日志如下。
结果出乎意料,获取角色的查询并没有在调用 getRole() 方法时才执行嵌套的 SQL,为什么会这样呢?
在 MyBatis 的全局配置中,有一个参数为 aggressiveLazyLoading。这个参数的含义是,当该参数设置为 true 时,对任意延迟属性的调用会使带有延迟加载属性的对象完整加载,反之,每种属性都将按需加载。
上面的方法之所以没有按照预想去执行,就是因为这个参数默认为 true,所以当查询 sys_user 过后并给 SysUser 对象赋值时,会调用该对象其他属性的 setter 方法,这也会触发上述规则,导致本该延迟加载的属性直接加载。为了避免这种情况,需要在 mybatis-config.xml 中添加如下配置。
<settings>
<!--其它配置-->
<setting name="aggressiveLazyLoading" value="false" />
</settings>
增加这个配置,再次执行测试,这次输出的日志如下。
从日志中可以看出,执行的结果和预期的结果一样,在调用 getRole() 方法后才执行嵌套 SQL 查询结果。
许多对延迟加载原理不太熟悉的朋友会经常遇到一些莫名其妙的问题:有些时候延迟加载可以得到数据,有些时候延迟加载就会报错,为什么会出现这种情况呢? MyBatis 延迟加载是通过动态代理实现的,当调用配置为延迟加载的属性方法时,动态代理的操作会被触发,这些额外的操作就是通过 MyBatis 的 SqlSession 去执行嵌套 SQL 的。由于在和某些框架集成时,SqlSession 的生命周期交给了框架来管理,因此当对象超出 SqlSession 生命周期调用时,会由于链接关闭等问题而抛出异常。在和 Spring 集成时,要确保只能在 Service 层调用延迟加载的属性。当结果从 Service 层返回至 Controller 层时,如果获取延迟加载的属性值,会因为 SqlSession 已经关闭而抛出异常。 |
虽然这个方法已经满足了我们的要求,但是有些时候还是需要在触发某方法时将所有的数据都加载进来,而我们已经将 aggressiveLazyLoading 设置为 false,这种情况又该怎么解决呢?
MyBatis 仍然提供了参数 lazyLoadTriggerMethods 帮助解决这个问题,这个参数的含义是,当调用配置中的方法时,加载全部的延迟加载数据。默认值为 "equals,clone,hashCode,toString"。因此在使用默认值的情况下,只要调用其中一个方法就可以实现加载调用对象的全部数据。修改测试,修改部分代码如下。
System.out.println("调用 user.equals(null)");
user.equals(null);
System.out.println("调用 user.getRole()");
Assert.assertNotNull(user.getRole());
在调用 getRole() 方法前,先调用 equals 方法,修改后测试输出日志如下。
从日志中可以看到,调用 equals 方法后就触发了延迟加载属性的查询,这种方式可以满足需要。
这一节中,我们通过讲解 4 种方式的一对一查询,循序渐进地为大家介绍了高级映射中的关键内容。除了基本的属性,还讲解了 resultMap 的继承、关联的嵌套查询、关联的嵌套结果查询。在关联的嵌套查询中,我们又介绍了延迟加载的详细用法。这一节的内容(尤其是 6.1.1.4 节)非常重要,在下一节关于一对多映射的介绍中,嵌套查询和延迟加载与本节介绍的内容完全一样,唯一不同的只是映射结果的数量,继续来看一对多映射。
一对多映射
在上一节中,我们使用了 4 种方式实现一对一映射。这一节中,一对多映射只有两种配置方式,都是使用 collection 标签进行的,下面来看具体的介绍。
collection集合的嵌套结果映射
和 association 类似,集合的嵌套结果映射就是指通过一次 SQL 查询将所有的结果查询出来,然后通过配置的结果映射,将数据映射到不同的对象中去。在一对多的关系中,主表的一条数据会对应关联表中的多条数据,因此一般查询时会查询出多个结果,按照一对多的数据结构存储数据的时候,最终的结果数会小于等于查询的总记录数。
在 RBAC 权限系统中,一个用户拥有多个角色(注意,使用 association 是设定的特例,限制一个用户只有一个角色),每个角色又是多个权限的集合,所以要渐进式地去实现一个 SQL,查询出所有用户和用户拥有的角色,以及角色所包含的所有权限信息的两层嵌套结果。
先来看如何实现一层嵌套的结果,为了能够存储一对多的数据,先对 SysUser 类进行修改,代码如下。
public class SysUser {
/**
* 用户角色集合
*/
private List<SysRole> roleList;
// 原有 setter 和 getter 方法
/**
* 获取 用户角色集合
*
* @return roleList 用户角色集合
*/
public List<SysRole> getRoleList() {
return this.roleList;
}
/**
* 设置 用户角色集合
*
* @param roleList 用户角色集合
*/
public void setRoleList(List<SysRole> roleList) {
this.roleList = roleList;
}
}
在 SysUser 类中增加 roleList 属性用于存储用户对应的多个角色。
在 UserMapper.xml 中创建 resultMap,代码如下。
<resultMap id="userRoleListMap" extends="userMap" type="tk.mybatis.simple.model.SysUser">
<id property="id" column="id" />
<result property="userName" column="user_name" />
<result property="userPassword" column="user_password" />
<result property="userEmail" column="user_email" />
<result property="userInfo" column="user_info" />
<result property="headImg" column="head_img" jdbcType="BLOB"/>
<result property="createTime" column="create_time" jdbcType="TIMESTAMP"/>
<collection property="roleList" columnPrefix="role_"
javaType="tk.mybatis.simple.model.SysRole">
<id column="id" property="id"/>
<result column="roleName" property="role_name"/>
<result column="enabled" property="enabled"/>
<result column="createBy" property="create_by"/>
<result column="createTime" property="create_time" jdbcType="TIMESTAMP"/>
</collection>
</resultMap>
和 6.1.1.3 中的方式对比会很容易发现,此处就是把 association 改成了 collection,然后将 property 设置为了 roleList,其他的 id 和 result 的配置都还一样。仔细想想应该不难理解,collection 用于配置一对多关系,对应的属性必须是对象中的集合类型,因此这里是 roleList。另外,resultMap 只是为了配置数据库字段和实体属性的映射关系,因此其他都一样。同时能存储一对多的数据结构肯定也能存储一对一关系,所以一对一像是一对多的一种特例。collection 支持的属性以及属性的作用和 association 完全相同,这里不做详细介绍。
上一节中,我们逐步对 resultMap 进行了简化,在这一节,因为有了上一节的基础,因此可以大刀阔斧地对这个 resultMap 进行快速简化。首先,SysUser 中的属性可以直接通过继承 userMap 来使用 sys_user 的映射关系,其次在 RoleMapper.xml 中的 roleMap 映射包含了 sys_role 的映射关系,因此可以直接引用 roleMap,经过这两个方式的简化,最终的 userRoleListMap 如下。
<resultMap id="userRoleListMap" extends="userMap" type="tk.mybatis.simple.model.SysUser">
<collection property="roleList" columnPrefix="role_"
resultMap="tk.mybatis.simple.mapper.RoleMapper.roleMap">
</collection>
</resultMap>
经过简化后的配置和 6.1.1.3 中最终简化的结果也极其相似,变化的地方也是 association 变成了 collection,property 从 role 变成了 roleList。
仿照上一节的 selectUserAndRoleById2 方法,创建 selectAllUserAndRoles 方法,代码如下。
<select id="selectAllUserAndRoles" resultMap="userRoleListMap">
SELECT
u.id,
u.user_name,
u.user_password,
u.user_email,
u.user_info,
u.head_img,
u.create_time,
r.id role_id,
r.role_name role_role_name,
r.enabled role_enabled,
r.create_by role_create_by,
r.create_time role_create_time,
FROM sys_user u
INNER JOIN sys_user_role ur ON u.id = ur.user_id
INNER JOIN sys_role r ON ur.role_id = r.id
</select>
这个方法用于查询所有用户及其对应的角色,sys_role 对应的查询列都增加了以 “role_” 作为前缀的别名。
在 UserMapper 接口中增加如下的对应方法。
/**
* 获取所有的用户以及对应的所有角色
* @return 用户级角色信息列表
*/
List<SysUser> selectAllUserAndRoles();
针对该方法,在 UserMapperTest 中添加如下测试。
@Test
public void testSelectAllUserAndRoles(){
// 获取 sqlSession
SqlSession sqlSession = getSqlSession();
try {
// 获取 UserMapper 接口
UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
List<SysUser> userList = userMapper.selectAllUserAndRoles();
Assert.assertNotNull(userList);
for (SysUser user : userList) {
System.out.println("用户名:" + user.getUserName());
for (SysRole role : user.getRoleList()) {
System.out.println("角色名:"+role.getRoleName());
for (SysPrivilege privilege : role.getPrivilegeList()) {
System.out.println("权限名:"+privilege.getPrivilegeName());
}
}
}
} finally {
// 不要忘记关闭 sqlSession
sqlSession.close();
}
}
在执行该测试时,可以使用调试模式,在获取 userList 的下一行设置断点,运行到此处时,可以看到对象的属性如图6-2所示。

图中第一个 SysUser 的值展开后如图6-3所示。

从上图已经可以看到,第一个用户拥有两个角色,实现了一对多的查询。再来看一下测试代码输出的日志。
通过日志可以清楚地看到,SQL执行的结果数有3条,后面输出的用户数是2,也就是说本来查询出的3条结果经过MyBatis对collection数据的处理后,变成了两条。我们都知道,因为第一个用户拥有两个角色,所以转换为一对多的数据结构后就变成了两条结果。那么,MyBatis又是怎么知道要处理成这样的结果呢?理解MyBatis处理的规则对使用一对多配置是非常重要的,如果只是一知半解,很容易就会遇到各种莫名其妙的问题,所以针对MyBatis处理中的要点,下面进行一个详细的阐述。先来看MyBatis是如何知道要合并admin的两条数据的,为什么不把test这条数据也合并进去呢?MyBatis在处理结果的时候,会判断结果是否相同,如果是相同的结果,则只会保留第一个结果,所以这个问题的关键点就是MyBatis如何判断结果是否相同。MyBatis判断结果是否相同时,最简单的情况就是在映射配置中至少有一个id标签,在userMap中配置如下。
<id property="id" column="id" />
我们对id(构造方法中为idArg)的理解一般是,它配置的字段为表的主键(联合主键时可以配置多个id标签),因为MyBatis的resultMap只用于配置结果如何映射,并不知道这个表具体如何。id的唯一作用就是在嵌套的映射配置时判断数据是否相同,当配置id标签时,MyBatis只需要逐条比较所有数据中id标签配置的字段值是否相同即可。在配置嵌套结果查询时,配置id标签可以提高处理效率。这样一来,上面的查询就不难理解了。因为前两条数据的userMap部分的id相同,所以它们属于同一个用户,因此这条数据会合并到同一个用户中。为了让大家更清楚地理解id的作用,可以临时对userMap的映射进行如下修改。
<resultMap id="userMap" type="tk.mybatis.simple.model.SysUser">
<id property="userPassword" column="user_password" />
<result property="id" column="id" />
<result property="userName" column="user_name" />
<result property="userEmail" column="user_email" />
<result property="userInfo" column="user_info" />
<result property="headImg" column="head_img" jdbcType="BLOB"/>
<result property="createTime" column="create_time" jdbcType="TIMESTAMP"/>
</resultMap>
在测试数据中,用户的密码都是 123456,因此如果用密码作为 id,按照上面的说法,这3条数据就会合并为 1 条数据。对 userMap 做好临时修改后,再次执行测试,输出的部分日志如下。
用户数:1
用户名:admin
角色名:管理员
角色名:普通用户
是不是变成了一个用户?用户信息保留的是第一条数据的信息,因此用户名是 admin。角色为什么不是 3 条呢?因为 “普通用户” 这个角色重复了,所以也只保留了第一个出现的 “普通用户”,具体的合并规则后面会详细说明。
大家通过这个简单的例子应该明白 id 的作用了。需要注意,很可能会出现一种没有配置 id 的情况。没有配置 id 时,MyBatis 就会把 resultMap 中配置的所有字段进行比较,如果所有字段的值都相同就合并,只要有一个字段值不同,就不合并。
在嵌套结果配置 id 属性时,如果查询语句中没有查询 id 属性配置的列,就会导致 id 对应的值为 null。这种情况下,所有值的 id 都相同,因此会使嵌套的集合中只有一条数据。所以在配置 id 列时,查询语句中必须包含该列。 |
可以对 userMap 再次修改,将 id 标签改为 result,然后执行测试查看结果。这时的结果和使用 id 标签配置 id 属性时的结果相同,因为 admin 用户在 userMap 这部分配置的属性都相同,因此也会合并。虽然结果相同,但是由于 MyBatis 要对所有字段进行比较,因此当字段数为 M 时,如果查询结果有 N 条,就需要进行 M×N 次比较,相比配置 id 时的 N 次比较,效率相差更多,所以要尽可能配置 id 标签。
前面将 id 标签配置为 userPassword 时,最后的结果少了一个角色,这是因为 MyBatis 会对嵌套查询的每一级对象都进行属性比较。MyBatis 会首先比较顶层的对象,如果 SysUser 部分相同,就继续比较 SysRole 部分,如果 SysRole 不同,就会增加一个 SysRole,两个 SysRole 相同就保留前一个。假设 SysRole 还有下一级,仍然按照该规则去比较。
在 RBAC 权限系统中,除了一个用户对应多个角色外,每一个角色还会对应多个权限。所以在现有例子的基础上可以再增加一级,获取角色对应的所有权限。
如果在 PrivilegeMapper.xml 中没有 privilegeMap 映射配置,就在该配置文件中添加如下代码。
<resultMap id="privilegeMap" type="tk.mybatis.simple.model.SysPrivilege">
<id column="id" property="id"/>
<result column="privilege_name" property="privilegeName"/>
<result column="privilege_url" property="privilegeUrl"/>
</resultMap>
然后在 SysRole 类中添加如下属性和方法。
/**
* 角色包含的权限列表
*/
private List<SysPrivilege> privilegeList;
/**
* 获取 角色包含的权限列表
*
* @return privilegeList 角色包含的权限列表
*/
public List<SysPrivilege> getPrivilegeList() {
return this.privilegeList;
}
/**
* 设置 角色包含的权限列表
*
* @param privilegeList 角色包含的权限列表
*/
public void setPrivilegeList(List<SysPrivilege> privilegeList) {
this.privilegeList = privilegeList;
}
在 RoleMapper.xml 文件中,增加如下 resultMap 配置。
<resultMap id="rolePrivilegeListMap" extends="roleMap" type="tk.mybatis.simple.model.SysRole">
<collection property="privilegeList" columnPrefix="privilege_" resultMap="tk.mybatis.simple.mapper.PrivilegeMapper.privilegeMap"/>
</resultMap>
我们创建了角色权限映射,继承了 roleMap,嵌套了 privilegeList 属性,直接使用了 PrivilegeMapper.xml 中的 privilegeMap。
最后还要修改 UserMapper.xml 中的 userRoleListMap,代码如下。
<resultMap id="userRoleListMap" extends="userMap" type="tk.mybatis.simple.model.SysUser">
<collection property="roleList" columnPrefix="role_"
resultMap="tk.mybatis.simple.mapper.RoleMapper.rolePrivilegeListMap">
</collection>
</resultMap>
完成以上步骤就配置好了一个两层嵌套的映射。为了得到权限信息,还需要修改SQL进行关联,代码如下。
<select id="selectAllUserAndRoles" resultMap="userRoleListMap">
SELECT
u.id,
u.user_name,
u.user_password,
u.user_email,
u.user_info,
u.head_img,
u.create_time,
r.id role_id,
r.role_name role_role_name,
r.enabled role_enabled,
r.create_by role_create_by,
r.create_time role_create_time,
p.id role_privilege_id,
p.privilege_name role_privilege_privilege_name,
p.privilege_url role_privilege_privilege_url
FROM sys_user u
INNER JOIN sys_user_role ur ON u.id = ur.user_id
INNER JOIN sys_role r ON ur.role_id = r.id
INNER JOIN sys_role_privilege rp ON rp.role_id = r.id
INNER JOIN sys_privilege p ON p.id = rp.privilege_id
</select>
这里要特别注意 sys_privilege 表中列的别名,因为 sys_privilege 嵌套在 rolePrivilegeListMap 中,而 rolePrivilegeListMap 的前缀是 “role_”,所以 rolePrivilegeListMap 中 privilegeMap 的前缀就变成了 “role_privilege_”。在嵌套中,这个前缀需要叠加,一定不要写错。配置好 SQL 后,修改测试方法中的循环部分,代码如下。
for (SysUser user : userList) {
System.out.println("用户名:" + user.getUserName());
for (SysRole role : user.getRoleList()) {
System.out.println("角色名:"+role.getRoleName());
for (SysPrivilege privilege : role.getPrivilegeList()) {
System.out.println("权限名:"+privilege.getPrivilegeName());
}
}
}
上述代码只是增加了权限名的输出,测试执行后,输出的部分日志如下。
用户数:2
用户名:admin
角色名:管理员
权限名:用户管理
权限名:系统日志
权限名:角色管理
角色名:普通用户
权限名:人员维护
权限名:单位维护
用户名:test
角色名:普通用户
权限名:人员维护
权限名:单位维护
为了加深印象,利用上面的 rolePrivilegeListMap 实现一个查询角色和对应权限的方法。在 RoleMapper.xml 中添加如下方法。
<select id="selectAllRoleAndPrivileges" resultMap="rolePrivilegeListMap">
select
r.id,
r.role_name,
r.enabled,
r.create_by,
r.create_time,
p.id privilege_id,
p.privilege_name privilege_privilege_name,
p.privilege_url privilege_privilege_url
from sys_role r
inner join sys_role_privilege rp on rp.role_id = r.id
inner join sys_privilege p on p.id = rp.privilege_id
</select>
在这个方法中,大家需要注意 sys_privilege 对应列的别名,请自行在 RoleMapper 中添加对应的接口,并且在 RoleMapperTest 中添加该方法的测试。
此处通过使用 rolePrivilegeListMap,大家可以了解这样一个映射配置:它不仅可以被嵌套的配置引用,其本身也可以使用。一个复杂的映射就是由这样一个基本的映射配置组成的。通常情况下,如果要配置一个相当复杂的映射,一定要从基础映射开始配置,每增加一些配置就进行对应的测试,在循序渐进的过程中更容易发现和解决问题。
虽然 association 和 collection 标签是分开介绍的,但是这两者可以组合使用或者互相嵌套使用,也可以使用符合自己需要的任何数据结构,不需要局限于数据库表之间的关联关系。
例如,对于前面提到的 selectAllRoleAndPrivileges 方法,其中包含 create_by 和 create_time 两个字段,假设要创建一个 CreateInfo 类,代码如下。
package tk.mybatis.simple.model;
import java.io.Serializable;
import java.util.Date;
/**
* 创建信息
*/
public class CreateInfo implements Serializable {
private static final long serialVersionUID = 8275281589408844992L;
/**
* 创建人
*/
private String createBy;
/**
* 创建时间
*/
private Date createTime;
public String getCreateBy() {
return createBy;
}
public void setCreateBy(String createBy) {
this.createBy = createBy;
}
public Date getCreateTime() {
return createTime;
}
public void setCreateTime(Date createTime) {
this.createTime = createTime;
}
}
然后使用 CreateInfo 替换 SysRole 中的这两个字段来存储值,在 SysRole 中增加如下属性和方法。
/**
* 创建信息
*/
private CreateInfo createInfo;
public CreateInfo getCreateInfo() {
return createInfo;
}
public void setCreateInfo(CreateInfo createInfo) {
this.createInfo = createInfo;
}
再修改 RoleMapper.xml 中的 roleMap 配置,代码如下。
<resultMap id="roleMap" type="tk.mybatis.simple.model.SysRole">
<id column="id" property="id"/>
<result column="role_name" property="roleName"/>
<result column="enabled" property="enabled"/>
<association property="createInfo" javaType="tk.mybatis.simple.model.CreateInfo">
<result column="create_by" property="createBy"/>
<result column="create_time" property="createTime" jdbcType="TIMESTAMP"/>
</association>
</resultMap>
此时,rolePrivilegeListMap 包含了 association 和 collection 两种类型的配置。在调试模式下查看该查询结果,如图6-4所示。

通过上图可以清楚地看到 createInfo 和 privilegeList 中存储的数据。
举这么多的例子一方面是希望大家可以多做一些练习,另一方面是希望可以通过这种变化开拓大家的思路,对嵌套结果映射用法的理解不只是停留在表面。下面继续来学习 collection 关联的嵌套查询方式。
collection集合的嵌套查询
我们知道 association 关联的嵌套查询这种方式会执行额外的 SQL 查询,映射配置会简单很多。关于 collection 的映射配置,我们已经学习了很多,结合上一节 association 的内容,再学习 collection 集合的嵌套查询就会更加容易。
仍然以关联的嵌套结果中的 selectAllUserAndRoles 为基础,以上一节最后的两层嵌套结果为目标,将该方法修改为集合的嵌套查询方式。
下面以自下而上的过程来实现这样一个两层嵌套的功能,并且这个自下而上的过程中的每一个方法都是一个独立可用的方法,最后的结果都是以前一个方法为基础的。把所有对象设置为延迟加载,因此每个方法都可以单独作为一个普通(没有嵌套)的查询存在。
首先在 PrivilegeMapper.xml 中添加如下方法。
<select id="selectPrivilegeByRoleId" resultMap="privilegeMap">
select p.*
from sys_privilege p
inner join sys_role_privilege rp on rp.privilege_id = p.id
where role_id = #{roleId}
</select>
这个方法通过角色 id 获取该角色对应的所有权限信息,可以在 PrivilegeMapper 接口中增加相应的方法。这是一个很常见的方法,许多时候都需要这样一个方法来获取角色包含的所有权限信息。大家要尽可能地针对每一步的方法进行测试,通过查看执行的日志以及返回的结果来深入学习和了解 MyBatis 的用法。
下一步,在 RoleMapper.xml 中配置映射和对应的查询方法,代码如下。
<resultMap id="rolePrivilegeListMapSelect" extends="baseResultMap" type="cn.liaozh.mybatis2.ch6.query.model.SysRole">
<collection property="privilegeList" fetchType="lazy" column="{roleId=id}" select="cn.liaozh.mybatis2.ch6.query.mapper.PrivilegeMapper.selectPrivilegeByRoleId"/>
</resultMap>
<select id="selectRoleByUserId" resultMap="rolePrivilegeListMapSelect">
select
r.id,
r.role_name,
r.enabled,
r.create_by,
r.create_time
from sys_role r
inner join sys_user_role ur on ur.role_id = r.id
where ur.user_id = #{userId}
</select>
在上面代码中要注意 column 属性配置的 {roleId=id},roleId 是 select 指定方法 selectPrivilegeByRoleId 查询中的参数,id 是当前查询 selectRoleByUserId 中查询出的角色 id。selectRoleByUserId 是一个只有一层嵌套的一对多映射配置,通过调用 PrivilegeMapper 的 selectPrivilegeByRoleId 方法,很轻易就实现了嵌套查询的功能。针对这个方法,大家也要添加相应的接口方法进行测试。
终于要轮到顶层的用户信息了,在 UserMapper.xml 中添加如下映射和查询,代码如下。
<resultMap id="userRoleListMapSelect" extends="baseResultMap" type="cn.liaozh.mybatis2.ch6.query.model.SysUser">
<collection property="roleList" fetchType="lazy"
select="cn.liaozh.mybatis2.ch6.query.mapper.RoleMapper.selectRoleByUserId" column="{userId=id}"/>
</resultMap>
<select id="selectAllUserAndRolesSelect" resultMap="userRoleListMapSelect">
SELECT
u.id,
u.user_name,
u.user_password,
u.user_email,
u.user_info,
u.head_img,
u.create_time
FROM sys_user u
WHERE u.id = #{id}
</select>
这里也需要注意,collection 的属性 column 配置为 {userId=id},将当前查询用户中的 id 赋值给 userId,使用 userId 作为参数再进行 selectRoleByUserId 查询。因为所有嵌套查询都配置为延迟加载,因此不存在 N+1 的问题。在 UserMapper 接口中添加如下方法。
/**
* 通过嵌套查询获取指定用户的信息以及用户的角色和权限信息
* @param id 用户ID
* @return 用户信息
*/
SysUser selectAllUserAndRolesSelect(Long id);
然后在 UserMapperTest 中添加相应的测试,代码如下。
@Test
public void testSelectAllUserAndRolesSelect(){
// 获取 sqlSession
SqlSession sqlSession = getSqlSession();
try {
UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
SysUser user = userMapper.selectAllUserAndRolesSelect(1L);
Assert.assertNotNull(user);
System.out.println("用户名:" + user.getUserName());
for (SysRole role : user.getRoleList()) {
System.out.println("角色名:"+role.getRoleName());
for (SysPrivilege privilege : role.getPrivilegeList()) {
System.out.println("权限名:"+privilege.getPrivilegeName());
}
}
} finally {
sqlSession.close();
}
}
由于这里是多层嵌套,并且是延迟加载,因此这段测试会输出很长的日志,日志如下。
简单分析这段日志,当执行 selectAllUserAndRolesSelect 方法后,可以得到 admin 用户的信息,由于延迟加载,此时还不知道该用户有几个角色。当调用 user.getRoleList() 方法进行遍历时,MyBatis 执行了第一层的嵌套查询,查询出了该用户的两个角色。对这两个角色进行遍历获取角色对应的权限信息,因为已经有两个角色,所以分别对两个角色进行遍历时会查询两次角色的权限信息。特别需要注意的是,之所以可以根据需要查询数据,除了和 fetchType 有关,还和全局的 aggressiveLazyLoading 属性有关,这个属性在介绍 association 时被配置成了 false,所以才会起到按需加载的作用。
通过自下而上的方式学习这种映射结果的配置,大家应该对 collection 的用法有了一定的了解,熟练掌握 association 和 collection 的配置,在某些情况下会带给我们很大的便利。
鉴别器映射
有时一个单独的数据库查询会返回很多不同数据类型(希望有些关联)的结果集。discriminator 鉴别器标签就是用来处理这种情况的。鉴别器非常容易理解,因为它很像 Java 语言中的 switch 语句。
discriminator 标签常用的两个属性如下。
-
column:该属性用于设置要进行鉴别比较值的列。
-
javaType:该属性用于指定列的类型,保证使用相同的 Java 类型来比较值。
discriminator 标签可以有 1 个或多个 case 标签,case 标签包含以下三个属性。
-
value:该值为 discriminator 指定 column 用来匹配的值。
-
resultMap:当 column 的值和 value 的值匹配时,可以配置使用 resultMap 指定的映射,resultMap 优先级高于 resultType。
-
resultType:当 column 的值和 value 的值匹配时,用于配置使用 resultType 指定的映射。
case 标签下面可以包含的标签和 resultMap 一样,用法也一样。
现在以上一节 RoleMapper 中的 selectRoleByUserId 为基础,进行简单的改动,首先在 RoleMapper.xml 中增加一个使用鉴别器的映射,代码如下。
<resultMap id="rolePrivilegeListMapChoose" type="cn.liaozh.mybatis2.ch6.query.model.SysRole">
<discriminator javaType="int" column="enabled">
<case value="1" resultMap="rolePrivilegeListMapSelect"/>
<case value="0" resultMap="baseResultMap"/>
</discriminator>
</resultMap>
角色的属性 enable 值为 1 的时候表示状态可用,为 0 的时候表示状态不可用。当角色可用时,使用 rolePrivilegeListMapSelect 映射,这是一个一对多的嵌套查询映射,因此可以获取到该角色下详细的权限信息。当角色被禁用时,只能获取角色的基本信息,不能获得角色的权限信息。
继续在 RoleMapper.xml 中添加如下方法。
<select id="selectRoleByUserIdChoose" resultMap="rolePrivilegeListMapChoose">
select
r.id,
r.role_name,
r.enabled,
r.create_by,
r.create_time
from sys_role r
inner join sys_user_role ur on ur.role_id = r.id
where ur.user_id = #{userId}
</select>
这个方法是根据用户 id 查询用户所有角色信息的,resultMap 使用的是新增的映射配置。在 RoleMapper 接口中增加对应的接口方法,代码如下。
/**
* 根据用户ID获取用户的角色信息
* @param userId 用户ID
* @return 角色列表
*/
List<SysRole> selectRoleByUserIdChoose(Long userId);
在 RoleMapperTest 测试类中增加如下测试。
@Test
public void testSelectRoleByUserIdChoose(){
//获取 sqlSession
SqlSession sqlSession = getSqlSession();
try {
//获取 RoleMapper 接口
RoleMapper roleMapper = sqlSession.getMapper(RoleMapper.class);
//由于数据库数据 enable 都是 1,所以我们给其中一个角色的 enable 赋值为 0
SysRole role = roleMapper.selectById(2L);
role.setEnabled(Enabled.disabled);
roleMapper.updateById(role);
//获取用户 1 的角色
List<SysRole> roleList = roleMapper.selectRoleByUserIdChoose(1L);
for(SysRole r: roleList){
System.out.println("角色名:" + r.getRoleName());
if(r.getId().equals(1L)){
//第一个角色存在权限信息
Assert.assertNotNull(r.getPrivilegeList());
} else if(r.getId().equals(2L)){
//第二个角色的权限为 null
Assert.assertNull(r.getPrivilegeList());
continue;
}
for(SysPrivilege privilege : r.getPrivilegeList()){
System.out.println("权限名:" + privilege.getPrivilegeName());
}
}
} finally {
sqlSession.rollback();
//不要忘记关闭 sqlSession
sqlSession.close();
}
}
在测试中将 id=2 的角色的 enable 更新为 0,这样一来,结果中 id=1 的角色的权限信息可以查看,id=2 的角色由于被禁用,因此无法获取对应的权限信息。执行该测试,输出日志如下。
从测试代码和输出日志可以看出,第二个角色没有输出对应的权限信息,为了对比,可以将更新第二个角色为禁用状态的代码屏蔽后再次执行。
鉴别器有一个特殊的地方,把上面的 rolePrivilegeListMapChoose 映射配置修改如下。
<resultMap id="rolePrivilegeListMapChoose" type="tk.mybatis.simple.model.SysRole">
<discriminator column="enabled" javaType="int">
<case value="1" resultMap="rolePrivilegeListMapSelect"/>
<case value="0" resultMap="tk.mybatis.simple.model.SysRole">
<id property="id" column="id" />
<result property="roleName" column="role_name" />
</case>
</discriminator>
</resultMap>
在这个配置中,禁用状态的角色没有使用 resultMap 配置,而是使用了 resultType,并且 case 中配置了两个属性的映射。在这种情况下,MyBatis 只会对列举出来的配置进行映射,不会对没有配置的属性进行映射,不像使用 resultMap 配置时会自动映射其他的字段。
鉴别器是一种很少使用的方式,在使用前一定要完全掌握,没有把握的情况下要尽可能避免使用。