Java自学者论坛

 找回密码
 立即注册

手机号码,快捷登录

恭喜Java自学者论坛(https://www.javazxz.com)已经为数万Java学习者服务超过8年了!积累会员资料超过10000G+
成为本站VIP会员,下载本站10000G+会员资源,会员资料板块,购买链接:点击进入购买VIP会员

JAVA高级面试进阶训练营视频教程

Java架构师系统进阶VIP课程

分布式高可用全栈开发微服务教程Go语言视频零基础入门到精通Java架构师3期(课件+源码)
Java开发全终端实战租房项目视频教程SpringBoot2.X入门到高级使用教程大数据培训第六期全套视频教程深度学习(CNN RNN GAN)算法原理Java亿级流量电商系统视频教程
互联网架构师视频教程年薪50万Spark2.0从入门到精通年薪50万!人工智能学习路线教程年薪50万大数据入门到精通学习路线年薪50万机器学习入门到精通教程
仿小米商城类app和小程序视频教程深度学习数据分析基础到实战最新黑马javaEE2.1就业课程从 0到JVM实战高手教程MySQL入门到精通教程
查看: 348|回复: 0

Mybatis 3.4.4 升级到3.4.5+版本导致读写操作的时候使用不同的TypeHandler导致异常的解决方案

[复制链接]
  • TA的每日心情
    奋斗
    2024-11-24 15:47
  • 签到天数: 804 天

    [LV.10]以坛为家III

    2053

    主题

    2111

    帖子

    72万

    积分

    管理员

    Rank: 9Rank: 9Rank: 9

    积分
    726782
    发表于 2021-6-29 03:51:21 | 显示全部楼层 |阅读模式

    项目背景

    项目中因需要保留时区信息, 前后台交互采用时间格式为标准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.

    哎...今天够累的,签到来了1...
    回复

    使用道具 举报

    您需要登录后才可以回帖 登录 | 立即注册

    本版积分规则

    QQ|手机版|小黑屋|Java自学者论坛 ( 声明:本站文章及资料整理自互联网,用于Java自学者交流学习使用,对资料版权不负任何法律责任,若有侵权请及时联系客服屏蔽删除 )

    GMT+8, 2025-2-1 15:56 , Processed in 0.055933 second(s), 27 queries .

    Powered by Discuz! X3.4

    Copyright © 2001-2021, Tencent Cloud.

    快速回复 返回顶部 返回列表