最近有一个数据统计服务须要升级SpringBoot
的版本,由1.5.x.RELEASE
直接升级到2.3.0.RELEASE
,考虑到没有用到SpringBoot
的内建SPI
,升级过程算是顺利。可是出于代码洁癖和版本洁癖,看到项目中依赖的MyBatis
的版本是3.4.5
,相比当时的最新版本3.5.5
大有落后,因而顺便把它升级到3.5.5
。升级完毕以后,执行全部现存的集成测试,发现有部分OffsetDateTime
类型入参的查询方法出现异常,因而进行源码层面的DEBUG
找到最终的问题而且解决。java
项目中有一个查询方法相似下面的演示例子:mysql
public interface OrderMapper { List<Order> selectByCreateTime(@Param("startCreateTime") OffsetDateTime startCreateTime, @Param("endCreateTime") OffsetDateTime endCreateTime); }
对应的XML
文件中的SQL
代码段以下:git
<select id="selectByCreateTime" resultMap="BaseResultMap"> SELECT * FROM t_order WHERE deleted = 0 AND create_time <![CDATA[>=]]> #{startCreateTime} AND create_time <![CDATA[<=]]> #{e ndCreateTime} </select>
上面的OrderMapper#selectByCreateTime()
方法在MyBatis
版本为3.4.5
的前提下执行没有任何异常,当MyBatis
版本升级为3.5.5
后再次执行,在SQL
执行日志输出正确的前提下返回了一个空集合,具体的内容以下:github
查询订单列表:[]
虽然上帝视角是确认了入参解析有问题,可是基于第一次发生异常的日志,其实定位不到具体发生问题的位置,当时条件反射认为有几处地方会出现这类异常(SQL
比较简单,能够排除人为写错SQL
占位符的状况):spring
MyBatis
解析OffsetDateTime
类型方法参数的方法有版本兼容问题。MySQL
驱动包解析OffsetDateTime
类型的参数有版本兼容问题。MyBatis
归根究竟是对MySQL
驱动包进行了封装。当时项目中使用的mysql-connector-java
版本为8.0.18
,并未升级为当前的最新版本8.0.21
,因此当时也有怀疑是低版本MySQL
驱动包没有兼容解析OffsetDateTime
类型的参数。sql
MyBatis
的源码并不复杂,若是省去分析它的配置和映射文件解析模块,一个查询SQL
(SelectList
)的执行流程大体以下:shell
固然,由于问题出如今参数解析部分,只须要关注StatementHandler
的处理逻辑便可。StatementHandler
的父类BaseStatementHandler
构造函数中,初始化了ParameterHandler
和ResultSetHandler
实例,提交到SimpleExecutor
中的doQuery()
方法中执行,使用了占位符参数的查询会经由doQuery()
方法中的prepareStatement()
方法而后调用PreparedStatementHandler#parameterize()
,最终委托到DefaultParameterHandler#setParameters()
方法进行参数设置,这个setParameters()
方法会用到ParameterMapping
和TypeHandler
。数据库
若是用到了内建的TypeHandler
或者自定义的TypeHandler
实现,同时出现了参数解析异常,那么很大概率异常就是从DefaultParameterHandler#setParameters()
方法中出现,这样就能顺藤摸瓜找到出现异常的TypeHandler
。mybatis
本文前面提到的解析OffsetDateTime
类型异常,实际上执行查询的时候代码会步入OffsetDateTimeTypeHandler
,这里对比一下3.4.5
和3.5.5
版本中MyBatis
对应的OffsetDateTimeTypeHandler
实现:app
发现了主要区别以下:
3.4.5
版本中,会把OffsetDateTime
参数类型转换为Timestamp
类型,再委托到PreparedStatement#setTimestamp()
进行参数设置。3.5.5
版本中,直接调用PreparedStatement#setObject()
进行参数设置。PreparedStatement#setTimestamp()
是很早期的产物,这个方法是没有任何问题的,3.4.5
版本MyBatis
把OffsetDateTime
类型兼容为Timestamp
类型处理。那么基本能够肯定问题出如今PreparedStatement#setObject()
方法上,对于MySQL8.x
的驱动,PreparedStatement
选用的实现类是com.mysql.cj.jdbc.ClientPreparedStatement
,经过层层DEBUG
最终到达AbstractQueryBindings#setObject()
方法:
因为驱动中没有任何解析OffsetDateTime
类型的片断,因此最终会使用AbstractQueryBindings#setSerializableObject()
方法(也就是else
分支的代码)兜底,直接转化为一个byte[]
传输到MySQL
服务端,问题就出在这里,直接把OffsetDateTime
类型序列化疑似在MySQL
服务端拿到的不是预期的参数,致使查询条件出现失效(这里笔者没有花时间去阅读MySQL
的协议,也没有花大量时间去抓包,因此这里还只是猜想)。然而,这个问题在2020-7-12
最新发布的mysql:mysql-connector-java:8.0.21
依然没有解决。可是看到这里又出现一个疑惑,MyBatis
的开发者应该不可能在这种关键而不复杂的问题上出现纰漏,因而花时间去看看这里的代码提交记录:
这是Raupach
在2017-08-22
的一个提交,提交的message
是:测试OffsetDateTimeHandler
保留了UTC
的偏移量。单元测试类OffsetDateTimeTypeHandlerTest
也只是验证了TypeHandler#setParameter()
和PreparedStatement#setObject()
参数传递的正确性,并无作集成测试去跟踪全部类型数据库的传参问题,估计就是这一步疏忽了,可是这个应该不属于MyBatis的问题,毕竟它只是对数据库驱动包的封装。其中集成测试TimestampWithTimezoneTypeHandlerTest
使用了内存数据库,这里能够猜想是HSQLDB
驱动完善了日期时间的参数解析。
一样的问题在h2
数据库中不会出现,因而稍微DEBUG
了一下h2
数据库驱动进行参数设置的源码,最终定位到org.h2.value.DataType
(驱动包的版本为com.h2database:h2:1.4.200
)的第1333
行有对应JSR310.OFFSET_DATE_TIME
的解析逻辑,因此h2
数据库驱动能够支持全部JSR310
引入的参数类型的参数值设置。下面的截图是h2
数据库驱动中PreparedStatement#setObject()
的解析实现(见org.h2.jdbc.JdbcPreparedStatement
和DataType#convertToValue()
的源码):
这里可见,h2
的驱动真的对JDK8+
新增的全部日期时间类型都作了解析:
若是选用了MySQL
,这个参数解析异常的问题截至mysql:mysql-connector-java:8.0.21
只有一种解决方案:要把OffsetDateTime
类型兼容为Timestamp
类型进行参数设置。其实对于全部非LocalXX
的日期时间类型都须要进行兼容,兼容表格以下:
序号 | 类型 | 兼容类型 | 调用方法 |
---|---|---|---|
1 | OffsetDateTime |
Timestamp |
PreparedStatement#setTimestamp() |
2 | ZonedDateTime |
Timestamp |
PreparedStatement#setTimestamp() |
3 | OffsetDate |
java.sql.Date |
PreparedStatement#setDate() |
4 | OffsetTime |
java.sql.Time |
PreparedStatement#setTime() |
以OffsetDateTime
为例,只须要参考或者直接使用3.4.5
版本中的MyBatis
的OffsetDateTimeTypeHandler
,而后经过配置直接覆盖内置实现便可。
// 假设全类名为club.throwable.OffsetDateTimeTypeHandler public class OffsetDateTimeTypeHandler extends BaseTypeHandler<OffsetDateTime> { @Override public void setNonNullParameter(PreparedStatement ps, int i, OffsetDateTime parameter, JdbcType jdbcType) throws SQLException { ps.setTimestamp(i, Timestamp.from(parameter.toInstant())); } @Override public OffsetDateTime getNullableResult(ResultSet rs, String columnName) throws SQLException { Timestamp timestamp = rs.getTimestamp(columnName); return getOffsetDateTime(timestamp); } @Override public OffsetDateTime getNullableResult(ResultSet rs, int columnIndex) throws SQLException { Timestamp timestamp = rs.getTimestamp(columnIndex); return getOffsetDateTime(timestamp); } @Override public OffsetDateTime getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { Timestamp timestamp = cs.getTimestamp(columnIndex); return getOffsetDateTime(timestamp); } private static OffsetDateTime getOffsetDateTime(Timestamp timestamp) { if (timestamp != null) { // 这里能够考虑自定义系统的时区,例如ZoneId.of("Asia/Shanghai") return OffsetDateTime.ofInstant(timestamp.toInstant(), ZoneId.systemDefault()); } return null; } }
配置文件中进行TypeHandler
配置覆盖,下面是类路径下配置文件mybatis-config.xml
的示例:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd"> <configuration> <settings> <!--下划线转驼峰--> <setting name="mapUnderscoreToCamelCase" value="true"/> <!--未知列映射忽略--> <setting name="autoMappingUnknownColumnBehavior" value="NONE"/> </settings> <typeHandlers> <!--覆盖内置OffsetDateTimeTypeHandler--> <typeHandler handler="throwable.club.OffsetDateTimeTypeHandler"/> </typeHandlers> </configuration>
其余类型解析异常均可以参照此思路进行兼容。
升级基础框架版本须要谨慎。另外,文中提到的解决方案只是笔者目前经过问题分析和定位获得的一种相对合理的解决方案,也可能有更优解。
本文的demo
项目仓库:
Github
:https://github.com/zjcscut/spring-boot-guide/tree/master/ch9-mybatis-mysql
(本文完 c-2-d e-a-20200802 前段时间搬家带宽一直出问题,断更了接近一周)