背景
最近有一個數據統計服務需要升級SpringBoot的版本,由1.5.x.RELEASE直接升級到2.3.0.RELEASE,考慮到沒有用到SpringBoot的內建SPI,升級過程算是順利。但是出於代碼潔癖和版本潔癖,看到項目中依賴的MyBatis的版本是3.4.5,相比當時的最新版本3.5.5大有落后,於是順便把它升級到3.5.5。升級完畢之后,執行所有現存的集成測試,發現有部分OffsetDateTime類型入參的查詢方法出現異常,於是進行源碼層面的DEBUG找到最終的問題並且解決。

問題復現
項目中有一個查詢方法類似下面的演示例子:
public interface OrderMapper {
List<Order> selectByCreateTime(@Param("startCreateTime") OffsetDateTime startCreateTime,
@Param("endCreateTime") OffsetDateTime endCreateTime);
}
對應的XML文件中的SQL代碼段如下:
<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執行日志輸出正確的前提下返回了一個空集合,具體的內容如下:
查詢訂單列表:[]
雖然上帝視角是確認了入參解析有問題,但是基於第一次發生異常的日志,其實定位不到具體發生問題的位置,當時條件反射認為有幾處地方會出現這類異常(SQL比較簡單,可以排除人為寫錯SQL占位符的情況):
MyBatis解析OffsetDateTime類型方法參數的方法有版本兼容問題。MySQL驅動包解析OffsetDateTime類型的參數有版本兼容問題。- 前面兩種情況混合相互影響導致的,其實這里也可以理解為同一種情況,因為
MyBatis歸根到底是對MySQL驅動包進行了封裝。
當時項目中使用的mysql-connector-java版本為8.0.18,並未升級為當前的最新版本8.0.21,所以當時也有懷疑是低版本MySQL驅動包沒有兼容解析OffsetDateTime類型的參數。
簡析MyBatis的執行流程
MyBatis的源碼並不復雜,如果省去分析它的配置和映射文件解析模塊,一個查詢SQL(SelectList)的執行流程大致如下:

當然,因為問題出現在參數解析部分,只需要關注StatementHandler的處理邏輯即可。StatementHandler的父類BaseStatementHandler構造函數中,初始化了ParameterHandler和ResultSetHandler實例,提交到SimpleExecutor中的doQuery()方法中執行,使用了占位符參數的查詢會經由doQuery()方法中的prepareStatement()方法然后調用PreparedStatementHandler#parameterize(),最終委托到DefaultParameterHandler#setParameters()方法進行參數設置,這個setParameters()方法會用到ParameterMapping和TypeHandler。

如果用到了內建的TypeHandler或者自定義的TypeHandler實現,同時出現了參數解析異常,那么很大幾率異常就是從DefaultParameterHandler#setParameters()方法中出現,這樣就能順藤摸瓜找到出現異常的TypeHandler。
參數解析異常的根本原因
本文前面提到的解析OffsetDateTime類型異常,實際上執行查詢的時候代碼會步入OffsetDateTimeTypeHandler,這里對比一下3.4.5和3.5.5版本中MyBatis對應的OffsetDateTimeTypeHandler實現:
發現了主要區別如下:
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 前段時間搬家帶寬一直出問題,斷更了接近一周)

