Mybatis 结果集映射源码解析

2,276 阅读12分钟

一、必备知识

在分析源码之前,我们先来回顾下 MyBatis 中存在的几种查询情况和语法,如果不熟悉这些对源码理解上面有一定的困难。

1. 几种常见的映射方式

image.png

  • 简单结果集映射
  • 嵌套结果集映射:1-1、1-N(association、collection)

使用 嵌套查询 存在的 N+1 问题:

<resultMap id="blogResult" type="Blog">
  <association property="author" column="author_id" javaType="Author" select="selectAuthor"/>
</resultMap>

<select id="selectBlog" resultMap="blogResult">
  SELECT * FROM BLOG WHERE ID = #{id}
</select>

<select id="selectAuthor" resultType="Author">
  SELECT * FROM AUTHOR WHERE ID = #{id}
</select>
  • 你执行了一个单独的 SQL 语句来获取结果的一个列表(就是 “+1”)。
  • 对列表返回的每条记录,你执行一个 select 查询语句来为每条记录加载详细信息(就是 “N”)。

所以当你使用分页查询 Blog 时,假设每页是 50 条,那 selectBlog 只会被调用一次,但是 selectAuthor 却被调用了 50 次,产生大量的数据库操作。

2. discriminator 语法

<discriminator javaType="String" column="channel_code">
    <case value="CMB" resultMap="CmbDetail"/>
    <case value="ALI" resultMap="AliDetail"/>
</discriminator>

有时候,一个数据库查询可能会返回多个不同的结果集(但总体上还是有一定的联系的)。鉴别器(discriminator)元素就是被设计来应对这种情况的,另外也能处理其它情况,例如 类的继承层次结构。 鉴别器的概念很好理解——它很像 Java 语言中的 switch 语句。

一个鉴别器的定义需要指定 column 和 javaType 属性。column 指定了 MyBatis 查询被比较值的地方。 而 javaType 用来确保使用正确的相等测试(虽然很多情况下字符串的相等测试都可以工作)。例如:

<resultMap id="DetailResult" type="com.ariclee.mybatis.dto.vo.QueryDetailRespVO">
    <id column="mch_info_id" property="id" />
    <result column="mch_code" property="mchCode" />
    <result column="mch_name" property="mchName" />

    <discriminator javaType="String" column="channel_code">
        <case value="CMB" resultMap="CmbDetail"/>
        <case value="ALI" resultMap="AliDetail"/>
    </discriminator>
</resultMap>

在这个示例中,MyBatis 会从结果集中得到每条记录,然后比较它的 channel_code 值。 如果它匹配任意一个鉴别器的 case,就会使用这个 case 指定的结果映射。这个过程是互斥的。来看看 AliDetail 结果集声明:

<resultMap id="AliDetail" type="com.ariclee.mybatis.dto.vo.QueryAliDetailRespVO" extends="DetailResult">
    <result column="is_auth_mode" property="isAuthMode" />
    <result column="auth_code" property="authCode" />
    <result column="pub_key" property="pubKey" />
    <result column="pri_key" property="priKey" />
</resultMap>

为了能和父结果集产生关联,这里采用了 extends 属性,来 继承父结果集中的 ResultMapping,就不需要再次声明。

二、DefaultResultSetHandler

MyBatis 会将结果集按照映射配置文件中 定义的映射规则,例如 <resultMap> 节点、 resultType 属性等,映射成相应的结果对象。这种映射机制是 MyBatis 的 核心功能之一,可以避免重复的 JDBC 代码。在 StatementHandler 接口在执行完指定的 select 语句之后,会将查询得到的结果集交给 ResultSetHandler 接口完成映射处理。

public interface ResultSetHandler {
   // 处理结果集,生成相应的结果对象集合
  <E> List<E> handleResultSets(Statement stmt) throws SQLException;
   // 处理结果集,返回游标对象
  <E> Cursor<E> handleCursorResultSets(Statement stmt) throws SQLException;
   // 处理储存过程的输出参数 
  void handleOutputParameters(CallableStatement cs) throws SQLException;
}

DefaultResultSetHandler 是 MyBatis 提供的 ResultSetHandler 接口的唯一实现。所以关于结果集处理的所有东西,我们都可以在这个类中找到。

本次源码分享的重点在 handleResultSets 方法,另外两个方法因为不常用这里暂不开展分析。

image.png

上半部分代码是我们日常开发中多数走的逻辑,也就是一个查询 sql 只返回一个结果集情况。下面是逻辑分析:

  1. 迭代数据库返回的 ResultSet(JDBC 规范中的对象)
  2. 拿出从 xml 中解析出来的 ResultMap 配置
  3. 调用 handleResultSet 方法,并将反序列化好的对象保存到 multipleResults
  4. 清理处理嵌套好结果集映射时使用的 nestedResultObjects 集合

接着看 handleResultSet 方法

image.png

都调用了 handleRowValue 方法:

public void handleRowValues(ResultSetWrapper rsw, ResultMap resultMap, ResultHandler<?> resultHandler, RowBounds rowBounds, ResultMapping parentMapping) throws SQLException {
  if (resultMap.hasNestedResultMaps()) {
    ensureNoRowBounds();
    checkResultHandler();
    handleRowValuesForNestedResultMap(rsw, resultMap, resultHandler, rowBounds, parentMapping);
  } else {
    handleRowValuesForSimpleResultMap(rsw, resultMap, resultHandler, rowBounds, parentMapping);
  }
}

通过判断 resultMap.hasNestedResultMaps() 是否具有 嵌套映射,分为两个分支,通过名字可以得出:

  • handleRowValuesForSimpleResultMap:处理简单的结果集映射
  • handleRowValuesForNestedResultMap:处理嵌套的结果集映射

如何判断 ResultMap 是 Simple 还是 Nested?

org.apache.ibatis.builder.xml.XMLMapperBuilder#processNestedResultMappings

分析两个主分支之前,我们先来看看 如何判断当前 resultMap 是 简单的还是嵌套得结果集映射(即resultMap.hasNestedResultMaps() 判断成立的条件)。

该方法返回的是当前 ResultMap 实例的成员变量 hasNestedResultMaps 的值,所以要找到该变量被赋左值的地方

resultMap.hasNestedResultMaps = 
resultMap.hasNestedResultMaps || (resultMapping.getNestedResultMapId() != null 
&& resultMapping.getResultSet() == null);

因为我们现在撇开多结果集处理情况,件 resultMapping.getResultSet() == null 永远都是 true;所以我们要看 resultMapping.getNestedResultMapId() 的返回值;点进去发现该方法返回的当前类 ResultMaping 的成员变量 nestedResultMapId 的值,同样看该变量被赋左值的地方,一直往上看被调用的地方:

org.apache.ibatis.mapping.ResultMapping.Builder#nestedResultMapId
                ↓ 
org.apache.ibatis.builder.MapperBuilderAssistant#buildResultMapping
                ↓
org.apache.ibatis.builder.xml.XMLMapperBuilder#buildResultMappingFromContext

最终会定位到

private String processNestedResultMappings(XNode context, List<ResultMapping> resultMappings, Class<?> enclosingType) throws Exception {
  if ("association".equals(context.getName()) || "collection".equals(context.getName())
      || "case".equals(context.getName())) {
    if (context.getStringAttribute("select") == null) {
      validateCollection(context, enclosingType);
      ResultMap resultMap = resultMapElement(context, resultMappings, enclosingType);
      return resultMap.getId();
    }
  }
  return null;
}

看代码可知,是否为嵌套查询结果集,看 <resultMap> 声明时 是否包含 associationcollectioncase 关键字。下面是官网对 <resultMap> 声明的解释;

image.png

了解完什么是嵌套循环后,接着我们开始分析这两个方法

三、handleRowValuesForSimpleResultMap

下先来看简单的结果集处理

image.png

可以看到方法逻辑非常的直白,可以得到关键信息如下:

  1. 通过 while 循环 和对 shouldProcessMoreRows 方法的判断,按行对这个结果集进行处理(非常关键,一个结果集中大部分情况都会有很多行)
  2. 结果集中配置 <discriminator> 节点,resolveDiscriminatedResultMap 方法就是用来解决这个配置
  3. 拿到当前结果集的真正 ResultMap 后,也就是知道了当前解析的目标,接着调用 getRowwValue 进行解析,所以这个方法是重中之重
  4. 解析完成一行后,调用 storeObject,存入 resultHandler注意,跟 resultSetHandler 是不一样的东西)

我们来详细看看这些方法:

resolveDiscriminatedResultMap

image.png

如果这一列配置了 鉴别器,就是用 typeHandler 拿出这一列的值,并去寻找对应的 ResultMap 配置。注意看,这里判断时,使用了 while 条件,也就是说在配置 某个 case 的 RestMap 时,还可以进行嵌套。

这样子一来,就拿到了这个结果集实际需要解析成的目标类型了,接着开始进行实际的解析工作

  • getRowValue

image.png

题外话:方法注释之所以会写着 GET VALUE FROM ROW FOR SIMPLE RESULT MAP 是因为当前类还有一个同名的重载方法:处理嵌套的结果集行映射方法(后边会讲到)

  //
  // GET VALUE FROM ROW FOR NESTED RESULT MAP
  //

  private Object getRowValue(ResultSetWrapper rsw, ResultMap resultMap, 
  CacheKey combinedKey, String columnPrefix, Object partialObject) throws SQLException {
  ...
  }

这里再把 getRowValue 分为几个大的步骤

  1. 创建对象:使用反射创建出实例,createResultObject
  2. 自动映射:如果开启了自动映射,对实例进行自动赋值,applyAutomaticMappings
  3. 配置映射:对用户主动声明的属性进行映射,applyAutomaticMappings

下面对这三个步骤进行展开分析

createResultObject:Mybatis 是如何创建对象实例的

image.png

这里需要注意的是,new 实例时会存在多种情况,MyBatis 对这些场景都做了处理(从上往下存在顺序):

  1. 结果集只有一列(比如调用数据库的聚合函数:sum、avg 等),且存在 TypeHandler 对象可以将该列转换成 ResultType 类型的值
  2. 结果集映射配置中存在 <constructor> 节点。意思是用户主动 声明了需要用这个构造函数
  3. 存在默认的无参构造函数
  4. 当以上三种情况都不满足时,说明 Mybatis 需要自己去找合适的构造方法。通过自动映射方式查找合适的构造方法并创建结果
    • 查找否带有 @AutomapConstructor 注解的构造方法,如果有优先使用
    • 迭代构造方法集合,查找方法的参数数量和结果集中返回的构造方法,且需要都能被处理(能被 typeHandler 处理)

结合使用 Lombok 的 @Builder 注解时的问题

applyAutomaticMappings:Mybatis 没有指定映射列也能自动映射?

分析方法前先来看看什么情况会进行自动映射,也就是 shouldApplyAutomaticMappings(resultMap, false) 方法

image.png

所以在简单映射中,一般都看系统配置自动映射行为是啥。在 Configuration 类,自动映射行为属性的默认值是 AutoMappingBehavior.PARTIAL,不等于 NONE,所以默认开启自动映射的。

protected AutoMappingBehavior autoMappingBehavior = AutoMappingBehavior.PARTIAL;

进入正题,看 applyAutomaticMappings 方法

image.png

createAutomaticMappings 方法中我们可以看到当没有主动声明映射列是,MyBaits 会根据该属性的 Setter 方法进行类型推断,假如当前配置内注册过的 typeHandler 能处理,则并塞入 List<UnMappedColumnAutoMapping> 返回结果中。 外层方法拿到未映射列集合后,进行迭代赋值。

注意: 这里有个简单 autoMappingsCache 类实例级别缓存。当在一个结果集内,处理不同列时,无需每次都去计算并组装 List<UnMappedColumnAutoMapping>,缓存中有就直接返回。换句话说,一次查询中只有第一行是需要真正 createAutomaticMappings

applyPropertyMappings

自动映射完后,实例中已有部分属性具有实际的值。接着再进行用户主动声明的列映射。

image.png

因为要处理嵌套、延迟等复杂的操作,所以这里开了新专门的方法 getPropertyMappingValue 进行处理

image.png

这里不对 getNestedQueryMappingValue 方法进行展开,目前重点关注主流程。特定分支后面专门开章节讲。

至此为止,MyBatis 已经完成了一个实例的初始化和所有未声明、已声明的列的赋值工作。接着返回即可。返回前,MyBatis 还做了一个判断

rowValue = foundValues || configuration.isReturnInstanceForEmptyRow() ? rowValue : null;

foundValues 指的是,任意列中被成功映射:值不会为 NULL,且包括延迟、嵌套查询。一般来说这个值都为 true。

configuration.isReturnInstanceForEmptyRow() 是 Mybatis 的一个属性,默认为 false。意思是当 返回行的所有列都是空 时,默认返回 null。当 开启这个设置时,MyBatis 会返回一个空实例

mybatis.configuration.return-instance-for-empty-row=true

storeObject

默认情况下,简单映射后,该方法会调用 org.apache.ibatis.executor.result.DefaultResultHandler#handleResult,本质就是把结果实例添加进 DefaultResultHandler 实例中的 private final List<Object> list 属性中。

四、handleRowValuesForNestedResultMap

在实际应用中,除了使用简单的 select 语句查询单个表,还可能通过多表连接查询获取多张表的记录,这些记录在逻辑上 需要映射成多个 Java 对象,而这些对象之间可能是一对一或一对多等复杂的关联关系,这就需要使用 MyBatis 提供的嵌套映射。(其实就是用了 associationcollection 等关联语法)

上文我们已经分析完了,handleRowValues 方法简单结果集映射处理,现在我们来看看嵌套结果集的处理逻辑,与简单相比有哪些区别。

从整体的逻辑上面来看与简单的映射大差不差,也看到了几个熟悉的方法(绿框框) resolveDiscriminatedResultMapstoreObject,这里就不对这几个方法重复解释了。来看新的面孔(红框框)。

image.png

getValueapplyNestedResultMappings

image.png

这两个方法存在递归调用,父结果集中存在嵌套映射的话,会递归的调用 getValue 方法进行子实例的创建,过程中用 nestedResultObjectsancestorObjects 成员变量来解决存在的合并、循环引用等问题。

createRowKey

  1. 尝试使用 <idArg> 节点或 <id> 节点中定义的列名以及该列在当前记录行中对应的列值组成 CacheKey 对象
  2. 如果 ResultMap 中没有定义 <idArg> 节点或 <id>,则由 ResultMap 中明确要映射的列名(getPropertyResultMappings)以及它们在当前记录行中对应的列值一起构成 CacheKey 对象。
  3. 如果经过上述两个步骤后,依然查找不到相关的列名和列值,且 ResultMap.type 属性明确指明了结果对象为 Map 类型,则由结果集中所有列名以及该行记录行的所有列值一起构成CacheKey 对象。
  4. 如果映射的结果对象不是 Map 类型,则由结果集中未映射的列名以及它们在当前记录行中的对应列值一起构成 CacheKey 对象 。

一个 CacheKey 列表实例:

image.png

所以这里,我们可以得出结论,当我们结果集是嵌套映射时,一定要指定的能标识这行记录的 <id> 节点,不仅能节省部分内存,还可以提高比较速度。

mappedStatement.isResultOrdered() 的意义

我们先来看看当嵌套结果集映射时 SQL 语句中没有排序的情况,将断点设置在 handleRowValuesForNestedResultMap 方法的 if (rowValue != null && mappedStatement.isResultOrdered() && shouldProcessMoreRows(resultContext, rowBounds)) { 语句上,通过 debug 窗口观察到 nestedResultObjects 列表,得到如下:

image.png

再通过对结果集排序(对外层对象的 id 进行排序),并设置 属性 resultOrdered="true",得到如下:

image.png

很明显当排序以后,缓存在 nestedResultObjects 中的对象明显变少。其实原理就是 handleRowValuesForNestedResultMap 方法中的这个判断

if (mappedStatement.isResultOrdered()) {
    if (partialObject == null && rowValue != null) {
        nestedResultObjects.clear();
        storeObject(resultHandler, resultContext, rowValue, parentMapping, resultSet);
    }
    rowValue = getRowValue(rsw, discriminatedResultMap, rowKey, null, partialObject);
}

当 MyBatis 处理完一部分数据(指的是当有结果集需要进行合并时,隶属于同一个大的对象的那些数据),如果判断为当前结果集已经排序,会清除 这部分的缓存对象,提前释放空间,避免内存不足。

注:想象一下,List 转 Map 这个场景,假如结果集是有序的,是不是更好处理一点。

combineKeys

image.png

同一个客户经理下挂在多个企业下面时,假如我们这时候没有 combineKeys 方法:

映射第一行记录时,缓存 Key 是 WHW。映射第二行记录时,缓存 Key 还是 WHW。所以这行 nestedResultObjects.get(combinedKey) 就会找到原先的第一行 嵌套对象(如果找不到会创建)。所以会导致两个不同的 企业实例,指向了同一个 客户经理 实例。如图:

image.png

循环引用(ancestorObjects

结果集映射是我们手动配的,有可能存在如下情况:

<resultMap id="resultMapForB" type="TestB">
    <id property="id" column="id_b"/>
    <association property="testA" resultMap="resultMapForA"/>
</resultMap> 

<resultMap id="resultMapForA" type="TestA">
    <id property="id" column="id_a"/>
    <association property="testB" resultMap="resultMapForB"/>
</resultMap>

resultMapForA 里有 resultMapForB,resultMapForB 里有 resultMapForA。两个结果集之间相互引用。(有一点 SpringBean 循环引用 的意思)

  1. 首先会调用 getRowValue() 方法按照 id 为 resultMapForA 的 ResultMap 对结果集进行映射,此时会创建 TestA 对象,并将该 TestA 对象记录到 ancestorObjects 集合中。之后调用 applyNestedResultMappings() 方法处理 resultMapForA 中的嵌套映射,即映射 TestA.testB 属性。

  2. 在映射 TestA.testB 属性的过程中,会调用 getRowValue() 方法按照 id 为 resultMapForB 的 ResultMap 对结果集进行映射,此时会创建 TestB 对象。但是, resultMapForB 中存在嵌套映射,所以将 TestB 对象记录到 ancestorObjects 集合中。之后再次调用 applyNestedResultMappings()方法处理嵌套映射。

  3. 在此次调用 applyNestedResultMappings() 方法处理 resultMapForA 嵌套映射时,发现它的 TestA 对象己存在于 ancestorObjects 集合中, MyBatis 会认为存在循环引用,不再根据 resultMapForA 嵌套映射创建新的 TestA 对象 ,而是将 ancestorObjects 集合中己存在 的 TestA 对象设置到 TestB.testA 属性中井返回。

五、参考

  1. MyBatis 官网
  2. 《MyBatis 技术内幕》