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

throwable發表於2020-08-02

背景

最近有一個資料統計服務需要升級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佔位符的情況):

  1. MyBatis解析OffsetDateTime型別方法引數的方法有版本相容問題。
  2. MySQL驅動包解析OffsetDateTime型別的引數有版本相容問題。
  3. 前面兩種情況混合相互影響導致的,其實這裡也可以理解為同一種情況,因為MyBatis歸根到底是對MySQL驅動包進行了封裝。

當時專案中使用的mysql-connector-java版本為8.0.18,並未升級為當前的最新版本8.0.21,所以當時也有懷疑是低版本MySQL驅動包沒有相容解析OffsetDateTime型別的引數。

簡析MyBatis的執行流程

MyBatis的原始碼並不複雜,如果省去分析它的配置和對映檔案解析模組,一個查詢SQLSelectList)的執行流程大致如下:

當然,因為問題出現在引數解析部分,只需要關注StatementHandler的處理邏輯即可。StatementHandler的父類BaseStatementHandler建構函式中,初始化了ParameterHandlerResultSetHandler例項,提交到SimpleExecutor中的doQuery()方法中執行,使用了佔位符引數的查詢會經由doQuery()方法中的prepareStatement()方法然後呼叫PreparedStatementHandler#parameterize(),最終委託到DefaultParameterHandler#setParameters()方法進行引數設定,這個setParameters()方法會用到ParameterMappingTypeHandler

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

引數解析異常的根本原因

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

發現了主要區別如下:

  • 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 前段時間搬家頻寬一直出問題,斷更了接近一週)

相關文章