java枚举触发了Mybatis Plus的BUG折腾了我三个小时,怀疑人生

本文涉及的产品
RDS MySQL Serverless 基础系列,0.5-2RCU 50GB
云数据库 RDS MySQL,集群系列 2核4GB
推荐场景:
搭建个人博客
云数据库 RDS MySQL,高可用系列 2核4GB
简介: java枚举触发了Mybatis Plus的BUG折腾了我三个小时,怀疑人生

问题

昨天用mybatis-plus写了一段crud,代码如下:

@Transactional
  @Override
  public boolean updateTaskStatus(Integer taskId, TaskStatusEnum taskStatusEnum) {
    // 查询任务
    Task task = taskMapper.selectById(taskId);
    if (Objects.isNull(task)) {
      throw new IllegalArgumentException("没有查询到任务!");
    }
    // 检查状态是否正常
    if (!task.getStatus().nextStatus().contains(taskStatusEnum)) {
      throw new IllegalStateException("不能修改当前任务的状态!");
    }
    // 状态正常就修改状态到下一个状态
    task.setStatus(taskStatusEnum);
    // 更新任务状态
    int result = taskMapper.updateById(task);
    return result > 0;
  }
复制代码

结果一直报错:

java.sql.SQLException: Incorrect integer value: 'COMPLETED' for column 'status' at row 1\n\tat com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:129)\n\tat com.mysql.cj.jdbc.exceptions.SQLExceptionsMapping.translateException(SQLExceptionsMapping.java:122)\n\tat com.mysql.cj.jdbc.ClientPreparedStatement.executeInternal(ClientPreparedStatement.java:916)\n\tat com.mysql.cj.jdbc.ClientPreparedStatement.execute(ClientPreparedStatement.java:354)\n\tat 
复制代码

这个报错的意思是说,我的数据表中对应的status表字段类型是integer value,但是传进来的值却是COMPLETED字符串;

通过不断地测试,发现该BUG出现的现象如下:

1.项目重启后,该报错出现在执行updateById()这一段代码上;

2.报错后如果继续调用该接口,selectById()将出现以下报错:

org.apache.ibatis.executor.result.ResultMapException: Error attempting to get column 'status' from result set.  Cause: java.lang.IllegalArgumentException: No enum constant com.example.awesomespring.enums.TaskStatusEnum.0\n\tat org.apache.ibatis.type.BaseTypeHandler.getResult(BaseTypeHandler.java:87)\n\tat org.apache.ibatis.executor.resultset.DefaultResultSetHandler.applyAutomaticMappings(DefaultResultSetHandler.java:561)\n\tat 
复制代码

提前申明一下,项目中已经配置了该枚举类型对应的TypeHandler;

排查过程

  • DEBUG自定义TypeHandler
    出现第一个报错的时候,第一时间想到的就是TaskStatusEnum枚举没有匹配到对应的TypeHandler,所以在自定义TypeHandler中打上断点,再次发起请求后,出现了第二个报错,并且该线程并没有进入TypeHandler的断点当中;此后无论请求多少次,始终在selectById()上报错;
    为此不得不重启项目后重新断点,重启项目后第一次请求,在selectById()后进入断点,成功拿到解析后的结果task;并在执行updateById()前后都未进入断点,此后无论如何请求都没有进入断点;
    根据上述现象,我有以下两个判断:

1.可能在selectById()执行过程中引入了变量导致updateById()没有找到对应的TypeHandler

2.可能是updateById()产生的错误影响了全局配置,导致后续无论如何都无法找到TypeHandler

  • 深入源码
    我们都知道,mybatis-plus也是基于mybatis实现的,所以mybatis的那一套理论我们还是用得上的;mybatis在处理参数和结果集的时候都需要通过TypeHandler来处理;
    mybatis-plus中,我们可以找到MybatisParameterHandler.setParameters()中的这一段代码:
TypeHandler typeHandler = parameterMapping.getTypeHandler();
复制代码
  • 通过Debug我们发现它最终拿到的是UnknownTypeHandler:

image.png

  • 查看TypeHandlerRegistry
    通过上述手段,我们发现mybatis-plus确实没有拿到正确的TypeHandler,这不得不让我们怀疑TypeHandler是否成功地注册到配置中了,随即我们在Debug变量表中展开Configuration对象,准备查看里面的TypeHandler:

image.png

  • 我们在处理完参数后再次查看TypeHandlerRegistry,发现该枚举对应的TypeHandler已经发生改变了:

image.png

  • 另外值得一提的是,另一个枚举类型对应的TypeHandler始终没有改变:

image.png

  • 对比枚举类型差异

发现两个枚举类型的不同表现后,我尝试对比一下两个枚举类型的差异:

TaskTypeEnum.java:

public enum TaskTypeEnum implements IEnum<Integer, String> {
  QUERY(1, "查询任务"),
  UPDATE(2, "更新任务");
  private final Integer code;
  private final String value;
  TaskTypeEnum(Integer code, String value) {
    this.code = code;
    this.value = value;
  }
  @Override
  public Integer getCode() {
    return this.code;
  }
  @Override
  public String getValue() {
    return this.value;
  }
}
复制代码

TaskStatusEnum.java:

public enum TaskStatusEnum implements IEnum<Integer, String> {
  START(0, "开始") {
    @Override
    public List<TaskStatusEnum> nextStatus() {
      return Arrays.asList(COMPLETED);
    }
  },
  COMPLETED(1, "完成") {
    @Override
    public List<TaskStatusEnum> nextStatus() {
      return Arrays.asList(END);
    }
  },
  END(2, "结束") {
    @Override
    public List<TaskStatusEnum> nextStatus() {
      return null;
    }
  };
  TaskStatusEnum(Integer code, String value) {
    this.code = code;
    this.value = value;
  }
  private final Integer code;
  private final String value;
  @Override
  public Integer getCode() {
    return this.code;
  }
  @Override
  public String getValue() {
    return this.value;
  }
  public abstract List<TaskStatusEnum> nextStatus();
}
复制代码

两者的不同点在于出问题的枚举有一个抽象方法,每个实例都要实现该抽象方法;为此我尝试把第二个枚举改造成和第一个枚举一样,删掉抽象方法后,重新调用接口,竟然真的成功了!

定位问题

对于这种莫名其妙的情况下就把问题解决了,我是不甘心的。当我准备找到问题根源重新DEBUG时,无意间我发现了一点小小的线索:

image.png

这个传进来的枚举类型是TaskStatusEnum$2.class,它应该是TaskStatusEnum.class;一开始我以为是spring mvc在做请求参数解析的时候做了一层包装,我尝试把代码改成这样:

task.setStatus(TaskStatusEnum.valueOf(taskStatusEnum.name()));
复制代码

结果发现我的猜测是错误的,类型依旧是TaskStatusEnum$2.class;只有当枚举中没有抽象方法时,类型才是正确的;

并且我们发现,TaskStatusEnum中所有的实例类型都不一样:

image.png

解释现象

为了能够了解这个现象出现的原因,我简单看了一下源码,大概过程如下:

private Map<JdbcType, TypeHandler<?>> getJdbcHandlerMap(Type type) {
    // 传进来的type为TaskStatusEnum$2.class,jdbcHandlerMap为null
    Map<JdbcType, TypeHandler<?>> jdbcHandlerMap = (Map)this.typeHandlerMap.get(type);
    if (jdbcHandlerMap != null) {
        return NULL_TYPE_HANDLER_MAP.equals(jdbcHandlerMap) ? null : jdbcHandlerMap;
    } else {
        if (type instanceof Class) {
            Class<?> clazz = (Class)type;
            // 判断是否时枚举类型
            if (Enum.class.isAssignableFrom(clazz)) {
                // TaskStatusEnum$2.class是匿名类,所以找到父类TaskStatusEnum.class
                Class<?> enumClass = clazz.isAnonymousClass() ? clazz.getSuperclass() : clazz;
                // 返回null,至于为啥直接返回null,一看代码便知
                jdbcHandlerMap = this.getJdbcHandlerMapForEnumInterfaces(enumClass, enumClass);
                if (jdbcHandlerMap == null) {
                    // 给这个类型TaskStatusEnum.class注册上默认的枚举类型处理器
                    this.register(enumClass, this.getInstance(enumClass, this.defaultEnumTypeHandler));
                    // 返回默认的枚举类型处理器EnumTypeHandler
                    return (Map)this.typeHandlerMap.get(enumClass);
                }
            } else {
                jdbcHandlerMap = this.getJdbcHandlerMapForSuperclass(clazz);
            }
        }
        this.typeHandlerMap.put(type, jdbcHandlerMap == null ? NULL_TYPE_HANDLER_MAP : jdbcHandlerMap);
        return jdbcHandlerMap;
    }
}
复制代码

我们可以简单总结一下:

1.mybatis-plus处理枚举类型参数时,是直接通过传进来的参数值对应的类型去TypeHandlerRegistry中查找对应的TypeHandler的;

2.当没有找到该匿名类型对应的TypeHandler时,获取了父类类型,但是getJdbcHandlerMapForEnumInterfaces()显然是从枚举中的接口去找对应的TypeHandler,这一步让这个匿名类枚举实例完美地错过了它的TypeHandler

3.最后一步是指定了默认的枚举类型处理器org.apache.ibatis.type.EnumTypeHandler,并且执行力register()操作,那么TypeHandlerRegistryTaskStatusEnum.class对应的TypeHandler被修改了;

根据上述总结,我们对于前面的问题就很好理解了,正是因为匿名枚举类型造成了TypeHandler被动态修改了,才导致了后面无论如何执行,都无法成功地执行selectById(),因为在结果集解析时,通过TaskStatusEnum.class找到的org.apache.ibatis.type.EnumTypeHandler无法构建TaskStatusEnum实例;

对比mybatis

当今天我希望通过mybatis复现该问题时,我发现mybatis完全没有问题,说明这个问题仅仅出现在mybatis-plus上面,完全不是mybatis的锅,也不是枚举的锅;

通过对比发现,mybatis在项目启动时,就已经把对应的实体类中属性字段类型和TypeHandler放进缓存中了,在SQL执行阶段,直接拿出对应的TypeHandler来处理参数值,它的参数解析是不依赖参数类型的;而Mybatis-plus是通过参数类型从TypeHandlerRegistry中取TypeHandler的,这就导致了获取到不正确的TypeHandler

Mybatisstatus字段对应的参数类型:

image.png

metaClass是已经解析好的实体类元数据,可以直接从里面获取对应的属性字段类型;

Mybatis-Plusstatus字段对应的数据类型:

image.png

mybatis-plus会将参数包装成ParamMap类型,导致返回的数据类型是Object.class,最后匿名枚举类型匹配不到TypeHandler,导致BUG出现;

如何避免

现在我们已经知道了导致这个问题的原因了,也就很容易就给出以下解决方案:

1.在使用枚举时,尽可能不要使用抽象方法,导致枚举实例都是匿名类型;这是代价最小的方案;

2.直接给父级接口配置TypeHandler;因为它找不到匿名类对应的TypeHandler就会找父级接口对应的TypeHandler;这个也算是比较好的解决方案了;

3.动态地给所有的匿名类型也配置上TypeHandler;代价也很小,调用TypeHandlerRegistry.register就可以;

4.将mybatis替换掉mybatis-plus;这个代价很大,意味着你的项目中要改很多代码以及调整相关配置



相关实践学习
如何快速连接云数据库RDS MySQL
本场景介绍如何通过阿里云数据管理服务DMS快速连接云数据库RDS MySQL,然后进行数据表的CRUD操作。
全面了解阿里云能为你做什么
阿里云在全球各地部署高效节能的绿色数据中心,利用清洁计算为万物互联的新世界提供源源不断的能源动力,目前开服的区域包括中国(华北、华东、华南、香港)、新加坡、美国(美东、美西)、欧洲、中东、澳大利亚、日本。目前阿里云的产品涵盖弹性计算、数据库、存储与CDN、分析与搜索、云通信、网络、管理与监控、应用服务、互联网中间件、移动服务、视频服务等。通过本课程,来了解阿里云能够为你的业务带来哪些帮助 &nbsp; &nbsp; 相关的阿里云产品:云服务器ECS 云服务器 ECS(Elastic Compute Service)是一种弹性可伸缩的计算服务,助您降低 IT 成本,提升运维效率,使您更专注于核心业务创新。产品详情: https://www.aliyun.com/product/ecs
相关文章
|
1天前
|
JSON 前端开发 Java
【Bug合集】——Java大小写引起传参失败,获取值为null的解决方案
类中成员变量命名问题引起传送json字符串,但是变量为null的情况做出解释,@Data注解(Spring自动生成的get和set方法)和@JsonProperty
|
1月前
|
安全 Java 测试技术
🎉Java零基础:全面解析枚举的强大功能
【10月更文挑战第19天】本文收录于「滚雪球学Java」专栏,专业攻坚指数级提升,希望能够助你一臂之力,帮你早日登顶实现财富自由🚀;同时,欢迎大家关注&&收藏&&订阅!持续更新中,up!up!up!!
121 60
|
29天前
|
Java
java小工具util系列4:基础工具代码(Msg、PageResult、Response、常量、枚举)
java小工具util系列4:基础工具代码(Msg、PageResult、Response、常量、枚举)
52 24
|
2月前
|
Java 数据库连接 Maven
mybatis使用一:springboot整合mybatis、mybatis generator,使用逆向工程生成java代码。
这篇文章介绍了如何在Spring Boot项目中整合MyBatis和MyBatis Generator,使用逆向工程来自动生成Java代码,包括实体类、Mapper文件和Example文件,以提高开发效率。
149 2
mybatis使用一:springboot整合mybatis、mybatis generator,使用逆向工程生成java代码。
|
2月前
|
搜索推荐 Java 数据库连接
Java|在 IDEA 里自动生成 MyBatis 模板代码
基于 MyBatis 开发的项目,新增数据库表以后,总是需要编写对应的 Entity、Mapper 和 Service 等等 Class 的代码,这些都是重复的工作,我们可以想一些办法来自动生成这些代码。
41 6
|
3月前
|
安全 Java 索引
Java——反射&枚举
本文介绍了Java反射机制及其应用,包括获取Class对象、构造方法、成员变量和成员方法。反射允许在运行时动态操作类和对象,例如创建对象、调用方法和访问字段。文章详细解释了不同方法的使用方式及其注意事项,并展示了如何通过反射获取类的各种信息。此外,还介绍了枚举类型的特点和使用方法,包括枚举的构造方法及其在反射中的特殊处理。
78 9
Java——反射&枚举
|
3月前
|
缓存 前端开发 Java
【Java面试题汇总】Spring,SpringBoot,SpringMVC,Mybatis,JavaWeb篇(2023版)
Soring Boot的起步依赖、启动流程、自动装配、常用的注解、Spring MVC的执行流程、对MVC的理解、RestFull风格、为什么service层要写接口、MyBatis的缓存机制、$和#有什么区别、resultType和resultMap区别、cookie和session的区别是什么?session的工作原理
|
3月前
|
Java
java小工具util系列4:基础工具代码(Msg、PageResult、Response、常量、枚举)
java小工具util系列4:基础工具代码(Msg、PageResult、Response、常量、枚举)
94 5
|
3月前
|
安全 Java 开发者
Java 枚举(enum)详解
Java 中的枚举(`enum`)是一种特殊的数据类型,用于定义一组固定的常量,提升代码的类型安全性和可读性。枚举使用 `enum` 关键字定义,支持方法和构造函数,具有类型安全、单例、自动序列化等特点,并且可以遍历和用于 `switch` 语句中。实际应用包括状态机、指令集、类型标识等场景。枚举使代码更加清晰易维护。
255 1
|
3月前
|
SQL Java 数据库连接
【Java笔记+踩坑】MyBatisPlus基础
MyBatisPlus简介、标准数据层开发CRUD、业务层继承IService、ServiceImpl、条件查询、LambdaQueryWrapper、id生成策略、逻辑删除、乐观锁@Version、代码生成器、ActiveRecord
【Java笔记+踩坑】MyBatisPlus基础