通过PageHelper源码学习MyBatis插件的开发

        最近工作中遇到了一些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拦截判断。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值