从一个随机出现的错误开读Mybatis源码

2022/06/11

从一个随机出现的错误开读Mybatis源码

关于Mapper方法重载

对于两个拥有相同方法名,但入参不同的方法,Mybatis会如何处理?

@Select("sql xxx")
Long statisticTotal(@Param("beginTime") String beginTime, @Param("endTime") String endTime);

@Select("sql xxx")
Long statisticTotal(@Param("name") String name);
  1. 如果采用传统的 XML,XML 必须指定一个id,当在启动时,就会报这个 id 冲突了,这个问题在之前遇到过。
  2. 但如果不用 XML,用的是注解呢? 思考这个问题是处于是想调用两个重载的方法中的第一个双参方法,却发现偶尔成功,偶尔报错: Paramter not found,没有发现这个报错的规律。

开读

入口

从一些地方了解到,Mybatis 其实就是在运行时,对于每个 Mapper 的方法调用最终都会被一个代理所捕获:

// 在Mybatis中是在org.apache.ibatis.binding.MapperProxy
// MyBatisPlus中是在com.baomidou.mybatisplus.core.override.MybatisMapperProxy
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    try {
        if (Object.class.equals(method.getDeclaringClass())) {
            return method.invoke(this, args);
        } else {
            return cachedInvoker(method).invoke(proxy, method, args, sqlSession);
        }
    } catch (Throwable t) {
        throw ExceptionUtil.unwrapThrowable(t);
    }
}

在 cachedInvoker 方法中,其会将接口的方法进行包装为 DefaultMethodInvoker 或者 PlainMethodInvoker:

return methodCache.computeIfAbsent(method, m -> {
  if (m.isDefault()) {
    try {
      if (privateLookupInMethod == null) {
        return new DefaultMethodInvoker(getMethodHandleJava8(method));
      } else {
        return new DefaultMethodInvoker(getMethodHandleJava9(method));
      }
    } catch (IllegalAccessException | InstantiationException | InvocationTargetException
        | NoSuchMethodException e) {
      throw new RuntimeException(e);
    }
  } else {
    return new PlainMethodInvoker(new MapperMethod(mapperInterface, method, sqlSession.getConfiguration()));
  }
});

其中 DefaultMethodInvoker 是为了实现默认方法的调用而实现的,重点还是 PlainMethodInvoker,其创建时又需要传递一个 MapperMethod,这个 MapperMethod 就是在 cachedInvoker 后 invoke 的东西,在 MyBatisPlus 中这个类是 MybatisMapperMethod。

方法转为命令表示

在 MapperMethod 创建时,会将接口的方法转为一条命令:

this.command = new MapperMethod.SqlCommand(config, mapperInterface, method);

其内部就是根据 Mapper 接口名称以及方法在配置中寻找对应的 Statement,并且在 resolveMappedStatement 方法中,它生成了一个 statementId 就是用来寻找 Statement:

// MapperMethod
String statementId = mapperInterface.getName() + "." + methodName;

由此可见,参数并不构成这个唯一ID,至此 Command 的内容就差不多清楚了 而且这个 statementId 就是command 的name:

// MapperMethod.SqlCommand
name = ms.getId();
type = ms.getSqlCommandType();

命令执行

回到 MapperMethod 的 execute(MyBatisPlus中是invoke) 代理捕获调用后来到这里 根据先前的 command 的name 去调用sqlSession

而 sqlSession 则根据这个 name 去查找 MappedStatement,再根据 MappedStatement 以及传递过来的参数构造SQL语句,最后发到数据库去查询

// org.apache.ibatis.session.defaults.DefaultSqlSession
MappedStatement ms = configuration.getMappedStatement(statement);
return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER);

// org.apache.ibatis.executor.BaseExecutor
BoundSql boundSql = ms.getBoundSql(parameter);
CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
return query(ms, parameter, rowBounds, resultHandler, key, boundSql);

猜测

那么最后还有一个问题,就是两个同名的方法存在时,我去调用其中一个,却时好时坏,我的猜测是这个 MappedStatement 的加载顺序是不固定的,相同 id 的 Statement 会导致后面扫描到的覆盖之前的,但如果这样的话,结果应该是确定的,为什么有时报错有时却不报错?

验证

但是在刚才的调用链路中似乎并没有发现是如何加载 Statement 的,于是换个思路,从 SpringBoot 的自动装配来着手,在 MybatisPlus starter 的 jar 包下,有个 spring.factories 的文件 里面记录了要自动装配哪些类。

当然就发现了 MybatisPlusAutoConfiguration,里面有个内部类:AutoConfiguredMapperScannerRegistrar 看名字就是这个了

这个类声明一大堆对Mapper的描述为BeanDefinition 并交由 MapperScannerConfigurer 来进行扫描,经过一顿扫描,但在这里没怎么读懂把 Configuration 注入到 Spring 的,我猜大概是利用了 Spring 的一些机制,最后是会调用到:

configuration.addMapper(this.mapperInterface);

最后来到MapperRegistry (MyBatisMapperRegistry),这里会对每个Mapper接口的方法进行处理:

// addMapper
MybatisMapperAnnotationBuilder parser = new MybatisMapperAnnotationBuilder(config, type);
parser.parse();
// 对Mapper接口的每个方法做处理
for (Method method : type.getMethods()) {
  if (!canHaveStatement(method)) {
      continue;
  }
  if (getAnnotationWrapper(method, false, Select.class, SelectProvider.class).isPresent()
      && method.getAnnotation(ResultMap.class) == null) {
      parseResultMap(method);
  }
  try {
      // TODO 加入 注解过滤缓存
      InterceptorIgnoreHelper.initSqlParserInfoCache(cache, mapperName, method);
      parseStatement(method);
  } catch (IncompleteElementException e) {
      // TODO 使用 MybatisMethodResolver 而不是 MethodResolver
      configuration.addIncompleteMethod(new MybatisMethodResolver(this, method));
  }
}

结论

重点就在于 type.getMethods 这个地方,随着我每次启动JVM,每次获取的 methods 返回的数组顺序是不一样的,这就导致我之前那个时好时坏的问题,后面的相同的 statementId 覆盖了之前的,但是谁先谁后并不是一个确定性的行为,Class.getMethods 的文档其实也说明了:

// The elements in the returned array are not sorted and are not in any particular order
// 返回的元素没有特定顺序

调试直到这里,也是印证了我的猜测,不过问题不出在MyBatis,而是出在JDK的反射机制上

Post Directory