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找到最終的問題而且解決。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

  1. MyBatis解析OffsetDateTime類型方法參數的方法有版本兼容問題。
  2. MySQL驅動包解析OffsetDateTime類型的參數有版本兼容問題。
  3. 前面兩種狀況混合相互影響致使的,其實這裏也能夠理解爲同一種狀況,由於MyBatis歸根究竟是對MySQL驅動包進行了封裝。

當時項目中使用的mysql-connector-java版本爲8.0.18,並未升級爲當前的最新版本8.0.21,因此當時也有懷疑是低版本MySQL驅動包沒有兼容解析OffsetDateTime類型的參數。sql

簡析MyBatis的執行流程

MyBatis的源碼並不複雜,若是省去分析它的配置和映射文件解析模塊,一個查詢SQLSelectList)的執行流程大體以下:shell

固然,由於問題出如今參數解析部分,只須要關注StatementHandler的處理邏輯便可。StatementHandler的父類BaseStatementHandler構造函數中,初始化了ParameterHandlerResultSetHandler實例,提交到SimpleExecutor中的doQuery()方法中執行,使用了佔位符參數的查詢會經由doQuery()方法中的prepareStatement()方法而後調用PreparedStatementHandler#parameterize(),最終委託到DefaultParameterHandler#setParameters()方法進行參數設置,這個setParameters()方法會用到ParameterMappingTypeHandler數據庫

若是用到了內建的TypeHandler或者自定義的TypeHandler實現,同時出現了參數解析異常,那麼很大概率異常就是從DefaultParameterHandler#setParameters()方法中出現,這樣就能順藤摸瓜找到出現異常的TypeHandlermybatis

參數解析異常的根本緣由

本文前面提到的解析OffsetDateTime類型異常,實際上執行查詢的時候代碼會步入OffsetDateTimeTypeHandler,這裏對比一下3.4.53.5.5版本中MyBatis對應的OffsetDateTimeTypeHandler實現:app

發現了主要區別以下:

  • 3.4.5版本中,會把OffsetDateTime參數類型轉換爲Timestamp類型,再委託到PreparedStatement#setTimestamp()進行參數設置。

  • 3.5.5版本中,直接調用PreparedStatement#setObject()進行參數設置。

PreparedStatement#setTimestamp()是很早期的產物,這個方法是沒有任何問題的,3.4.5版本MyBatisOffsetDateTime類型兼容爲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的開發者應該不可能在這種關鍵而不復雜的問題上出現紕漏,因而花時間去看看這裏的代碼提交記錄:

這是Raupach2017-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.JdbcPreparedStatementDataType#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版本中的MyBatisOffsetDateTimeTypeHandler,而後經過配置直接覆蓋內置實現便可。

// 假設全類名爲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項目倉庫:

  • Githubhttps://github.com/zjcscut/spring-boot-guide/tree/master/ch9-mybatis-mysql

(本文完 c-2-d e-a-20200802 前段時間搬家帶寬一直出問題,斷更了接近一週)

相關文章
相關標籤/搜索