源码分析PageHelper是如何实现分页功能的

PageHelper是MyBatis中一个很好用的分页插件,在集成插件之后,只需要在需要分页的MyBatis查询方法前调用PageHelper.startPage静态方法,紧跟在这个方法后的第一个MyBatis查询方法会被进行分页。

//增加此行代码开启分页,pageNum为第几页,pageSize为一页多少条
Page<ArticleVO> page = PageHelper.startPage(pageNum, pageSize);
//执行正常的sql查询
articleMapper.selectAll(query);

简单好用,但是实现原理是什么呢?

总结实现原理:

1、插件是通过MyBatis的拦截器来实现分页逻辑的,入口是一个PageInterceptor类。
2、通过ThreadLocal线程变量的形式增加需要分页的标识以及分页信息,这也就是PageHelper.startPage能开启实现分页的原因。
3、在MySql下(其他数据库没去研究),最终实现分页的查询操作,还是通过给sql添加limit x,x实现的。

准备工作:

使用的源码版本为:

<dependency>
    <groupId>com.github.pagehelper</groupId>
    <artifactId>pagehelper</artifactId>
    <version>5.1.10</version>
</dependency>

由于项目中使用的是SpringBoot,所以引入的是starter的依赖就够了,使用的是:

<dependency>
    <groupId>com.github.pagehelper</groupId>
    <artifactId>pagehelper-spring-boot-starter</artifactId>
    <version>1.2.12</version>
</dependency>

分页信息及分页标识

聚焦于PageHelper.startPage这个静态方法,startPage有很多重载方法,但是最后调用的是:

/**
* 开始分页
*
* @param pageNum      页码
* @param pageSize     每页显示数量
* @param count        是否进行count查询
* @param reasonable   分页合理化,null时用默认配置
* @param pageSizeZero true且pageSize=0时返回全部结果,false时分页,null时用默认配置
*/
public static <E> Page<E> startPage(int pageNum, int pageSize, boolean count, Boolean reasonable, Boolean pageSizeZero) {
    Page<E> page = new Page<E>(pageNum, pageSize, count);
    page.setReasonable(reasonable);
    page.setPageSizeZero(pageSizeZero);
    //当已经执行过orderBy的时候
    Page<E> oldPage = getLocalPage();
    if (oldPage != null && oldPage.isOrderByOnly()) {
        page.setOrderBy(oldPage.getOrderBy());
    }
    setLocalPage(page);
    return page;
}

方法的逻辑是:实例化一个Page对象,这个对象用来存放分页相关的信息,页码,每页显示数量等。然后通过setLocalPage方法将这个Page对象存储到线程变量ThreadLocal<Page> LOCAL_PAGE中。到这里分页信息的设置就结束了,剩下的工作就是拦截器里面的内容,拦截器会通过LOCAL_PAGE中这个Page对象的存在与否决定是否开启分页逻辑。

分页的核心逻辑PageInterceptor拦截器

由于使用的是starter的依赖,所以在代码中是通过PageHelperAutoConfiguration这个配置类将PageInterceptor拦截器添加到MyBatis中的

@PostConstruct
public void addPageInterceptor() {
    PageInterceptor interceptor = new PageInterceptor();
    Properties properties = new Properties();
    properties.putAll(pageHelperProperties());
    properties.putAll(this.properties.getProperties());
    interceptor.setProperties(properties);
    for (SqlSessionFactory sqlSessionFactory : sqlSessionFactoryList) {
        //添加分页拦截器
        sqlSessionFactory.getConfiguration().addInterceptor(interceptor);
    }
}

通过@PostConstruct注解执行addInterceptor方法来添加拦截器。

整个分页的核心代码逻辑就在于拦截器中,拦截器是分页的入口

@Override
public Object intercept(Invocation invocation) throws Throwable {
    try {
        // 省略部分源码
        List resultList;
        //调用方法判断是否需要进行分页,如果不需要,直接返回结果
        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);
    } finally {
        if(dialect != null){
            dialect.afterAll();
        }
    }
}

分页的基本逻辑都在这个方法中,通过dialect对象的各个方法结合实现分页功能,Dialect是一个接口,他有不同的子类实现,对应不同的数据库方言。如果我们没有在配置中指定具体的实现类的话,默认的实现类是PageHelper。而PageHelper相当于一个大管家,他内部有一个PageAutoDialect自动方言类来自动的选择对应的数据库方言。

PageAutoDialect识别数据库的实现原理是通过获取DataSource对象继而获取jdbcUrl,然后通过jdbcUrl来识别的,也就是通过识别jdbc:mysql://实现

/**
 * 根据 jdbcUrl 获取数据库方言
 *
 * @param ms
 * @return
 */
private AbstractHelperDialect getDialect(MappedStatement ms) {
    //改为对dataSource做缓存
    DataSource dataSource = ms.getConfiguration().getEnvironment().getDataSource();
    String url = getUrl(dataSource);
    if (urlDialectMap.containsKey(url)) {
        return urlDialectMap.get(url);
    }
    try {
        lock.lock();
        if (urlDialectMap.containsKey(url)) {
            return urlDialectMap.get(url);
        }
        if (StringUtil.isEmpty(url)) {
            throw new PageException("无法自动获取jdbcUrl,请在分页插件中配置dialect参数!");
        }
        String dialectStr = fromJdbcUrl(url);
        if (dialectStr == null) {
            throw new PageException("无法自动获取数据库类型,请通过 helperDialect 参数指定!");
        }
        AbstractHelperDialect dialect = initDialect(dialectStr, properties);
        urlDialectMap.put(url, dialect);
        return dialect;
    } finally {
        lock.unlock();
    }
}

回到intercept方法看分页的主流程,流程主要是:是否需要分页(skip)->分页前是否要count查询(beforeCount)->count查询(count)->查询分页数据(ExecutorUtil.pageQuery)->处理分页结果(afterPage)->清理数据(afterAll)

skip方法:逻辑就是判断线程变量LOCAL_PAGE是否有page对象,如果没有的话看是否有原始的rowBounds对象

beforeCount方法:page对象中的count属性判断,默认是true

count方法:会通过主语句的id+_COUNT后缀的形式查找是否有自定义的count查询语句,如果没有的话则会自动创建一个;自动创建的count语句是在com.github.pagehelper.parser.CountSqlParser#getSmartCountSql(java.lang.String, java.lang.String)方法中,大部分是通过创建简单的count子查询实现

ExecutorUtil.pageQuery方法:这个方法比较复杂,是实现最终数据查询的地方,主要的逻辑是获取对应数据库方言的分页语句形式,MySql的话是在com.github.pagehelper.dialect.helper.MySqlDialect#getPageSql方法中,通过添加limit实现

afterPage方法:返回结果的整理,将查询的数据结果以及分页的信息(总数总页数等)统一聚合设置到page对象中

afterAll方法:清理线程变量中的page对象,避免影响下一个sql的执行

到这里分页就完成了,更多的逻辑可以下载源码仔细看看,本地debug一下更高效。


觉得内容还不错?打赏个钢镚鼓励鼓励!!👍