最近有一個數據統計服務須要升級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 前段時間搬家帶寬一直出問題,斷更了接近一週)