背景
最近有一個資料統計服務需要升級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 前段時間搬家頻寬一直出問題,斷更了接近一週)