源码解析之 Mybatis 对 Integer 参数做了什么手脚?

title: 源码解析之 Mybatis 对 Integer 参数做了什么手脚?
date: 2021-03-11
updated: 2021-03-11
categories:

  • Mybatis
  • 源码解析
    tags:
  • Mybatis
  • 源码解析

解决方案放在第二节,急需解决问题,可直接查看解决方案。

本文为深度长文,请耐心阅读!

问题描述

在 Mybatis 中,Integer 的入参为 0 时,发现判断条件的非空判断没有生效,原本应该存在的判断条件丢失了。

那么,Mybatis 到底对 Integer 参数做了什么手脚呢?下面我们来举例说明:

环境示例

该问题只与 Mybatis 的实现机制有关,与版本基本无关(如果说相关性,可能只与源码中实现代码所在的行数有关)。

不过,为了养成良好的习惯,还是稍微提一下,我使用的 Mybatis 版本是 3.5.2。

接口示例

@GetMapping("/queryByAgeGroup")
public HttpStatus queryByAgeGroup(@RequestParams("ageGroup") Integer ageGroup) {

  // ageGroup 年龄段:0 代表幼儿,1 代表青年,2 代表中年,3 代表老年,-1 代表未知
  IndexTestService.queryByAgeGroup(ageGroup);
  return HttpStatus.HTTP_OK;
}

测试用例属于参数透传,没有业务逻辑,故省略 Service 和 Dao 层。

查询 SQL 示例

<select id="queryByAgeGroup" text="">
  select *
  from `people_info`
  where 1 = 1
  <if test="ageGroup != null and ageGroup != ''">
  and age_group = #{ageGroup}
  </if>
</select>

数据表结构示例

CREATE TABLE `people_info` (
  `id` varchar(64) NOT NULL COMMENT '主键',
  `name` varchar(255) DEFAULT NULL COMMENT '名称',
  `age_group` int(11) DEFAULT NULL COMMENT '年龄',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

当入参为 0 时,

发现控制台打印 SQL 如下:

select *
from `people_info`
where 1 = 1
-- 本该存在的 ageGroup 判断消失了!

解决方案

首先提供该问题的几种解决方案。

方案一

如果是数据字典类型的字段,在定义数据字典时,避免使用 0 作为枚举值,从根源杜绝该问题。

方案二

如果是非法数据,可在 Controller 层入参增加参数校验,如果传 0,提示“参数无效”。

方案三

如果是合法数据,可在 SQL 判断条件上增加 or ageGroup == 0 判断。

<if test="ageGroup != null and ageGroup != '' or ageGroup == 0">
and age_group = #{ageGroup}
</if>

方案四

如果是合法数据,可将 Integer 转为 String,按 String 参数处理。

String ageGroupStr = String.valueOf(1);

源码解析

下面,我们就通过分析源码,一起来看一下 Mybatis 不为人知的“小动作”。

解析

  1. 首先,让我们来到 DefaultSqlSession#select(statement, parameter, rowBounds, handler) 方法。

    // 第 165 行
    @Override
    public void select(String statement, Object parameter, RowBounds rowBounds, ResultHandler handler) {
      try {
        // 拿到映射的 sql 语句
        MappedStatement ms = configuration.getMappedStatement(statement);
        // 执行器执行查询 sql -- 重点!!!
        executor.query(ms, wrapCollection(parameter), rowBounds, handler);
      } catch (Exception e) {
        throw ExceptionFactory.wrapException("Error querying database.  Cause: " + e, e);
      } finally {
        ErrorContext.instance().reset();
      }
    }
    
  2. 此时拿到传入 sql,那么有“小动作”的相想必是执行器,下面进入 BaseExecutor#query(ms, parameter, rowBounds, resultHandler) 方法。

    // 第 132 行
    @Override
    public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
      // sql 绑定 -- 重点!!!
      BoundSql boundSql = ms.getBoundSql(parameter);
      // 创建缓存 key
      CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
      // 执行查询
      return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
    }
    
  3. 在 sql 绑定过程中都做了什么?下面进入 MappedStatement#getBoundSql(parameterObject) 方法。

    // 第 296 行
    public BoundSql getBoundSql(Object parameterObject) {
      // 获取 sql 绑定 -- 重点!!!
      BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
      // 获取参数映射集合
      List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
      if (parameterMappings == null || parameterMappings.isEmpty()) {
        boundSql = new BoundSql(configuration, boundSql.getSql(), parameterMap.getParameterMappings(), parameterObject);
      }
    
      // check for nested result maps in parameter mappings (issue #30)
      for (ParameterMapping pm : boundSql.getParameterMappings()) {
        String rmId = pm.getResultMapId();
        if (rmId != null) {
          ResultMap rm = configuration.getResultMap(rmId);
          if (rm != null) {
            hasNestedResultMaps |= rm.hasNestedResultMaps();
          }
        }
      }
    
      return boundSql;
    }
    
  4. 可以看到 sql 是在 sqlSource 中被绑定的,下面进入 SqlSource#getBoundSql(parameterObject) 方法。

    // 第 24 行
    public interface SqlSource {
    
      BoundSql getBoundSql(Object parameterObject);
    }
    
  5. 这是个接口方法,有 4 种默认实现:DynamicSqlSource、ProviderSqlSource、RawSqlSource 和 StaticSqlSource。

  6. 以为 DynamicSqlSource 为例,进入 DynamicSqlSource#getBoundSql(parameterObject) 方法。

    // 第 36 行
    @Override
    public BoundSql getBoundSql(Object parameterObject) {
      // 获取上下文对象
      DynamicContext context = new DynamicContext(configuration, parameterObject);
      // 装载 sql 标签节点中的语句 -- 重点!!!
      rootSqlNode.apply(context);
      // 创建 sql 构造器
      SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
      // 设置参数类型
      Class<?> parameterType = parameterObject == null ? Object.class : parameterObject.getClass();
      // 解析 sql
      SqlSource sqlSource = sqlSourceParser.parse(context.getSql(), parameterType, context.getBindings());
      // 获取已绑定的 sql
      BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
      // 循环绑定参数
      context.getBindings().forEach(boundSql::setAdditionalParameter);
      return boundSql;
    }
    
  7. 进入 SqlNode#apply(context) 方法。

    // 第 21 行
    public interface SqlNode {
      boolean apply(DynamicContext context);
    }
    
  8. 这也是个接口方法,有 8 种默认实现:ChooseSqlNode、ForEachSqlNode、IfSqlNode、MixedSqlNode、StaticTextSqlNode、TextSqlNode、TrimSqlNode 和 VarDeclSqlNode,分别对应不同类型的标签节点处理方式。

  9. 这里我们要找的是 if 标签节点的处理逻辑,因此进入 IfSqlNode#apply(context) 方法。

    // 第 32 行
    @Override
    public boolean apply(DynamicContext context) {
      // 判断 -- 重点!!!
      if (evaluator.evaluateBoolean(test, context.getBindings())) {
        // 继续解析后续标签节点
        contents.apply(context);
        return true;
      }
      return false;
    }
    
  10. 进入 ExpressionEvaluator#evaluateBoolean(expression, parameterObject) 方法。

    // 第 31 行
    public boolean evaluateBoolean(String expression, Object parameterObject) {
      // 获取参数值 -- 重点!!!
      // 注意!value 不管之前是 '' 还是 0,这里返回一定是 0,具体逻辑咱们继续向下看。
      Object value = OgnlCache.getValue(expression, parameterObject);
      // 若参数值为布尔类型
      if (value instanceof Boolean) {
        // 强转为布尔值并返回
        return (Boolean) value;
      }
      // 若参数值为数值类型
      if (value instanceof Number) {
        // 强转为字符串后,再转为 BigDecimal,然后与 BigDecimal.ZERO 比较,判断是否不为 0,并返回判断结果
        // 根据上面的结论,当传入 '' 和 0 走到这一步时,value 值必然为 0,那么返回结果为 false,因此该 if 标签被跳过
        return new BigDecimal(String.valueOf(value)).compareTo(BigDecimal.ZERO) != 0;
      }
      // 判断参数值是否为空,并返回判断结果
      return value != null;
    }
    
  11. 这里我们会发现Mybatis 实际上是使用 OGNL 表达式来处理参数的,下面进入 OgnlCache#getValue(expression, root) 静态方法。

    // 第 43 行
    public static Object getValue(String expression, Object root) {
      try {
        // 获取上下文对象
        Map context = Ognl.createDefaultContext(root, MEMBER_ACCESS, CLASS_RESOLVER, null);
        // 获取参数值 -- 重点!!!
        return Ognl.getValue(parseExpression(expression), context, root);
      } catch (OgnlException e) {
        throw new BuilderException("Error evaluating expression '" + expression + "'. Cause: " + e, e);
      }
    }
    
  12. 继续跟进去,进入 Ognl#getValue(tree, context, root) 静态方法。

    // 第 454 行
    public static Object getValue(Object tree, Map context, Object root)
            throws OgnlException
    {
      // 没什么好解释的,继续跟进 
      return getValue(tree, context, root, null);
    }
    
    // 第 482 行
    public static Object getValue(Object tree, Map context, Object root, Class resultType)
            throws OgnlException
    {
      Object result;
      // 构建 OGNL 上下文对象
      OgnlContext ognlContext = (OgnlContext) addDefaultContext(root, context);
    
      // 强转节点对象
      Node node = (Node)tree;
    
      // 若寄存器不为空
      if (node.getAccessor() != null)
        // 从寄存器中获取参数值
        result =  node.getAccessor().get(ognlContext, root);
      else
        // 否则,从节点对象获取参数值
        result = node.getValue(ognlContext, root);
    
      // 若参数类型不为空
      if (resultType != null) {
        // 获取类型转换器,转换参数值 -- 重点!!!
        result = getTypeConverter(context).convertValue(context, root, null, null, result, resultType);
      }
      return result;
    }
    
  13. 这不是参数转换的方法吗?进入 TypeConverter#convertValue(context, target, member, propertyName, value, toType) 方法。

    public interface TypeConverter
    {
      public Object convertValue(Map context, Object target, Member member, String propertyName, Object value, Class toType);
    }
    
  14. 又是接口方法,下面进入实现类 DefaultTypeConverter#convertValue(context, target, member, propertyName, value, toType) 方法。

    // 第 48 行 -- 再进入这里
    public Object convertValue(Map context, Object value, Class toType)
    {
      // 转换参数值 -- 重点!!!
      return OgnlOps.convertValue(value, toType);
    }
    
    // 第 53 行 -- 先进入这里
    public Object convertValue(Map context, Object target, Member member, String propertyName, Object value, Class toType)
    {
      return convertValue(context, value, toType);
    }
    
  15. 离真相越来越近了,进入 OgnlOps#convertValue(Object value, Class toType) 方法。

    // 第 508 行 -- 先进入这里
    public static Object convertValue(Object value, Class toType)
    {
      return convertValue(value, toType, false);
    }
    
    // 第 553 行 -- 再进入这里
    public static Object convertValue(Object value, Class toType, boolean preventNulls)
    {
      Object result = null;
    
      // 如果参数值不为空,且与传入类型相同,返回参数值
      if (value != null && toType.isAssignableFrom(value.getClass()))
        return value;
    
      if (value != null) {
        // 如果参数值不为空
        /* If array -> array then convert components of array individually */
        if (value.getClass().isArray() && toType.isArray()) {
          // 如果参数值与参数类型都为数组
          // 获取类型
          Class componentType = toType.getComponentType();
    
          // 根据类型和长度,创建 Array 对象
          result = Array.newInstance(componentType, Array.getLength(value));
          // 循环 Array,对 Array 每一个参数值进行转换并赋值
          for(int i = 0, icount = Array.getLength(value); i < icount; i++) {
            Array.set(result, i, convertValue(Array.get(value, i), componentType));
          }
        } else if (value.getClass().isArray() && !toType.isArray()) {
    
          // 如果参数值为数组,参数类型非数组
          // 对参数值进行转换并赋值
          return convertValue(Array.get(value, 0), toType);
        } else if (!value.getClass().isArray() && toType.isArray()){
    
          // 如果参数值非数组,参数类型为数组
          if (toType.getComponentType() == Character.TYPE) {
    
            // 如果参数类型为 char
            // 将参数值转为字符串后,转为 char 数组
            result = stringValue(value).toCharArray();
          } else if (toType.getComponentType() == Object.class) {
            // 如果参数类型为 Obejct
            if (value instanceof Collection) {
              // 如果参数类型为集合
              // 强转为集合
              Collection vc = (Collection) value;
              // 转换为数组
              return vc.toArray(new Object[0]);
            } else
              // 创建一个新的 Object 对象并返回
              return new Object[] { value };
          }
        } else {
          // 如果参数类型为 Integer -- 重点!!!
          if ((toType == Integer.class) || (toType == Integer.TYPE)) {
            // 参数值转换,强转为 int 并赋值 -- 重点!!!
            result = new Integer((int) longValue(value));
          }
          if ((toType == Double.class) || (toType == Double.TYPE)) result = new Double(doubleValue(value));
          if ((toType == Boolean.class) || (toType == Boolean.TYPE))
            result = booleanValue(value) ? Boolean.TRUE : Boolean.FALSE;
          if ((toType == Byte.class) || (toType == Byte.TYPE)) result = new Byte((byte) longValue(value));
          if ((toType == Character.class) || (toType == Character.TYPE))
            result = new Character((char) longValue(value));
          if ((toType == Short.class) || (toType == Short.TYPE)) result = new Short((short) longValue(value));
          if ((toType == Long.class) || (toType == Long.TYPE)) result = new Long(longValue(value));
          if ((toType == Float.class) || (toType == Float.TYPE)) result = new Float(doubleValue(value));
          if (toType == BigInteger.class) result = bigIntValue(value);
          if (toType == BigDecimal.class) result = bigDecValue(value);
          if (toType == String.class) result = stringValue(value);
        }
      } else {
        if (toType.isPrimitive()) {
          result = OgnlRuntime.getPrimitiveDefaultValue(toType);
        } else if (preventNulls && toType == Boolean.class) {
          result = Boolean.FALSE;
        } else if (preventNulls && Number.class.isAssignableFrom(toType)){
          result = OgnlRuntime.getNumericDefaultValue(toType);
        }
      }
    
      if (result == null && preventNulls)
        return value;
    
      if (value != null && result == null) {
    
        throw new IllegalArgumentException("Unable to convert type " + value.getClass().getName() + " of " + value + " to type of " + toType.getName());
      }
    
      return result;
    }
    
  16. 我们看到了 Integer 参数实际会走到 OgnlOps#longValue(value) 方法。

    // 第 213 行
    public static long longValue(Object value)
        throws NumberFormatException
    {
      // 参数值若为 null,返回 0
      if (value == null) return 0L;
      // 获取参数值对应类
      Class c = value.getClass();
      // 若参数值为数值类型,强转为数值类型后返回
      if (c.getSuperclass() == Number.class) return ((Number) value).longValue();
      // 若参数值为布尔类型,强转为布尔类型后,转为 0 或 1 后返回
      if (c == Boolean.class) return ((Boolean) value).booleanValue() ? 1 : 0;
      // 若参数值为数值类型,强转为字节类型后返回
      if (c == Character.class) return ((Character) value).charValue();
      // 若未匹配到参数类型,转为字符串后,再转为长整型类型后返回
      return Long.parseLong(stringValue(value, true));
      /* 这里可以看出,不管是 '' 还是 0,都统一做 0 处理 */
    }
    

结论

在 Mybatis 的 if 标签中,为了兼容错误的类型传参(如:参数为 Integer 类型,却传入 String 类型),在数值转换的处理上,非数值类型的参数值会转换为数值类型的值。然后通过判断是否为 0 ,决定 if 标签中的 sql 语句是否生效。

因此,当我们使用 ageGroup != null and ageGroup != '' 条件时,如果入参为 0,会被 Mybatis 忽略。

会出现类似情况的类型还包括其他数值类型:Double、Byte、Character、Short、Long、Float、BigInteger、BigDecimal

给TA买糖
共{{data.count}}人
人已赞赏
经验教程

前端学习 node 快速入门 系列 —— 模块(module)

2021-3-11 20:44:00

经验教程

最简单的JVM内存结构图

2021-3-11 20:58:00

⚠️
免责声明:根据《计算机软件保护条例》第十七条规定“为了学习和研究软件内含的设计思想和原理,通过安装、显示、传输或者存储软件等方式使用软件的,可以不经软件著作权人许可,不向其支付报酬。”您需知晓本站所有内容资源均来源于网络,仅供用户交流学习与研究使用,版权归属原版权方所有,版权争议与本站无关,用户本人下载后不能用作商业或非法用途,需在24个小时之内从您的电脑中彻底删除上述内容,否则后果均由用户承担责任;如果您访问和下载此文件,表示您同意只将此文件用于参考、学习而非其他用途,否则一切后果请您自行承担,如果您喜欢该程序,请支持正版软件,购买注册,得到更好的正版服务。 本站为个人博客非盈利性站点,所有软件信息均来自网络,所有资源仅供学习参考研究目的,并不贩卖软件,不存在任何商业目的及用途,网站会员捐赠是您喜欢本站而产生的赞助支持行为,仅为维持服务器的开支与维护,全凭自愿无任何强求。本站部份代码及教程来源于互联网,仅供网友学习交流,若您喜欢本文可附上原文链接随意转载。
无意侵害您的权益,请发送邮件至 momeis6@qq.com 或点击右侧 私信:momeis 反馈,我们将尽快处理。
0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
今日签到
有新私信 私信列表
搜索