Mybatis疑难事件簿:'#'传递布尔值无效问题

815 阅读3分钟

Mybatis疑难事件簿:'#'传递布尔值无效问题

这是我参与2022首次更文挑战的第1天,活动详情查看:2022首次更文挑战

人生苦短,不如养狗

一、问题现场

  MySQL自5.7版本就开始提供JSON类型,本次问题就是在使用JSON类型时出现的MySQL服务可以正常查询而使用Mybatis查询失效问题。

  具体表现为在使用Mybatis(这里需要注意一下,笔者实际使用了其增强版Mybatis-Plus)按照JSON类型字段中某个key的指定value进行条件查询时出现无法查询出结果,在参数值传递时使用了'#'进行变量值传递,查询代码如下:

    @Select("select * from `task_info` where task_params -> #{fieldName} = #{fieldValue}")
    @ResultMap("mybatis-plus_TaskInfo")
    List<TaskInfo> selectByTaskParams(String fieldName, Object fieldValue);

  经过测试可以发现,上述查询语句只有在针对布尔类型变量传递时才会发生查询失效的情况,当传递的参数为字符串类型和数字类型时是能够正常查询出结果。

二、探寻症结

1. 打印执行SQL

  在发现Mybatis的执行结果和直接运行SQL的执行结果不一致后,最初的猜测是Mybatis实际执行的SQL和目标SQL不一致,导致最终执行结果和预期结果不一致。为了验证猜想,需要将Mybatis中实际执行的SQL打印出来进行对比判断,这里引入了p6spy开源工具来进行数据库操作的跟踪(这里没有使用StdOutImpl的原因是因为在控制台打印时,执行SQL并没有做到拼接处理,对应的参数仍然是以'?'展示)。

  通过上面的工具可以在控制台中看到实际的执行SQL如下:

select * FROM `task_info` WHERE task_params -> '$.online' = true;

  将对应的粘贴到数据库图形化界面中进行执行,发现能够正常获取对应的结果。这里不由得开始怀疑p6spy工具在进行拼接SQL过程中并没有完全按照Mybatis的拼接逻辑进行拼接处理,而是单纯在获取到SQL执行语句和传入参数之后进行语句文本的简单拼接处理。

2. 使用'$'传值

  从SQL语句上没有看出明显异常的地方,只能将怀疑的矛头指向了SQL组装的过程,即使用'#{}'传递操作来进行SQL组装造成的问题。从上面的代码中可以看到,这里使用'#{}'来进行值的传递操作,为了验证猜想是否成立,将'#{}'替换成'${}'来进行值传递,变换后的查询代码如下:

    @Select("select * from `task_info` where task_params -> #{fieldName} = ${fieldValue}")
    @ResultMap("mybatis-plus_TaskInfo")
    List<TaskInfo> selectByTaskParams(String fieldName, Object fieldValue);

  经过测试发现,使用上面的查询语句是可以正确查询出对应的结果的,说明上面的猜想是成立。

三、原因推导

  从上面的问题探寻过程可以发现,原始查询语句的执行异常确实是由于'#{}'造成的,但是具体的原因是什么呢?

1. '#{}'和'${}'的区别

  从上面的测试中我们可以发现,同一个SQL语句使用#{}传值无法查询成功,而使用${}则能正确查询到对应的结果,这说明对于上述两种不同的占位符Mybatis内部的处理机制是不一样的。这里我们回到Mybatis的代码中可以发现,当使用#{}时Mybatis会创建PreparedStatement来进行SQL的组装,而使用${}时则会使用Statement来进行SQL的组装。这两个类最大的不同点相信各位也已经能倒背如流了,即前者在执行SQL时会有预编译的过程,而后者则会不加处理地直接执行静态SQL并返回对应的结果(这里的静态SQL是指值传递过程不会做任何类型校验,按照文本拼接的方式直接拼接出对应的SQL)。

2. '#{}'的预编译过程

  既然明确问题的根源在于预编译的过程,下面我们就来仔细排查一下。首先来看一下PreparedStatement,结合Mybatis代码和官方注释可以看到,在使用PreparedStatement进行待执行SQL组装时需要用到其内部提供的Setter方法进行参数设置,由于上面我们出问题的变量类型为布尔类型,所以我们跟踪一下设置变量的过程。

  首先从设置变量的入口方法开始。这里需要提醒一下,由于笔者使用的是Mybatis-Plus,参数设置的入口方法和原生的Mybatis不一样,但底层设置实际都是一样,所以这里按照Mybatis-Plus的进行分析。具体入口方法为MybatisParameterHandler#setParameters,如下图:

mybatis-setParams.jpg

mybatis-setParams2.jpg

  从上图中可以看到最关键的一句是图中红框圈出来的typeHandler.setParameter(ps, i + 1, value, jdbcType);,由于在原始的代码中并未明确指明传入的变量到底的什么类型,所以在第二张图中可以看到此时TypeHandler对象实例的类型为UnknownTypeHandler。为了进一步明确TypeHandler的实际类型,UnknownTypeHandler中提供了resolveTypeHandler(Object parameter, JdbcType jdbcType)方法来进一步明确参数的类型和处理器的实例类型,具体如下图:

mybatis-setParams3.jpg

  可以看到,通过resolveTypeHandler方法明确了参数的类型为布尔类型,而类型处理器最终也选择了BooleanTypeHandler

mybatis-setParams4.jpg

  在完成了类型处理器的选择之后,终于开始接近我们的目标——setBoolean方法,继续跟踪下去:

mybatis-setParams5.jpg

mybatis-setParmas6.jpg

  一路跟踪到ClientPreparedQueryBindings#setBoolean方法内部才终于找到了本次问题的原因:原来在进行布尔值设置时,并不是按照我们以为的直接使用布尔值来进行设置,而是将布尔值转换成了0或者1,然后用转换后的值进行SQL语句的拼接操作,这就操作了实际生成的SQL变成了下面这样:

select * FROM `task_info` WHERE task_params -> '$.online' = '1';

  使用这种SQL进行查询当然不会查询到我们预期的结果,至此,使用'#'传递布尔值导致查询失败的问题得到了解决,最终我们选择改用'$'来完成值的传递处理。

四、总结

  关于'#{}'和'${}'的使用可以说是一个老生常谈的问题,但有时候只是概念上清楚而细节模糊的话,上面这样一个问题在产生之初还是较难探寻的本质原因的。

  当然,上面源码中对于布尔值的处理也不难理解,因为在MySQL当中布尔值实际上会转换成tinyint类型进行存储,即使用0/1来分别标识true/false。只是在SQL语句处理时,MySQL能够识别true/false并重新转换成0/1来进行检索操作,这也一定程度上造成了我们对于MySQL中布尔值的认知和其本质的错位。