MyBatis 拦截器:引入分页插件导致自定义插件失效“之密”

1,686 阅读7分钟

MyBatis的拦截器采用责任链设计模式,多个拦截器之间的责任链是通过动态代理组织的。我们一般都会在拦截器中的intercept方法中往往会有invocation.proceed()语句,其作用是将拦截器责任链向后传递,本质上便是动态代理的invoke

前言

在日常开发中,为了能使数据进行分页。通常会向Spring容器中注入MyBatis-plus的分页PaginationInnerInterceptor拦截器。此时码逻辑大致如下:

@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
    // 添加分页插件
    interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
    return interceptor;
}

其原理为将PaginationInnerInterceptor加入MybatisPlusInterceptor的内部拦截链中,并通过@BeanMybatisPlusInterceptor注入到Mybatis的拦截器中,从而在Sql执行时实现对数据的分页操作。

如果对MybatisPlusInterceptor插件原理和SpringBean对象的初始逻辑掌握不清晰的的话,很可能导致向Spring容器中注入的自定义的Mybatis插件不生效。

问题复盘

日常开发中,对于常用的分页插件外,对于项目我们通常会自定义一些Mybatis插件,从而实现对Sql的打印、监控、改写等。

为了复现Mybatis-plus分页插件所导致的问题,此处我们首先自定义一个简单的Mybatis的插件,其逻辑也很简单,仅是打印一段enter SqlInterceptor 其主要作用在于验证我们的拦截器是否执行。

@Intercepts({
   @Signature(type = Executor.class, method = "query",
           args = {MappedStatement.class, Object.class,
                  RowBounds.class, ResultHandler.class})
})
@Slf4j
public class SqlInterceptor implements Interceptor {


    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        log.info("enter SqlInterceptor......");
        return invocation.proceed();
    }

上述代码中,我们自定义一个SqlInterceptor的插件,接下来我们将其与Mybatis-plus的分页插件一同注入到我们的Spring容器中。

@Configuration
public class MybatisConfig {

    @Bean
    public SqlInterceptor sqlInterceptor() {
        return  new SqlInterceptor();
    }


    /**
     * 配置 MyBatis-Plus 分页插件
     * @return MybatisPlusInterceptor 包含分页插件的拦截器实例
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 添加分页插件
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
        return interceptor;
    }
    
}

通过上述操作,此时在Spring容器中存在两个Mybaits的插件,一个是我们自定义的SqlInterceptor另一个则是Mybatis-plus的分页插件。按照我们对Mybaits的认识,在执行sql时,上述插件应该可以顺利执行,即程序代码中既可以顺利实现数据分页,同时也能在控制台看到"enter SqlInterceptor......"的提示信息。

为此,我们构建一个简单的控制器,用以检验Mybaits插件的生效情况。

@GetMapping("t2")
public IPage<User> t2(Integer current ,Integer size){
    IPage<User> page = userMapper.selectPage(new Page<>(current,size), null);
    return page;
}

代码执行结果如下:

image.png

image.png

通过上述执行结果,不难发现在程序中数据可顺利实现分页,但却并未看到我们自定义的SqlInterceptor的执行。换言之,我们自定义的SqlInterceptor并未执行。

原因定位

对于这类Bean不生效的例子,我们首先应该想到的原因应该是Bean是否注入。换言之,我们所需要的Bean是否注入到Spring容器中。

回到我们本文的例子,我们在容器Bean注入时,我们自定义的SqlInterceptorMybatis-plus提供的MybatisPlusInterceptor采用了相同的注入方式,同时我们注入注入的分页插件已经生效,显然相同代码不会出现一个Bean成功注入,另一个Bean注入失败的情况,看到此可能还是会有人持有怀疑精神。针对质疑,事实远比无依据的猜测更具说服力。

那面对Mybatis茫茫多的代码,又该在何处进行断点调试进行验证呢?众所众知,当Mybatis内部在初始化 SqlSessionFactory 时,MyBatis 会读取并解析配置文件。在 XMLConfigBuilder 类的 parse 方法里,会调用 parseConfiguration 方法解析配置文件中的各个部分,其中就包含对插件配置的解析。具体逻辑如下:

public Configuration parse() {
    if (parsed) {
        throw new BuilderException("Each XMLConfigBuilder can only be used once.");
    }
    parsed = true;
    parseConfiguration(parser.evalNode("/configuration"));
    return configuration;
}

private void parseConfiguration(XNode root) {
    try {
        // 其他配置解析...
        pluginElement(root.evalNode("plugins"));
        // 其他配置解析...
    } catch (Exception e) {
        throw new BuilderException("Error parsing SQL Mapper Configuration. Cause: " + e, e);
    }
}

进一步来看,pluginElement 方法的作用是解析 <plugins> 标签下的所有 <plugin> 标签,为每个插件创建实例并且添加到 InterceptorChain 中。

private void pluginElement(XNode parent) throws Exception {
    if (parent != null) {
        for (XNode child : parent.getChildren()) {
            String interceptor = child.getStringAttribute("interceptor");
            Properties properties = child.getChildrenAsProperties();
            Interceptor interceptorInstance = (Interceptor) resolveClass(interceptor).getDeclaredConstructor().newInstance();
            interceptorInstance.setProperties(properties);
            configuration.addInterceptor(interceptorInstance);
        }
    }
}

而 InterceptorChain 信息则被维护至Configuration之中,同时ConfigurationSqlSessionFactory所持有。所以,如果要找寻Mybatis中插件的初始化逻辑,我们首先应该找到SqlSessionFactory的初始化位置,方能顺藤摸瓜的找到Interceptor的注入的问题。

对于SpringBoot整合Mybatis-plus的项目而言,其通常会在 MybatisPlusAutoConfiguration的配置类中完成Mybatis相关所需Bean的注入,具体来看,有关SqlSessionFactory的构建逻辑如下:

@Bean
@ConditionalOnMissingBean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
    // TODO 使用 MybatisSqlSessionFactoryBean 而不是 SqlSessionFactoryBean
    MybatisSqlSessionFactoryBean factory = new MybatisSqlSessionFactoryBean();
    // .... 省略相关无关代码
    // 添加拦截器
    if (!ObjectUtils.isEmpty(this.interceptors)) {
        factory.setPlugins(this.interceptors);
    }
   
   // .... 省略相关无关代码
}

可以看到Mybatis-plus在向容器中添加SqlSessionFactory中注入时,会将容器中全部的插件Interceptor保存到SqlSessionFactory之中,所以我们完全可以在!ObjectUtils.isEmpty(this.interceptors)此处添加断点,以确定自定义的SqlInterceptor是否注入容器。

image.png

通过图示,可以看到自定义SqlInterceptor确实注入到容器中,既然SqlInterceptor注入到容器中,为什么我们的SqlInterceptor没能顺利执行呢?

Mybatis插件执行逻辑

事实上,如果要搞清楚SqlInterceptor为什么没能执行,我们需要对Mybatis的插件的执行顺序有一定的了解。

MyBatis 里,拦截器的执行顺序和注册顺序是相关的,其在实际拦截逻辑执行时会形成类似洋葱模型的嵌套结构。例如,

<plugins>
    <plugin interceptor="com.example.FirstInterceptor"/>
    <plugin interceptor="com.example.SecondInterceptor"/>
</plugins>

在上述代码中我们先后定义了SecondInterceptorFirstInterceptor两个拦截器,而在Mybaits内部构建代理时,其逻辑大致如下:

  • 构建调用 FirstInterceptor 的 plugin 方法,对目标对象进行第一次包装。
  • 调用 SecondInterceptor 的 plugin 方法,对已经被 FirstInterceptor 包装过的对象进行第二次包装。内部逻辑具体如下所示:

image.png

换言之,在执行器执行Sql时,会按照 SecondInterceptor>FirstInterceptor>Executor>FirstInterceptor>SecondInterceptor 的顺序去执行的。而插件间的执行的传递则主要通过invocation.proceed()语句来进行向下传递。具体到上述例子,拦截器执行顺序如下:

  • 首先,调用 SecondInterceptor 的 intercept 方法,此时 SecondInterceptor 的 intercept 方法开始执行,但在执行到调用下一个拦截器或者目标方法之前,不会结束。
  • 然后,进入 FirstInterceptor 的 intercept 方法,FirstInterceptor 的 intercept 方法开始执行

自定义插件失效原因分析

回到我们本文主题,那为什么我们注册的SqlInterceptor会失效呢?通过前面对Mybatis插件执行顺序的介绍。我们知道对于Mybatis中的插件而言,插件间的调用通过invocation.proceed()来完成调用,且插件执行顺序其实和插件注册顺序相悖。 在结合我们之前看到的插件读取顺序,可以知道在程序内部,我们插件的执行应该为 MybatisPlusInterceptor -> SqlInterceptor

至此,我们自定义插件 SqlInterceptor失效的原因其实已经呼之欲出了,其无非就是MybatisPlusInterceptor 内部未继续执行invocation.proceed()来调用我们的自定义插件SqlInterceptor

为了验证我们的猜想,我们不妨在MybatisPlusInterceptor 进行断点观察:

image.png

可以看到,在MybatisPlusInterceptor 内部在执行插件时,其实是不会执行到 invocation.proceed()的,这也就是引入MybatisPlusInterceptor 导致自定义插件失效的罪魁祸首。

解决之道

对于引入Mybaits-plus导致自定义插件失效的解决方法其实有很多种,最简单的就是调换配置类中插件注入顺序。例如,如下代码

@Configuration
public class MybatisConfig {

  

    /**
     * 配置 MyBatis-Plus 分页插件
     * @return MybatisPlusInterceptor 包含分页插件的拦截器实例
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 添加分页插件
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
        return interceptor;
    }
    
      @Bean
    public SqlInterceptor sqlInterceptor() {
        return  new SqlInterceptor();
    }

    
}

或者通过ConfigurationCustomizer 来指定拦截器顺序。

 @Bean
    public ConfigurationCustomizer configurationCustomizer() {
        return configuration -> {
            MyInterceptor myInterceptor = new MyInterceptor();
            myInterceptor.setProperties(myInterceptorProperties.getProperties());
            configuration.addInterceptor(myInterceptor);
        };
    }

在这还可以通过@Order注解或者BeanDefinitionRegistryPostProcessor来控制Mybaits自定义拦截器在容器中的注入顺序。

总之,解决注入Mybaits-plus分之导致自定义失效的方式有很多种,但其核心逻辑完全在于控制Mybatis-Plus插件先与我们注解进行注入。当然,如果自定义拦截器切入的拦截点与Mybaits-plus插件不一样,则完全不用担心此问题插件不生效的问题~~~