引言
在学习Mybatis源码的时候,会经常看到有一个以"plugin"命名的包,自然而然的我们会想到在搭建项目框架的时候使用到的配置标签。其实通过名字我们就能猜到它的作用就是给Mybatis框架做扩展的插件,例如本文将要介绍的PageInterceptor分页插件就是利用配置标签实现的分页功能。其实Mybatis插件的原理非常简单,看完这篇文章,你不仅能了解Mybatis插件的原理,也能自己实现一个插件,例如分页插件、慢sql统计插件等。
统一术语
PageHelper的使用
com.github.pagehelper.PageHelper 分页插件使用起来非常方便,分为以下三个步骤:
第一步,在项目的pom.xml文件里导入pagehelper的依赖。
xml
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper</artifactId>
<version>5.1.11</version>
</dependency>
第二步,在mybatis-config.xml配置文件里加入插件的配置。
xml
<plugins>
<plugin interceptor="com.github.pagehelper.PageInterceptor" />
</plugins>
第三步,在需要分页的mapper接口前调用PageHelper#startPage方法。因为PageInterceptor的分页参数传递靠的是ThreadLocal,所以为了避免不同的Page对象之间发生数据串台的情况,所以每次调用完成以后需要将Page对象关闭。
java
int pageNum = 1;
int pageSize = 10;
String orderBy = "id desc" ;
try (Page<User> page = PageHelper.startPage(pageNum, pageSize, orderBy)) {
UserMapper mapper = session.getMapper(UserMapper.class);
List<User> users = mapper.findPage();
}

回忆完PageInterceptor分页插件的使用方法以后,下面进入正题,我们进入Mybatis的世界看看它的内部是如何识别分页插件的,又是如何将分页参数拼接到sql末尾的?
建议点赞+收藏+关注,方便以后复习查阅。如需转载请注明文章作者及原地址。

总体流程
遍历拦截器链,利用拦截器生成目标对象的代理对象,达到增强目标对象的目的。
下面我会将代码的每一步都拿出来细讲。在此之前可以先看下图了解一下插件的实现流程,对插件的实现原理有一个整体的认知。

分页拦截器
首先,实现Mybatis的插件需要写一个plugin包下的Interceptor接口的实现类。Interceptor接口有三个方法,最重要的方法就是拦截方法intercept(Invocation invocation)。该方法会拦截目标对象的目标方法,实现某些业务逻辑,再调用目标对象的目标方法,以达到对目标方法增强的目的。这句话说的有点拗口,说人话就是只要实现了Interceptor接口,就能对Mybatis的组件方法进行拦截,并插入一些业务逻辑,如分页功能。这里Mybatis使用了JDK动态代理来实现,不明白什么是动态代理的朋友可以找下我另外一篇关于代理模式的文章,里面会详细介绍动态代理的原理。

intercept方法的参数是一个Invocation对象,该对象里含有三个属性:
通过实现intercept方法,并使用该方法的参数Invocation,就可以在intercept方法里得到目标对象及其方法和参数,这样就能在自定义拦截器里实现对目标对象的方法增强。

下图是分页拦截器的实现类PageInterceptor。可以看到绿色框圈起来的就是查询分页列表的代码入口。如果在某些情况下不需要分页,例如Page对象的pageSize传0,就会进入不分页的代码逻辑(蓝色框部分)。可以发现不分页的代码其实就是调用的Mybatis核心组件Executor的query()方法,也就是说代码进入了正常的查询逻辑。对Mybatis组件和流程原理感兴趣的朋友,也可以找一下我另外一篇相关的文章,本文由于篇幅的关系不做详细探讨。

现在我们进入分页代码逻辑(下图),看下PageInterceptor是如何分页的?看下图绿色框圈起来的pageBoundSql变量和蓝色框圈起来的boundSql变量,BoundSql是Mybatis用来描述动态执行sql的对象。这里的boundSql变量是没有分页语句的sql,而pageBoundSql变量是带分页语句的变量,pageBoundSql变量的分页语句是通过dialect#getPageSql()方法获取的,由于篇幅关系本文不具体介绍如何解析sql,有兴趣的朋友可以自己看源码,我们这里只需要知道PageHelper是通过在原始sql尾部添加limit语句的方式实现的分页就行了。

至此,我们可以初步判定,PageInterceptor在拦截方法里主要就是拦截了Executor的query()方法,并在需要分页时将BoundSql参数替换成了带有分页的sql变量。在这里,我们可以将PageInterceptor的拦截方法看作是Executor#query()方法的增强版,因为它不仅调用了Executor#query()方法,还增加了分页逻辑,并且查询了一次select count得到了数据总数,然后回写到Page对象。

注册插件
在Mybatis的配置文件mybatis-config.xml中添加标签,在interceptor属性里配置分页拦截器实现类的全限定名。

当我们需要使用Mybatis进行分页查询的时候,需要创建一个Mybatis的Api会话接口,所以需要创建一个SqlSession对象,而SqlSession是由工厂对象创建而来,在SqlSessionFactoryBuilder#build()这个方法里,会对Mybatis的xml配置文件进行解析,并最终构成一个Configuration对象,生成SqlSession对象需要它,应该说在Mbatis框架里Configuration对象无处不在。
java
@Test
public void main() throws IOException {
String resource = "mybatis-config.xml";
InputStream inputStream = Resources.getResourceAsStream(resource);
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
SqlSession session = sqlSessionFactory.openSession();
try {
try (Page<User> page = PageHelper.startPage(1, 5, "id desc")) {
UserMapper mapper = session.getMapper(UserMapper.class);
List<User> users = mapper.findPage();
System.out.println(users);
}
} finally {
session.close();
}
}
下图是SqlSessionFactoryBuilder调用XMLConfigBuilder#parse()方法解析xml配置文件。

进入XMLConfigBuilder#parse()方法,跳转到parseConfiguration()方法。

在XMLConfigBuilder#parseConfiguration()方法里可以发现一行代码,是用来解析plugins标签的。

继续跟踪代码,我们终于找到了一行代码,将拦截器添加到拦截器链中。拦截器从哪里来的呢?可以看到一个newInstance()语句,原来是通过配置文件里的拦截器实现类的全限定名实例化的。



时序图

至此,我们已经了解了拦截器如何实现、如何配置以及如何从配置里读取出来并添加到配置对象里去,那么问题来了,添加到Configuration对象以后的拦截器会在哪个地方,以何种方式调用呢?
增强目标对象
事实上,每当Mybatis要做增删改查操作的时候,都会调用Executor组件的接口。当我们在业务代码里使用Mybatis做分页查询的时候,必然会使用Executor接口,所以我们可以从Executor入手寻找答案。
Executor接口的创建是通过调用Configuration对象的newExecutor()方法得到的,我们看下这个方法是如何获取到Executor对象的。我们发现有一行代码里提到了拦截器链的调用,这里肯定暗藏玄机。

为什么在newExecutor()方法里,executor变量一开始已经被new了一次,最后还要再调用InterceptorChain#pluginAll(executor)方法得到一个executor呢?前后两个executor有什么不一样吗?
ini
...
executor = (Executor) interceptorChain.pluginAll(executor);
...
InterceptorChain#pluginAll(executor)方法遍历了拦截器链,并调用每个拦截器的plugin()方法,我们继续往下看。

拦截器的plugin()方法调用了Plugin的静态方法wrap(),并将目标对象(executor)和拦截器自己(pageInterceptor)传递给了wrap()方法,并且返回了一个Object类型的对象,这里我们这个返回对象的类型肯定是Executor。发现没有,这里将目标对象和代理对象(拦截器)联系起来了,答案就快要揭晓了!

进入wrap()方法,终于我们看到了return Proxy.newProxyInstance的代码,真相大白,原来返回的Executor对象是一个代理,是对Executor的增强,也就是说当要执行一个sql时,使用的Executor对象是Configuration#newExecutor()方法返回的代理对象,这个代理对象继承了Proxy,并且实现了Executor接口,所以在调用Executor接口的query()方法时,实际上是调用了Proxy这个父类的h变量的invoke()方法,这个h变量就是下图中的Plugin对象,因为Plugin是一个InvocationHandler接口。
以上的内容只要了解JDK动态代理知识的朋友都能看懂,关注我看我另外一篇文章帮你详细解答。


至此,我们终于得到了前面提到的一个问题的答案。添加到Configuration对象以后的拦截器会在哪个地方,以何种方式调用的?
在创建Executor对象的时候,通过调用Plugin#wrap()方法,运用JDK动态代理,对Executor对象进行了偷梁换柱,返回的是一个得到增强的代理对象,在执行sql的时候调用Executor接口的方法时,代理对象使用到了拦截器的intercept()方法。
目标对象和目标方法
现在,我还有一个疑问。还记得前文讲解分页拦截器的拦截方法时,提到了分页查询时是调用的Mybatis组件Executor的query()方法。但是我一直没有解释**拦截器是如何告诉Mybatis它想要拦截的目标对象是谁,对目标对象的哪些方法进行拦截?**答案就在PageInterceptor类上。

通过在PageInterceptor对象上配置mybatis的@Intercepts注解,告诉mybatis三个信息:
@Intercepts注解配置的意思是,在PageInterceptor拦截器里对目标对象Executor的query()方法进行拦截。因为Executor里的query方法是重载过的,所以有多个Signature。
目标方法增强
在PageInterceptor上配置了@Intercepts注解以后,Mybatis在哪里使用到了这些注解配置信息呢?
在注册插件的时候,调用Plugin#wrap()方法时就将PageInterceptor对象里的@Intercepts注解解析出来保存到一个signatureMap变量里,等到生成代理对象的时候,Plugin参数保存了signatureMap变量。

等到调用分页查询时,通过代理对象调用了Plugin#invoke()方法。如果目标对象的方法在signatureMap变量能找到,才会执行拦截器的拦截方法。

时序图
自定义慢SQL统计插件
慢SQL统计插件的实现非常简单。首先实现Interceptor接口,在@Intercepts注解配置目标对象为StatementHandler,并配置拦截方法为query|update|batch。

finally块里记录结束时间,并将执行sql语句和sql执行时长打印出来。
建议点赞+收藏+关注,方便以后复习查阅。如需转载请注明文章作者及原地址。
