最近工作中遇到了一些sql语句写法上的问题(具体的问题会在下一篇文章中说明),想了一些办法来解决总觉得太麻烦,后来想到了用mybatis的插件方式来实现,但是不太清楚插件的原理以及如何书写,正好项目中用到了pageHelper,并且在生产中也发现了pageHelper的一些性能问题,于是带着这两个目的,我研究了一下pageHelper的源码,加上大致百度了一下mybatis插件的写法,在此分享一下经验。
与其说是插件,说成是拦截器好像更容易理解一些。本文使用的pageHelper版本为5.1.10
当我们在项目中引入pageHelper时,需要在mybatis的配置文件中加一个配置,只说核心吧,就是下面这个
<plugin interceptor="com.github.pagehelper.PageInterceptor"></plugin>
这个配置就是为了告诉mybatis,在执行一条SQL语句的时候,需要调用的插件类是哪一个,于是我们进入到这个类里看一下。发现他实现了mybatis的Interceptor接口。
Interceptor接口有三个方法,分别是setProperties、plugin和Intercept。
setProperties主要是将外部的配置文件载入,就是我们在配置pageHelper时,其他的xml配置。
plugin是mybatis插件的入口方法,主要是告知mybatis需要用哪个方法进行拦截sql,如果不需要拦截,那么直接return object;即可,不过这样就没意义了。一般而言,都是通过本类的Intercept方法,所以只需要写return Plugin.wrap(object, this);即可。
于是,Intercept就成了写插件的核心方法了。这里先暂停一下。
在看这个源码的时候,会发现它最开头有两个注解@Intercepts和@Signature,这是因为mybatis在处理每一个sql时,提供了4个对象,分别作用于四个时期,分别是executor(全流程),statementHandler(sql准备阶段),parameterHandler(参数配置阶段),resultHandler(结果处理阶段),而我们在执行一个插件执行时,需要明确在什么时期进行拦截。而每一个对象,又会有很多方法,需要指定拦截的具体方法,并将参数传入。
pageHelper主要是拦截了executor的query方法,查看mybatis源码后发现,query方法有两个重载方法,所以注解里会有两个@Signature,将每个方法参数列出来。关于这一点,我觉得可以当成规定,想写插件就得这么写,原理的话,我才应该是为了方便反射吧。
因为Intercept方法是拦截了mybatis的执行器,所以它的入参invocation其实就是mybatis在执行sql时的入参,它有有4个方法:
getTarget() 获取mybatis的执行器
getMethod() 获取mybatis的具体方法,里面包含很多方法,比如获取注解等等
getArgs:获取本次执行mybatis的所有参数。
0 MappedStatement 维护了一条<select|update|delete|insert>节点的封装,它的方法ms.getId()可以获取对应的mapper方法名
1 parameter mybatis所有的参数 ms.getBoundSql(parameter).getSql() 可以获取到本次执行的真正的SQL语句,参数用?代替
2 RowBounds mybatis的分页类,主要有两个方法,getLimit(默认最大)和getOffset(默认0),这个可以从mybatis的源码中看到:org.apache.ibatis.session.RowBounds
3 ResultHandler mybatis执行结果的封装
4 CacheKey mybatis的缓存
5 boundSql mybatis本次执行绑定的sql
可以看到query方法的两个重载的区别就是有没有后两个参数,说实话,这块我还没弄懂,等以后理解了再来补充吧,本次就只涉及前四个参数。
在程序启动时,就会调用setProperties ,将我们配置的参数载入。当拦截器刚开始执行时,会判断配置的参数是否有dialect,如果没有配置,那么就采用默认的方言类:com.github.pagehelper.PageHelper,这个类也是pageHelper的核心类。
Object[] args = invocation.getArgs();
MappedStatement ms = (MappedStatement) args[0];
Object parameter = args[1];
RowBounds rowBounds = (RowBounds) args[2];
ResultHandler resultHandler = (ResultHandler) args[3];
Executor executor = (Executor) invocation.getTarget();
CacheKey cacheKey;
BoundSql boundSql;
//由于逻辑关系,只会进入一次
if (args.length == 4) {
//4 个参数时
boundSql = ms.getBoundSql(parameter);
cacheKey = executor.createCacheKey(ms, parameter, rowBounds, boundSql);
} else {
//6 个参数时
cacheKey = (CacheKey) args[4];
boundSql = (BoundSql) args[5];
}
checkDialectExists();//判断用户是否指定了方言类
在获取到参数mybatis所有的参数以后,就要开始进行分页了,具体代码如下:
//调用方法判断是否需要进行分页,如果不需要,直接返回结果
if (!dialect.skip(ms, parameter, rowBounds)) {
//判断是否需要进行 count 查询
if (dialect.beforeCount(ms, parameter, rowBounds)) {
//查询总数
Long count = count(executor, ms, parameter, rowBounds, resultHandler, boundSql);
//处理查询总数,返回 true 时继续分页查询,false 时直接返回
if (!dialect.afterCount(count, parameter, rowBounds)) {
//当查询总数为 0 时,直接返回空的结果
return dialect.afterPage(new ArrayList(), parameter, rowBounds);
}
}
resultList = ExecutorUtil.pageQuery(dialect, executor,
ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);
} else {
//rowBounds用参数值,不使用分页插件处理时,仍然支持默认的内存分页
resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);
}
return dialect.afterPage(resultList, parameter, rowBounds);
就流程而言,看以上这段代码足以。
首先就是判断用户当前是否需要分页,会调用pageHelper类的skip方法进行判断,点进skip方法中,就会知道为什么我们只要在执行sql之前加上一句PageHelper.startPage(1,10);就能实现分页了。在执行完这句话以后,pageHelper会将分页参数存入一个map中,然后在skip方法中获取这个map的值,判断如果存在,那就需要分页,不存在就放弃分页。
当然,为了保证多线程环境,数据不会错乱,这个map采用了ThreadLocal方法:
protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<Page>();
在这里我们还会发现一个很奇怪的报错,skip方法的第一行就直接抛错:
if (ms.getId().endsWith(MSUtils.COUNT)) {
throw new RuntimeException("在系统中发现了多个分页插件,请检查系统配置!");
}
很奇怪为什么sql的名称不能以_COUNT结尾。这里先打个问号。
skip方法里还有一个优化点,就是允许用户自定义需要count的列,如果没配置,那么最终的count语句就是count(0),就是配置xml的时候加上<property name="countColumn value="id"/>这样
如果最终不需要分页,就调用executor.query执行mybatis本身的功能,就相当于在拦截器里空跑了一遍
如果需要分页,就要判断是否需要执行count语句了。在使用pageHelper的时候,我们应该都用过这个重载方法PageHelper.startPage(1,10,false),第三个参数默认是true,就是需要进行count操作,获取本次sql的总数,如果改为false,就不会执行count语句,只会执行分页,所以这一点也是我们要注意的。就我的工作经验而言,对于app端的分页,由于是那种无限下滑的模式,所以不存在返回总条数的情况,这里一般都要采用false;而在CMS系统中,前端控件一般都要求返回总条数,所以这里可以用true。
在执行count的时候,会首先判断用户是否有自定义的count语句,就是在你自己的sqlName后面加上_COUNT,走到这里,终于知道为什么它在skip中第一行就不允许_COUNT结尾的sql,因为该SQL只是用来给pageHelper被动调用的,不可以主动调用。如果该方法不存在,就会自己创建一个count语句,利用mybatis自带的MappedStatement.Builder方法构建一个新的sqlSession,最终采用jSqlParser工具类生成一个count语句,就是在头尾增加一个select count(0) from (原SQL) tmp_count
获取count总数后会判断一下该数量是否大于0,只有大于0才会执行真正的查询语句。
在执行完count语句后,会继续封装sql,按照startPage传递的参数,依然是将原sql最外层套上了limit 0,10这样的语句实现分页,最后依然通过mybatis的executor.query方法,将组装好的sql语句执行,然后将结果封装成Page对象返回。
这里多说两句,通常所说的pageHelper的性能问题,大部分都是指的这部分,有两点:
1、由于我们不知道有“_COUNT”这种写法的SQL存在,导致每次需要获取一条sql的总数时,都是pageHelper自动生成的,不管sql有多复杂,它都会在最外层套一个count(0)。我们在生产上抓到的慢查询语句中,大多是这种,如果只执行原来的复杂sql,只需要81ms,但是套上这个count(0)以后,会急剧下降到6.5s
2、同样的,分页也是原sql外面套一层limit语句,也会导致性能急剧降低。所以在使用pageHelper时,一般都要求sql语句尽量简单,并且PageHelper.startPage的第三个参数为false,如果需要为true,那么就需要手动增加一个count语句供pageHelper调用;如果不得不写复杂sql的话,要么不建议分页,要么不建议使用pageHelper,数据量不多的话,一次查出所有数据,然后在内存中自己分页。
这样pageHelper的源码就读完了。其实它的内部实现还是蛮复杂的,使用了缓存,支持多种sql语言,采用jsqlparse生成新的sql等等,不过它的基本执行方式我是了解了,并且我即将要写的插件功能并不复杂,了解了插件原理即可。下一步,写一个自定义的mybatis插件进行sql拦截判断。