文章来源于公众号程序员小岑成长记 ,作者凯伦说
1、背景
某一天的晚上,系统服务正在进行常规需求的上线,因为发布时,提示统一的pom版本需要升级,于是从 1.3.9.6 升级至 1.4.2.1。
当服务开始上线后,开始陆续出现了一些更新系统交互日志方面的报警,属于系统辅助流程,报警下图所示, 具体系统数据已脱敏,内容是Mybatis相关的报警,在进行类型转换的时候,产生了强转错误。
更新开票请求返回日志, id:{#######}, response:{{"code":XXX,"data":{"callType":3,"code":XXX,"msg":"XXXX","shopId":XXXXX,"taxPlateDockType":"XXXXXXX"},"msg":"XXXXX","success":XXXX}}
nested execption is org.apache.ibatis.type.TypeException: Could not set parameters for mapping: ParameterMapping{property='updateTime', mode=IN, javaType=class java.lang.String,
jdbcTyp=null,resultMapId='null',jdbcTypeName='null',expression='null'}.Cause org.apache.ibatis.type.TypeException,Error setting non null parameter #2 with JdbcType null. Try setting a
different Jdbc Type for this parameter or a different configuration property.Cause java.lang.ClassCastException:java.time.LocalDateTime cannot be cast to java.lang.String
报警的一块代码,属于历史功能,失败并不会影响主流程,但在定位期间,会频繁报警,造成一定的干扰,因此当时首先采取回滚操作,将统一的pom版本回滚至历史版本,报警消失,再进行问题的定位和分析。
以下章节是对报警原因的定位及原因详细分析的介绍。
2、报警原因定位
首先是具体的报警原因:
由于mybatis版本由inf-bom引入而来,在inf-bom升级后,由3.2.3 升级至了 3.4.6版本,而Mybatis自3.2.4开始就不支持目前系统内的SQL Mapper的用法,因此上线后,线上出现频繁报警。接下来是定位的过程。
回滚完毕后,开始具体分析报警产生的主要原因,进行了以下几步的排查。
1.查看了报警的Mapper方法,如下代码所示, 这个是接收返回参数,根据主键id,更新具体响应内容和时间的代码,入参有3个,类型分别为long, String 和 LocalDateTime
int updateResponse(@Param("id")long id, @Param("response")String response, @Param("updateTime")LocalDateTime updateTime);
2.查看了Mapper方法对应的XML文件,如下代码,对应的parameterType类型是String,而实际参数的类型有Long,有String,也有LocalDateTime。
UPDATE invoice_log
SET response = #{response}, update_time = #{updateTime}
WHERE id = #{id}
3.查看了Mybatis上线前后的版本,因为报警的内容是Mybatis处理sql语句时,发现不能将LocalDateTime转型为String,这一段逻辑在上线前是ok的,上线的业务逻辑对这段历史代码无改动,因此猜测是统一pom的升级,导致Mybatis的版本发生了变化,某些历史功能不支持了。mybatis版本上线前后的变化,1.3.9.6对应的版本是3.2.3,1.4.2.1对应的版本是3.4.6。
4.通过第3步可以得到,在这次inf-bom的版本升级中,mybatis3的版本直接升了两个大版本,因此可以基本将原因猜测为 Mybatis升级跨度大,导致部分历史功能没有兼容支持,引起的线上sql更新报错。
5.为了具体验证第4步的想法,通过UT的方式,通过将Mybatis的版本不断从3.4.6往下降,直至没有报错位置,最终定位是Mybatis版本为3.2.3时,线上代码是正常可用的,只要升一个版本也就是自3.2.4开始,就开始不兼容目前的用法。(这个当时思路不是很好,应该从小版本逐个往上升,可以去加速定位版本的效率)
最后定位报警原因,由于mybatis版本由统一pom引入而来,在统一pom升级后,由3.2.3 升级至了 3.4.6版本,而Mybatis自3.2.4开始就不支持目前系统内的SQL Mapper的用法,因此上线后,线上出现频繁报警。
报警原因已定位,但为什么版本升级后就不兼容历史的用法,并且具体不兼容的是哪一块内容,背后的原理又是什么,请看接下来章节的详细分析。
3、详细分析
3.1 Mybatis 升级3.2.4版本的官方Release公告
首先从报错的原因上来看,Caused by: java.lang.ClassCastException: java.lang.LocalDateTime cannot be cast to java.lang.String ,是Mybatis在构建sql语句时,发现时间字段 类型为LocalDateTime 不能强制转为String类型。这个SQL XML的配置在3.2.3的版本是正常可以用,那么首先是从Mybatis 的 release log上查看3.2.4版本 发生了什么变化。
An special remark about this feature. Previous versions ignored the "parameterType" attribute and used the actual parameter to calculate bindings. This version builds the binding information during startup and the "parameterType" attribute is used if present (though it is still optional), so in case you had a wrong value for it you will have to change it.
从官网的Release Log可以看出,Mybatis在3.2.4以前的版本,是忽略XML中的parameterType这个属性,并且使用真实的变量类型进行值的处理,在3.2.4及以后的版本中,这个属性会被启用,因此如果出现类型不匹配的话,就会出现转型失败的报错,也提示我们开发者在升级到这个版本及以上时,需要检查系统内的XML配置,使类型相匹配,或者不设置该属性,让Mybatis自行进行计算。
从以上内容,可以了解到,在版本升级后,mybatis在构建sql语句,获取字段值的时候逻辑发生了变化,那么接下来通过一个普通的示例,了解mybatis在获取字段值这一块的具体代码流程是怎样的,以3.2.3版本为例。
3.2 以版本3.2.3为例,mybatis构建SQL语句过程的原理分析
首先,先看以下配置,定义了一个通过主键id获取学生信息的方法,仿造系统内的历史代码,也将parameterType定义为 java.lang.String 和 方法对应的参数 int 并不相同。
public StudentEntity getStudentById(@Param("id") int id);
mybatis框架要做的事情就是在运行getStudentById(2)的时候,将 #{id}进行替换,使SQL语句变成 SELECT id,name,age FROM student WHERE id = 2 。Mybatis要将SQL语句完整替换成带参数值的版本,需要经历框架初始化以及实际运行时动态替换两个部分。因为Mybatis的代码非常多,接下来主要阐释和本次案例相关的内容。
在框架初始化阶段,主要有以下流程,如下图所示
在框架初始化阶段,有一些组件会被构建,接下来进行逐一做个简单的介绍:
SqlSession 作为MyBatis工作的主要顶层API,表示和数据库交互的会话,完成必要数据库增删改查功能。
SqlSource 负责根据用户传递的parameterObject,动态地生成SQL语句,将信息封装到BoundSql对象中,并返回。
Configuration MyBatis所有的配置信息都维持在Configuration对象之中。
接下来主要关注SqlSource,这个类会负责在负责生成SQL语句,也是本次案例中,3.2.3和3.2.4差异比较大的地方。接下来会一些源码部分的介绍。
在构建Configuration的过程中,会涉及到构建对应每一条sql语句对应的MappedStatemnt,在parmeterTypeClass就是根据我们在xml配置中写的parmeterType转换而来,值为java.lang.String,在接下来构建SqlSource中,传入了这个参数,如下图所示:
在SqlSource的构建阶段中,parameterType参数其实是被忽略不使用的,这也和官方的描述是一致的,3.2.4之前这个parameterType属性是被忽略的,然后创建了DynamicSqlSource,这个类主要是用于处理Mybatis动态Sql的类。
在框架初始化阶段,需要介绍的内容,在3.2.3版本已经介绍完毕,接下来是当执行getStudentById方法时,Mybatis的流程,如下图所示,受限于图片长度,进行了布局的调整:
在具体执行阶段,也有一些组件,我们需要做了解
SqlSession 作为MyBatis工作的主要顶层API,表示和数据库交互的会话,完成必要数据库增删改查功能
Executor MyBatis执行器,是MyBatis 调度的核心,负责SQL语句的生成和查询缓存的维护
BoundSql 表示动态生成的SQL语句以及相应的参数信息
StatementHandler 封装了JDBC Statement操作,负责对JDBC statement 的操作,如设置参数、将Statement结果集转换成List集合。
ParameterHandler 负责对用户传递的参数转换成JDBC Statement 所需要的参数,
TypeHandler 负责java数据类型和jdbc数据类型之间的映射和转换
接下来主要关注在获取BoundSql以及参数化语句的流程,也是本次案例中,3.2.3和3.2.4差异比较大的地方。接下来会一些源码部分的介绍。
在进入Executor的query方法后,会首先通过对应的MappedStatement获取BoundSql,用来帮助我们动态生成SQL语句,里面绑定了对应的SQL以及参数映射关系,在构建框架阶段,我们使用的SqlSource是DynamicSqlSource,通过这个类来生成获取BoundSql。
通过上图的代码可以得知,parameterType在初始化阶段未被使用,而是在SQL执行时,获取到的,但获取到的类型是parameterObject对应的类型,这个类是用来记录mapper方法上对应的参数的。如下图所示,并非在Sql配置文件中标注的java.lang.String。
接下来,通过SqlSourceBuilder sqlSourceParser 对sql以及计算得到的类型进行再次处理,当中流程代码比较长,主要是在这个过程中去制作 sql方法的入参 和 java类型的绑定关系,mybatis依赖这个绑定关系使用对应的TypeHandler去进行值的转换,调用链路是SqlSourceParser.parse -> 内部类 ParameterMappingTokenHandler.handleToken -> 私有方法 buildParameterMapping, 如下图代码所示。因为当前的parmeterType为 MapperMethod$ParamMap,进过了多个if判断,判定当前property id 的 propertyType 为Object.class类型,接下来就是制作 sql方法的入参 和 java类型的绑定关系 parameterMapping,并进行了返回。
制作完成的ParameterMapping的结构如下图代码所示,参数id对应的javaType类型为 java.lang.Object,对应的TypeHander处理器为UnknownTypeHandler,也就是未找到合适的TypeHandler的兜底选项。
接下来流程就会流转到Executor, org.apache.ibatis.executor.SimpleExecutor#doQuery进行查询时,会根据当前的SQL类型,生成对应的statmentHandler,因为我们目前都是用的预编译SQL,因此生成的statementHandler就是PrepareStatmentHandler,熟悉JDBC的小伙伴应该马上可以猜到这对应的语句是什么类型了。接下来就会对这句SQL语句进行填充,如下图代码所示,会通过PrepareStatmentHandler的parameterize方法对Statment进行参数化,也就是进行填充过程。
在PreparseStatmentHandler进行参数化时,会将参数化的职责交给DefaultParameterHandler进行,如下图代码所示,主要关注红线部分,首先会获取parameterMapping对应的TypeHander,如上章节所示,获取到的是UnknownTypeHandler,然后会通过setParameter方法,将参数id替换成对应的值。
在typehandler的流程里,首先会进入BaseTypeHandler,然后在具体设置时,进入子类的方法,在UnknownTypeHandler,首先会再次对parameter进行解析,判断最正确的TypeHandler类型,如下图代码所示:
在resolveTypeHandler方法中,因为已知参数值的类型,通过Integer这个class在typeHandlerRegistry中寻找对应的TypeHandler,TypeHandlerRegistry是Mybatis启动时内置好的,java对象类型和TypeHandler的映射关系,有兴趣的可以进这个类详细看下,在本案例中,会直接获取到IntegerHandler,如下图代码所示:
在获取到IntegerHandler后,就可以使用IntegerTypeHandler的setInt方法,对SQL语句中的参数进行替换,如下图代码所示,sql语句被成功替换。
后续就是执行SQL并处理返回结果,不在本文的讨论范围内,从上文的分析中,我们可以了解到,在3.2.3及以下版本,Mybatis会忽略parmeterType,在真正进行sql转换时,重新根据sql方法入参类型计算合适的TypeHandler处理器,所以本案例中的代码在3.2.3时运行时正常的。
3.3 以版本3.2.4为例,相比版本3.2.3,mybatis构建SQL语句过程的变化分析
在3.2章节中,得知mybatis是在运行sql阶段重新计算参数对应的TypeHandler进行sql参数替换,那么在版本3.2.4中,mybatis做了什么改动,导致了原有的使用方式不可用了呢。从官方的release log来看,版本3.2.4做了这样一个改动。
This version builds the binding information during startup and the "parameterType" attribute is used
意思是说 parameterType会在框架运行阶段就被使用到,从这个中,我们将分析的重点放在构建阶段,同时负责处理绑定关系的BoundSql由配置阶段的SqlSource生成,因此主要查看SqlSource的构建,3.2.4发生了什么变化,如下图所示。与3.2.3不同,3.2.4首先判断了是否为动态SQL,在非动态SQL情况下,将parameterType java.lang.String作为参数,传入了SqlSource的构造方法。
后续流程与3.2.3一致,因为parameter类型为java.lang.String,在构建parameterMapping时,使用的类型就是java.lang.String。
因为在框架初始化阶段,SqlSource中 parameterMapping, id对应的类型就是java.lang.String,导致在进行Sql语句替换时,获取到的TypeHandler是StringTypeHandler,如下图所示:
后面的报错原因就比较好理解了,在调用StringTypeHandler的setString方法时,报出了java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String的错误。
4、总结
总结一下这个案例的主要原因是:
mybatis 3.2.3版本 兼容parameterType和实际参数类型不匹配,运行时动态计算值处理器类型,在大版本升级2个版本号后,parameterType开始生效,以parameterType作为参数的实际类型进行TypeHandler的获取计算,导致类型不匹配时,强转报错。
带给我自己的在后续编写编写代码及系统上线方面的启示是:
1.在统一pom升级时,需要线下进行全面回归,避免框架存在不兼容的用法,导致线上错误。
2.开发同学可以检查自己系统内的mybatis版本,如果是3.2.4以下,需要全面检查下现在的mapper文件里 对于parameterType的使用 和实际的参数类型是否一致,避免升级到3.2.4及以上版本时发生兼容报错,如果有不匹配的情况存在,需要进行修正 或者 不使用parameterType,让Mybatis在运行SQL时自动计算对应的类型,
3.可以考虑使用mybatis-generator来自动生成xml和mapper文件,有专业团队维护,相对来说稳定性更好,也避免自己手动修改xml文件容易带来误操作。
4.可以主动关注强依赖的一些开源框架的Release log,有很多重要的信息。
5、作者简介
岑凯伦、90后软件工程师、5年服务端开发经验 微信公众号: KailunTalk,知识星球:IT编程成长。