⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 juejin.cn/post/7173449167618965534 「梦想实现家_Z」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注**微信公众号:【芋道源码】**有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言将得到认真回复。甚至不知道如何读源码也可以请教噢
  4. 新的源码解析文章实时收到通知。每周更新一篇左右
  5. 认真的源码交流微信群。

问题

昨天用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
at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:129)
at com.mysql.cj.jdbc.exceptions.SQLExceptionsMapping.translateException(SQLExceptionsMapping.java:122)
at com.mysql.cj.jdbc.ClientPreparedStatement.executeInternal(ClientPreparedStatement.java:916)
at com.mysql.cj.jdbc.ClientPreparedStatement.execute(ClientPreparedStatement.java:354)
at

这个报错的意思是说,我的数据表中对应的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
at org.apache.ibatis.type.BaseTypeHandler.getResult(BaseTypeHandler.java:87)
at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.applyAutomaticMappings(DefaultResultSetHandler.java:561)
at

提前申明一下,项目中已经配置了该枚举类型对应的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:

    UnknownTypeHandler

  • 查看TypeHandlerRegistry

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

    TypeHandler

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

    TypeHandlerRegistry

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

    TypeHandler

  • 对比枚举类型差异

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

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时,无意间我发现了一点小小的线索:

定位问题

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

task.setStatus(TaskStatusEnum.valueOf(taskStatusEnum.name()));

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

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

TaskStatusEnum

解释现象

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

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字段对应的参数类型:

参数类型

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

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

数据类型

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

如何避免

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

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

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

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

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

文章目录
  1. 1. 问题
  2. 2. 排查过程
  3. 3. 定位问题
  4. 4. 解释现象
  5. 5. 对比mybatis
  6. 6. 如何避免