1. 程式人生 > >Mybatis 3.4.4 升級到3.4.5+版本導致讀寫操作的時候使用不同的TypeHandler的解決方案

Mybatis 3.4.4 升級到3.4.5+版本導致讀寫操作的時候使用不同的TypeHandler的解決方案

專案背景

專案中因需要保留時區資訊, 前後臺互動採用時間格式為標準ISO8601格式時間, 例如: 2018-11-11T11:48:23.168+08:00,
資料庫使用VARCHAR儲存. 某日, 系統寫入資料依然正常, 但是系統查詢突然全部拋異常:

Caused by: java.time.format.DateTimeParseException: Text '2018-11-10 03:11:11.0' could not be parsed at index 10
    at java.time.format.DateTimeFormatter.parseResolved0(DateTimeFormatter.java:1949)
    at java.time.format.DateTimeFormatter.parse(DateTimeFormatter.java:1851)
    at java.time.OffsetDateTime.parse(OffsetDateTime.java:402)
    at java.time.OffsetDateTime.parse(OffsetDateTime.java:387)
    at com.example.demo.mapper.typehandler.OffsetDateTimeTypeHandler.getNullableResult(OffsetDateTimeTypeHandler.java:34)
    at com.example.demo.mapper.typehandler.OffsetDateTimeTypeHandler.getNullableResult(OffsetDateTimeTypeHandler.java:13)
    at org.apache.ibatis.type.BaseTypeHandler.getResult(BaseTypeHandler.java:66)
    ... 57 more

建表sql

create table task(
  task_id varchar(32) primary key ,
  create_at varchar(32) not null
);

entity

@Data
@EqualsAndHashCode(of = "taskId")
public class Task {

    private String taskId;

    private OffsetDateTime createAt;
}

插入sql xml片段:

<insert id="saveTask" parameterType="com.example.demo.entity.Task">
        insert into task(task_id, create_at) values (#{taskId}, #{createAt})
</insert>

查詢sql xml片段

<select id="getTaskById" resultType="com.example.demo.entity.Task">
        select task_id, create_at from task where task_id = #{taskId}
</select>

自定義TypeHandler

@MappedTypes(OffsetDateTime.class)
@MappedJdbcTypes(value = JdbcType.VARCHAR)
public class OffsetDateTimeTypeHandler extends BaseTypeHandler<OffsetDateTime> {

    private final DateTimeFormatter formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME;

    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, OffsetDateTime parameter, JdbcType jdbcType) throws SQLException {
        if (parameter == null){
            ps.setNull(i, Types.VARCHAR);
        }else {
            ps.setString(i, formatter.format(parameter));
        }
    }

    @Override
    public OffsetDateTime getNullableResult(ResultSet rs, String columnName) throws SQLException {
        String text = rs.getString(columnName);
        if (text == null){
            return null;
        }
        return OffsetDateTime.parse(text);
    }

    @Override
    public OffsetDateTime getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        String text = rs.getString(columnIndex);
        if (text == null){
            return null;
        }
        return OffsetDateTime.parse(text);
    }

    @Override
    public OffsetDateTime getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        String text = cs.getString(columnIndex);
        if (text == null){
            return null;
        }
        return OffsetDateTime.parse(text);
    }
}

從異常資訊可知: 是因為 '2018-11-10 03:11:11.0' 字串轉換為 OffsetDateTime出現了錯誤.

解決方案

  • 第一種: 降低Mybatis版本至3.4.4;
  • 第二種: 在插入sql中指定jdbcType或者指定typehandler(不推薦,因為這樣需要修改的地方太多);
    例如, 修改插入sql片段如下:
<insert id="saveTask" parameterType="com.example.demo.entity.Task">
        insert into task(task_id, create_at) values(#{taskId}, #{createAt, jdbcType = VARCHAR})
</insert>
或者:    
<insert id="saveTask" parameterType="com.example.demo.entity.Task">
        insert into task(task_id, create_at) values(#{taskId}, #{createAt, typeHandler = com.example.demo.mapper.typehandler.OffsetDateTimeTypeHandler})
</insert>
  • 第三種: 修改自定義TypeHandler的@MappedJdbcTypes(推薦)

    由@MappedJdbcTypes(value = JdbcType.VARCHAR)  ---> @MappedJdbcTypes(value = JdbcType.VARCHAR, includeNullJdbcType = true)

或者將自定義的TypeHandler上的 @MappedJdbcTypes(value = JdbcType.VARCHAR)去掉

排查過程

  • 檢視資料庫表資料, 發現task表資料如下:

      +-----------+-----------------------+
      | task_id   | create_at             |
      +-----------+-----------------------+
      | 123456789 | 2018-11-10 03:11:11.0 |
      +-----------+-----------------------+
  • 毫無疑問, 寫入資料庫的格式發生了改變, 於是開始懷疑有人改動程式碼,
    接著查詢git提交記錄, 發現pom.xml檔案的依賴發生了改動, 其他原始碼均無變動, 改動為:

      <dependency>
          <groupId>org.mybatis.spring.boot</groupId>
          <artifactId>mybatis-spring-boot-starter</artifactId>
          <version>1.3.0</version>
      </dependency>
      升級到:
      <dependency>
          <groupId>org.mybatis.spring.boot</groupId>
          <artifactId>mybatis-spring-boot-starter</artifactId>
          <version>1.3.1</version>
      </dependency>     
    
      對應Mybatis版本由3.4.4升級到3.4.5   
  • 於是開始懷疑這個問題是由於Mybatis版本升級帶來的, 經檢視原始碼發現,mybatis 3.4.5釋出的版本里面, 內建了jsr310時間型別的TypeHandler.
    在org.apache.ibatis.type.TypeHandlerRegistry新增了這樣一段程式碼:

      this.register(Instant.class, InstantTypeHandler.class);
      this.register(LocalDateTime.class, LocalDateTimeTypeHandler.class);
      this.register(LocalDate.class, LocalDateTypeHandler.class);
      this.register(LocalTime.class, LocalTimeTypeHandler.class);
      this.register(OffsetDateTime.class, OffsetDateTimeTypeHandler.class);
      this.register(OffsetTime.class, OffsetTimeTypeHandler.class);
      this.register(ZonedDateTime.class, ZonedDateTimeTypeHandler.class);
      this.register(Month.class, MonthTypeHandler.class);
      this.register(Year.class, YearTypeHandler.class);
      this.register(YearMonth.class, YearMonthTypeHandler.class);
      this.register(JapaneseDate.class, JapaneseDateTypeHandler.class);
    這也就對應了我們第一個解決方案, 降低Mybatis版本
  • 繼續debug發現, Mybatis中TypeHandler是使用一個雙層Map儲存的:

      private final Map<Type, Map<JdbcType, TypeHandler<?>>> TYPE_HANDLER_MAP = new ConcurrentHashMap<Type, Map<JdbcType, TypeHandler<?>>>();

再檢視註冊TypeHandler的核心程式碼如下:

    private <T> void register(Type javaType, TypeHandler<? extends T> typeHandler) {
        MappedJdbcTypes mappedJdbcTypes = typeHandler.getClass().getAnnotation(MappedJdbcTypes.class);
        if (mappedJdbcTypes != null) {
          for (JdbcType handledJdbcType : mappedJdbcTypes.value()) {
            register(javaType, handledJdbcType, typeHandler);
          }
          if (mappedJdbcTypes.includeNullJdbcType()) {
            register(javaType, null, typeHandler);
          }
        } else {
          register(javaType, null, typeHandler);
        }
      }

先註冊的是Mybatis提供的org.apache.ibatis.type.OffsetDateTimeHandler, 後註冊的是我們自定義的OffsetDateTimeHandler.

兩個Type的註冊資訊中, javaType都一樣, 區別在於, Mybatis提供的TypeHandler註冊資訊中二級key(JdbcType)為null, 而我們自定義的TypeHandler二級key為
JdbcType.VARCHAR, 而我們的插入sql片段中,對於jdbcType未指定,預設值也就是null.

所以在寫入的時候, 使用的是Mybatis提供的TypeHandler.

通過註冊TypeHandler原始碼,我們發現, 無論是去掉自定義TypeHandler上的@MappedJdbcTypes還是設定這個註解的includeNullJdbcType = true, 都可
以在註冊我們自定義的TypeHandler的時候, 替換掉Mybatis提供的TypeHandler.

  • 繼續debug, 我們現在還剩下一個疑問, 那為什麼在查詢的時使用的又是我們自定義的OffsetDateTimeHandler呢?
    這個我們分兩點來看:

    5.1. 如何確定jdbcType?

    5.2. 如何確定javaType?

先看第一個問題: 如何確定jdbcType?
在PreparedStatementHandler查詢到如下程式碼:

    public <E> List<E> query(Statement statement, ResultHandler resultHandler) throws SQLException {
        PreparedStatement ps = (PreparedStatement) statement;
        ps.execute();
        return resultSetHandler.<E> handleResultSets(ps);
      }

但是這個就是mybatis的邊界了,再繼續深入可以檢視ps.execute具體在MySql的驅動中如何實現,

現在只知道結果就是mysql提供的驅動包會將查詢結果集包裝成一個ResultSet具體實現是(com.mysql.cj.jdbc.result.ResultSet),
然後通過ResultSet#getMetaData這個介面就可以拿到每一列的sqlType(sqlType和JdbcType是一一對應的).

再看第二個問題: 如何確定javaType?
就以當前我們自己沒有定義任何resultMap來分析一下, 核心程式碼在DefaultResultSetHandler, 如下:

    private List<UnMappedColumnAutoMapping> createAutomaticMappings(ResultSetWrapper rsw, ResultMap resultMap, MetaObject metaObject, String columnPrefix) throws SQLException {
        final String mapKey = resultMap.getId() + ":" + columnPrefix;
        List<UnMappedColumnAutoMapping> autoMapping = autoMappingsCache.get(mapKey);
        if (autoMapping == null) {
          autoMapping = new ArrayList<UnMappedColumnAutoMapping>();
          final List<String> unmappedColumnNames = rsw.getUnmappedColumnNames(resultMap, columnPrefix);
          for (String columnName : unmappedColumnNames) {
            String propertyName = columnName;
            if (columnPrefix != null && !columnPrefix.isEmpty()) {
              // When columnPrefix is specified,
              // ignore columns without the prefix.
              if (columnName.toUpperCase(Locale.ENGLISH).startsWith(columnPrefix)) {
                propertyName = columnName.substring(columnPrefix.length());
              } else {
                continue;
              }
            }
            final String property = metaObject.findProperty(propertyName, configuration.isMapUnderscoreToCamelCase());
            if (property != null && metaObject.hasSetter(property)) {
              if (resultMap.getMappedProperties().contains(property)) {
                continue;
              }
              final Class<?> propertyType = metaObject.getSetterType(property);
              if (typeHandlerRegistry.hasTypeHandler(propertyType, rsw.getJdbcType(columnName))) {
                final TypeHandler<?> typeHandler = rsw.getTypeHandler(propertyType, columnName);
                autoMapping.add(new UnMappedColumnAutoMapping(columnName, property, typeHandler, propertyType.isPrimitive()));
              } else {
                configuration.getAutoMappingUnknownColumnBehavior()
                    .doAction(mappedStatement, columnName, property, propertyType);
              }
            } else {
              configuration.getAutoMappingUnknownColumnBehavior()
                  .doAction(mappedStatement, columnName, (property != null) ? property : propertyName, null);
            }
          }
          autoMappingsCache.put(mapKey, autoMapping);
        }
        return autoMapping;
      }

大概我們可以看出基本流程就是:
column_name -----> property ----> setterMethod ----> propertyType ----> TypeHandler

例如: create_at 通過在mybatis-config.xml中定義的

    <setting name="mapUnderscoreToCamelCase" value="true"/>
    

得到property為 'createAt', 然後結合ResultType找到createAt的set方法, 找到set方法的引數型別為OffsetDateTime,
也就是 javaType = OffsetDateTime.class, 再結合之前的jdbcType從TypeHandlerRegistry中查詢得到TypeHandler為我們自定義的OffsetDateTimeTypeHandler.