1. 程式人生 > >MyBatis版本升級導致OffsetDateTime入參解析異常問題覆盤

MyBatis版本升級導致OffsetDateTime入參解析異常問題覆盤

## 背景 最近有一個數據統計服務需要升級`SpringBoot`的版本,由`1.5.x.RELEASE`直接升級到`2.3.0.RELEASE`,考慮到沒有用到`SpringBoot`的內建`SPI`,升級過程算是順利。但是出於程式碼潔癖和版本潔癖,看到專案中依賴的`MyBatis`的版本是`3.4.5`,相比當時的最新版本`3.5.5`大有落後,於是順便把它升級到`3.5.5`。升級完畢之後,執行所有現存的整合測試,發現有部分`OffsetDateTime`型別入參的查詢方法出現異常,於是進行原始碼層面的`DEBUG`找到最終的問題並且解決。 ![](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202008/m-u-e-7.png) ## 問題復現 專案中有一個查詢方法類似下面的演示例子: ```java public interface OrderMapper { List selectByCreateTime(@Param("startCreateTime") OffsetDateTime startCreateTime, @Param("endCreateTime") OffsetDateTime endCreateTime); } ``` 對應的`XML`檔案中的`SQL`程式碼段如下: ```xml ``` 上面的`OrderMapper#selectByCreateTime()`方法在`MyBatis`版本為`3.4.5`的前提下執行沒有任何異常,當`MyBatis`版本升級為`3.5.5`後再次執行,在`SQL`執行日誌輸出正確的前提下返回了一個空集合,具體的內容如下: ```shell 查詢訂單列表:[] ``` 雖然上帝視角是確認了入參解析有問題,但是基於第一次發生異常的日誌,其實定位不到具體發生問題的位置,當時條件反射認為有幾處地方會出現這類異常(`SQL`比較簡單,可以排除人為寫錯`SQL`佔位符的情況): 1. `MyBatis`解析`OffsetDateTime`型別方法引數的方法有版本相容問題。 2. `MySQL`驅動包解析`OffsetDateTime`型別的引數有版本相容問題。 3. 前面兩種情況混合相互影響導致的,其實這裡也可以理解為同一種情況,因為`MyBatis`歸根到底是對`MySQL`驅動包進行了封裝。 當時專案中使用的`mysql-connector-java`版本為`8.0.18`,並未升級為當前的最新版本`8.0.21`,所以當時也有懷疑是低版本`MySQL`驅動包沒有相容解析`OffsetDateTime`型別的引數。 ## 簡析MyBatis的執行流程 `MyBatis`的原始碼並不複雜,如果省去分析它的配置和對映檔案解析模組,一個查詢`SQL`(`SelectList`)的執行流程大致如下: ![](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202008/m-u-e-8.png) 當然,因為問題出現在引數解析部分,只需要關注`StatementHandler`的處理邏輯即可。`StatementHandler`的父類`BaseStatementHandler`建構函式中,初始化了`ParameterHandler`和`ResultSetHandler`例項,提交到`SimpleExecutor`中的`doQuery()`方法中執行,使用了佔位符引數的查詢會經由`doQuery()`方法中的`prepareStatement()`方法然後呼叫`PreparedStatementHandler#parameterize()`,最終委託到`DefaultParameterHandler#setParameters()`方法進行引數設定,這個`setParameters()`方法會用到`ParameterMapping`和`TypeHandler`。 ![](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202008/m-u-e-1.png ) 如果用到了內建的`TypeHandler`或者自定義的`TypeHandler`實現,同時出現了引數解析異常,那麼很大機率異常就是從`DefaultParameterHandler#setParameters()`方法中出現,這樣就能順藤摸瓜找到出現異常的`TypeHandler`。 ## 引數解析異常的根本原因 本文前面提到的解析`OffsetDateTime`型別異常,實際上執行查詢的時候程式碼會步入`OffsetDateTimeTypeHandler`,這裡對比一下`3.4.5`和`3.5.5`版本中`MyBatis`對應的`OffsetDateTimeTypeHandler`實現: 發現了主要區別如下: - `3.4.5`版本中,會把`OffsetDateTime`引數型別轉換為`Timestamp`型別,再委託到`PreparedStatement#setTimestamp()`進行引數設定。 ![](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202008/m-u-e-3.png) - `3.5.5`版本中,直接呼叫`PreparedStatement#setObject()`進行引數設定。 ![](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202008/m-u-e-2.png) `PreparedStatement#setTimestamp()`是很早期的產物,這個方法是沒有任何問題的,**`3.4.5`版本`MyBatis`把`OffsetDateTime`型別相容為`Timestamp`型別處理**。那麼基本可以確定問題出現在`PreparedStatement#setObject()`方法上,對於`MySQL8.x`的驅動,`PreparedStatement`選用的實現類是`com.mysql.cj.jdbc.ClientPreparedStatement`,通過層層`DEBUG`最終到達`AbstractQueryBindings#setObject()`方法: ![](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202008/m-u-e-4.png) 由於驅動中沒有任何解析`OffsetDateTime`型別的片段,所以最終會使用`AbstractQueryBindings#setSerializableObject()`方法(也就是`else`分支的程式碼)兜底,直接轉化為一個`byte[]`傳輸到`MySQL`服務端,**問題就出在這裡,直接把`OffsetDateTime`型別序列化疑似在`MySQL`服務端拿到的不是預期的引數,導致查詢條件出現失效(這裡筆者沒有花時間去閱讀`MySQL`的協議,也沒有花大量時間去抓包,所以這裡還只是猜測)**。然而,**這個問題在`2020-7-12`最新發布的`mysql:mysql-connector-java:8.0.21`依然沒有解決**。但是看到這裡又出現一個疑惑,`MyBatis`的開發者應該不可能在這種關鍵而不復雜的問題上出現紕漏,於是花時間去看看這裡的程式碼提交記錄: ![](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202008/m-u-e-9.png) 這是`Raupach`在`2017-08-22`的一個提交,提交的`message`是:測試`OffsetDateTimeHandler`保留了`UTC`的偏移量。單元測試類`OffsetDateTimeTypeHandlerTest`也只是驗證了`TypeHandler#setParameter()`和`PreparedStatement#setObject()`引數傳遞的正確性,**並沒有做整合測試去跟蹤所有型別資料庫的傳參問題,估計就是這一步疏忽了,但是這個應該不屬於MyBatis的問題,畢竟它只是對資料庫驅動包的封裝**。其中整合測試`TimestampWithTimezoneTypeHandlerTest`使用了記憶體資料庫,這裡可以猜測是`HSQLDB`驅動完善了日期時間的引數解析。 ![](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202008/m-u-e-10.png) 同樣的問題在`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()`的原始碼): ![](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202008/m-u-e-5.png) 這裡可見,`h2`的驅動真的對`JDK8+`新增的所有日期時間型別都做了解析: ![](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202008/m-u-e-6.png) ## 針對問題的解決方案 如果選用了`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`,然後通過配置直接覆蓋內建實現即可。 ```java // 假設全類名為club.throwable.OffsetDateTimeTypeHandler public class OffsetDateTimeTypeHandler extends BaseTypeHandler { @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
``` 其他型別解析異常都可以參照此思路進行相容。 ## 小結 **升級基礎框架版本需要謹慎**。另外,文中提到的解決方案只是筆者目前通過問題分析和定位得到的一種相對合理的解決方案,也可能有更優解。 本文的`demo`專案倉庫: - `Github`:`https://github.com/zjcscut/spring-boot-guide/tree/master/ch9-mybatis-mysql` (本文完 c-2-d e-a-20200802 前段時間搬家頻寬一直出問題,斷更了接近