MyBatis|MyBatis 中 resultMap 的使用

MyBatis 中 resultMap 的使用

本文内容主要参考了掘金上一位大佬 @清幽之地 的文章,原文介绍了不同场景下 resultMap 的使用,全面且详细。写本文的目的是为了实际动手学习并做一个记录,原文见 MyBatis 中强大的 resultMap。
resultMap 简介 resultMap 的主要功能是将查询到的复杂数据映射到一个结果集中。MyBatis 官方所述:
resultMap 元素是 MyBatis 中最重要最强大的元素。它可以让你从 90% 的 JDBC ResultSets 数据提取代码中解放出来,并在一些情形下允许你进行一些 JDBC 不支持的操作。实际上,在为一些比如连接的复杂语句编写映射代码的时候,一份 resultMap 能够代替实现同等功能的数千行代码。ResultMap 的设计思想是,对简单的语句做到零配置,对于复杂一点的语句,只需要描述语句之间的关系就行了。
resultMap 实践 首先创建 SpringBoot 项目,配置 MyBatis,实现简单 CRUD,详细操作可参考我的另一篇博客SpringBoot 整合 MyBatis。
1. 字段映射
字段映射是 resultMap 最基础、最常用的功能。本实验中,我们创建实体类 User:
package com.example.entity; import lombok.Data; import java.util.Date; /** * @Author john * @Date 2021/11/14 */ @Data public class User {private long id; private String userName; private int age; private String address; private Date createTime; private Date updateTime; }

user 表定义如下:
MyBatis|MyBatis 中 resultMap 的使用
文章图片

user 表中的数据如下:
MyBatis|MyBatis 中 resultMap 的使用
文章图片

mapper 文件内容如下:
user_name, age, address, gmt_create, gmt_modifiedid, user_name, age, address, gmt_create, gmt_modified select from user where id = #{id} insert into user () values(#{userName}, #{age}, #{address}, UTC_TIMESTAMP(), UTC_TIMESTAMP())

上述代码中,我们使用 resultMap 定义了 'gmt_create' 与 'createTime' 以及 'gmt_modified' 与 'updateTime' 这两组变量之间的映射关系。
执行 findUserById() 方法可以查询到 User 的相关信息:
MyBatis|MyBatis 中 resultMap 的使用
文章图片

【MyBatis|MyBatis 中 resultMap 的使用】如果不配置 resultMap,而直接使用 resultType,那么结果集中将丢失 createTime 和 updateTime 这两个属性的值。修改 mapper 文件,将 resultMap 注释掉,并将 select 标签中的 resultMap="UserMap" 替换为 resultType="User":
select from user where id = #{id}

执行 findUserById() 方法查询用户信息:
MyBatis|MyBatis 中 resultMap 的使用
文章图片

可以看到,'createTime' 与 'updateTime' 并没有被赋值,因为 MyBatis 会根据查询到的列名找到 POJO 对应的属性名,然后调用该属性的 setter 方法进行赋值,而 User 对象中并没有 'gmt_create' 和 'gmt_modified' 这两个属性,因此不会赋值。但是 'user_name' 为什么就赋值给了 'userName'?因为我们开启了驼峰式命名映射,'user_name' 会自动映射到 'userName',详见博客SpringBoot 整合 MyBatis。。当然,如果将 User 类中的属性 'gmt_create' 和 'gmt_modified' 修改为 'gmtCreate' 和 'gmtModified',也是可以赋值成功的。
另外还有一种方法可以赋值成功,如果我们将 SQL 语句修改为:
select id, user_name, age, address, gmt_create as createTime, gmt_modified as updateTime from user where id = #{id}

上述代码中,我们为 'gmt_create' 和 'gmt_modified' 分别设置了别名 'createTime' 和 'updateTime',这样做也可以完成赋值。反过来,如果 SQL 语句为字段取了别名,如将上述 SQL 改为:'select id as uid, ...',则会导致 user 表中的 id 字段和 User 对象的 id 属性不再对应,这种情况也可以使用 resultMap 创建自定义的映射关系,将 'id' 和 'uid' 对应。
2. 构造方法
如果希望将查询到的数据注入到构造方法中,那么可以使用 constructor 元素。
比如我们为 User 类新增一个构造方法:
public User(String userName, Integer age) { this.userName = userName; this.age = age; }

mapper 文件中 resultMap 配置为:

其中,column 是数据表的字段名或别名;name 是构造方法中的参数名;javaType 是参数的类型。
配置完毕后调用 findUserById() 方法可成功查询出 User。
constructor 元素在什么情况下比较有用呢?首先,MyBatis 的赋值规则是先创建一个 User 对象,然后调用 setter 方法为属性赋值,而在 Java 中,有参构造方法会覆盖默认的无参构造方法。那么问题来了,如果我们不设置 constructor,那么 MyBatis 在数据库中查到了 6 项数据(id、user_name...),然后创建 User 对象,因为我们并没有显示地声明无参构造方法,所以 MyBatis 会调用有参构造方法,并根据有参构造的类型,从左往右进行匹配,即将查询到的 id 赋值给参数 userName,将 user_name 赋值给 age,因为类型不匹配,所以程序会报错。使用 constructor 后可以避免这个问题,当然也可以添加无参构造方法来解决。
待解决:上述有参构造方法中,如果参数 age 的类型设置为 int,程序会报错,设置为 Integer 后,正常运行,这是啥原因呢?麻烦了解的大佬帮忙解答。
3. 关联
通常一个 User 会担任一种角色,如管理员、版主等。在 User 类中可使用一个成员变量来表示:
@Data public class User { //省略用户属性...//角色信息 private Role role; }

Role 类定义为:
package com.example.entity; import lombok.Data; import java.util.Date; /** * @Author john * @Date 2021/11/19 */ @Data public class Role {private long id; private String roleName; private Date createTime; private Date updateTime; }

role 表定义为:
MyBatis|MyBatis 中 resultMap 的使用
文章图片

role 表中的数据为:
MyBatis|MyBatis 中 resultMap 的使用
文章图片

用户-角色关联表定义如下:
MyBatis|MyBatis 中 resultMap 的使用
文章图片

用户-角色关联表中的数据如下:
MyBatis|MyBatis 中 resultMap 的使用
文章图片

查询 User 时,如果希望查询出用户的角色信息,那么就不能使用 resultType="User",因为 User 类中只有一个 Role 对象,并没有 Role 对象里的属性(id 和 roleName)。这里我们使用 association 来关联它们。
将 mapper 文件修改为:
select u.id, u.user_name, u.age, u.address, u.gmt_create, u.gmt_modified, r.id as 'role_id', r.role_name from user as u left join user_roles as ur on ur.user_id = u.id left join role as r on ur.role_id = r.id where u.id = #{id}

因为需要多表查询,所以要仔细制定好每个字段的别名。还有一点需要说明,resultMap 中的 id 元素(id column="id" jdbcType="INTEGER" property="id")是用来指定主键的,但也可以使用 result 来代替。
执行 findUserById() 方法查询 User:
MyBatis|MyBatis 中 resultMap 的使用
文章图片

成功查询出了角色信息。
待解决:联合查询中,需要在 resultMap 中添加字段的映射关系,如 id、user_name 等(之前不添加也是可以赋值成功的)。即使字段名和属性名完全一致/符合驼峰命名规则,不添加则不会赋值,原理是什么?麻烦了解的大佬帮忙解答。
4. 集合
4.1 集合的嵌套结果映射 当一个 User 有多个角色时,如 'John' 的既担任 '管理员',又担任 '版主',这时我们需要将 User 类中的角色属性的类型改成 List。
@Data public class User { //省略用户属性...//角色信息 private List roles; }

此时一个用户对应多个角色,所以就不是简单的 association,因为 association 处理的是有一个类型的关联。当有多个类型的关联,需要用到 collection 属性。
将 mapper 文件修改为:

用户-角色关联表中的数据修改为:
MyBatis|MyBatis 中 resultMap 的使用
文章图片

执行 findUserById() 方法查询 User:
MyBatis|MyBatis 中 resultMap 的使用
文章图片

数据太长所以分两次截图,可以看到查询出了 'John' 的两个身份信息。
4.2 集合的嵌套 Select 查询 很多业务系统都会有一个菜单表,比如博客系统的 menu 表(部分)内容如下:
id name parent_id
1 文章 0
1001 所有文章 1
1002 写文章 1
1003 分类目录 1
2 用户 0
2001 个人资料 2
上述菜单分为两级,第一级有 '文章' 和 '用户','文章' 下有 '所有文章'、'写文章'、'分类目录' 这三个二级菜单;'用户' 菜单下有一个二级菜单 '个人资料'。前端页面中,当我们将鼠标移动到 '文章' 上时,会显示该菜单下的所有二级菜单:
MyBatis|MyBatis 中 resultMap 的使用
文章图片

下面我们演示如何查询出所有的菜单信息,并按层级展示。
定义 Menu 实体类:
package com.example.entity; import lombok.Data; import java.util.Date; import java.util.List; /** * @Author john * @Date 2021/11/20 */ @Data public class Menu {private long id; private String name; private long parentId; private List childMenus; private Date createTime; private Date updateTime; }

Menu 对象有一个属性 childMenus,该属性表示本菜单下的所有子菜单。
menu 表定义如下:
MyBatis|MyBatis 中 resultMap 的使用
文章图片

menu 表中的数据为:
MyBatis|MyBatis 中 resultMap 的使用
文章图片

MenuMapper 定义如下:
package com.example.mapper; import com.example.entity.Menu; import java.util.List; /** * @Author john * @Date 2021/11/20 */ public interface MenuMapper {List getMenus(); }

Mapper 接口中定义了一个方法,用来查询某个级别的菜单以及这些菜单的所有子菜单。
mapper 文件定义如下:
SELECT m.id, m.name, m.parent_id FROM menu m where 1=1 and m.parent_id = #{parent_id} and m.parent_id = '0'

SQL 的含义是当 parent_id 为空时,查询所有的一级菜单,否则根据 parent_id 查询。通过 collection,我们可以将所有的菜单信息查询出来,并按层级展示。getMenus() 方法执行结果如下:
[Menu(id=1, name=文章, parentId=0, childMenus=[Menu(id=1001, name=所有文章, parentId=1, childMenus=[], createTime=null, updateTime=null), Menu(id=1002, name=写文章, parentId=1, childMenus=[], createTime=null, updateTime=null), Menu(id=1003, name=分类目录, parentId=1, childMenus=[], createTime=null, updateTime=null)], createTime=null, updateTime=null), Menu(id=2, name=用户, parentId=0, childMenus=[Menu(id=2001, name=个人资料, parentId=2, childMenus=[], createTime=null, updateTime=null)], createTime=null, updateTime=null)]

collection 元素中:
  1. property="childMenus" 表示菜单中的子菜单列表;
  2. ofType="Menu" 表示返回数据的类型;
  3. select="getMenus" 指定 select 语句的 id;
  4. column="{parent_id=id}" 是参数的表达式。
这个 collection 整体的含义可以理解为:
通过 getMenus 这个 select 语句来获取一级菜单中的 childMenus 属性;在上面的 select 语句中,需要传递一个 parent_id 参数;这个参数的值就是一级菜单中的 id。
5. 自动填充关联对象
本节其实和 resultMap 的使用没有多少关系,大佬 @清幽之地 在这里给出了一种使用 resultType 代替 resultMap 的解决方案,也比较有意思,所以我们也实践一下。
MyBatis 解析返回值的具体过程:
  1. 获取返回值类型,拿到 Class 对象;
  2. 获取构造器,设置可访问,然后调用构造方法并返回实例对象;
  3. 将对象包装成 MetaObject 对象;
  4. 从数据库中查询出数据;
  5. 调用 MetaObject.setValue(String name, Object value) 来填充对象。
步骤 5 中,MyBatis 会以 '.' 来分隔这个 name 属性。如果 name 属性中包含 '.' 符号,那么就以 '.' 作为分隔符,符号前的名称作为一个实体对象来处理,符号后的名称作为该对象的某个属性。如 name 为 'role.id',那么 'role' 会作为一个 Role 对象,而 'id' 会作为这个 Role 对象的属性。
以第 3 节中一个用户对应一个角色为例,User 类定义如下:
@Data public class User { //省略用户属性...//角色信息 private Role role; }

mapper 文件定义如下:
select u.id, u.user_name, u.age, u.address, u.gmt_create as createTime, u.gmt_modified as updateTime, r.id as 'role.id', r.role_name as 'role.role_name' from user as u left join user_roles as ur on ur.user_id = u.id left join role as r on ur.role_id = r.id where u.id = #{id}

MyBatis 解析到 'role.id' 属性的时候,以 '.' 符号分隔之后发现,role 别名对应的是 Role 对象,则会先初始化 Role 对象(该 Role 对象就是 User 对象的属性),并将值赋予 id 属性。
执行 findUserById() 方法查询 User:
MyBatis|MyBatis 中 resultMap 的使用
文章图片

最后我们也分析一下 MetaObject.setValue(String name, Object value) 的源码:
public void setValue(String name, Object value) { // 创建PropertyTokenizer对象, 根据分隔符.分隔name, 分隔为parent和children PropertyTokenizer prop = new PropertyTokenizer(name); // 如果.分隔符存在, 即children存在 if (prop.hasNext()) { // 实例化parent对应的MetaObject, 如果parent为null, 则实例化为SystemMetaObject.NULL_META_OBJECT MetaObject metaValue = https://www.it610.com/article/this.metaObjectForProperty(prop.getIndexedName()); if (metaValue == SystemMetaObject.NULL_META_OBJECT) { // 如果属性值为null, 则直接返回 if (value == null) { return; } // 如果属性值不为null, 先实例化parent并重新构造MetaObject metaValue = this.objectWrapper.instantiatePropertyValue(name, prop, this.objectFactory); } // 父属性处理完毕, 处理子属性(递归处理) metaValue.setValue(prop.getChildren(), value); } else { // 不存在children, 直接赋值 this.objectWrapper.set(prop, value); } }

另外推荐一个讲解 PropertyTokenizer 的博客,见 MyBatis PropertyTokenizer。
欢迎批评指正!!!

    推荐阅读