0
点赞
收藏
分享

微信扫一扫

高薪程序员&面试题精讲系列86MySQL分页之PageHelper源码及原理分析

一. 面试题及剖析

1. 今日面试题

2. 题目剖析

在上一篇文章中,壹哥给大家讲解了MySQL中的分页效果是如何实现的,以及如何对大数据量时的分页进行优化。本文主要是结合经典的PageHelper分页插件,讲解分页插件的底层源码和执行原理。如果你没看过上一篇文章,请参考下面的链接:

高薪程序员&面试题精讲系列85之MySQL如何进行分页?

二. PageHelper基本使用

1. 简介

PageHelper是与Mybatis配合使用最方便的一款开源分页插件,具有如下特色:

2. 基本使用

PageHelper的使用其实很简单,引入必要的依赖包之后,在项目中进行简单的配置即可。

2.1 引入依赖包

在 pom.xml 文件中添加如下依赖即可:

<dependency>
  <groupId>com.github.pagehelper</groupId>
  <artifactId>pagehelper</artifactId>
  <version>版本号</version>
</dependency>

2.2 配置拦截器插件

特别注意,新版的拦截器插件是 com.github.pagehelper.PageInterceptor。而之前旧版的com.github.pagehelper.PageHelper 现在是一个特殊的 dialect 实现类,是分页插件的默认实现类,提供了和以前相同的用法。以下壹哥是在mybatis的配置文件中进行了拦截器插件的配置,但如果我们的项目是SpringBoot项目,则直接在配置类中生成PageInterceptor类的Bean对象即可,更为简单。

<!--
    plugins在配置文件中的位置必须符合要求,否则会报错,顺序如下:
    properties?, settings?,
    typeAliases?, typeHandlers?,
    objectFactory?,objectWrapperFactory?,
    plugins?,
    environments?, databaseIdProvider?, mappers?
-->
<plugins>
    <!-- com.github.pagehelper为PageHelper类所在包名 -->
    <plugin interceptor="com.github.pagehelper.PageInterceptor">
        <!-- 使用下面的方式配置参数,后面会有所有的参数介绍 -->
        <property name="param1" value="value1"/>
	</plugin>
</plugins>

至于该分页插件的属性详情,壹哥就不再详细讲解了,这些在我们的线下课程中都有详细讲解,只要各位跟着壹哥听过线下课,这些都已经了然于胸了。

2.3 代码实现

PageHelper给我们提供了多种分页代码实现方式,这里壹哥就简单讲解其中常用的3种实现方式,至于其他方式,大家可以来找我当面学习哦。

//第一种方式,RowBounds方式的调用
List<Country> list = sqlSession.selectList("x.y.selectIf", null, new RowBounds(0, 10));

//第二种方式,Mapper接口方式的调用,推荐这种使用方式。
//注意,这句话一定要放在分页查询语句之前
PageHelper.startPage(1, 10);
List<Country> list = countryMapper.selectIf(1);

//第三种方式,Mapper接口方式的调用,推荐这种使用方式。
//注意,这句话一定要放在分页查询语句之前
PageHelper.offsetPage(1, 10);
List<Country> list = countryMapper.selectIf(1);

经过以上3步,我们就可以很轻松的实现Mybatis环境下的分页查询了,而且效率也是杠杠的。

三. PageHelper分页原理

PageHelper的使用其实是非常简单的,但我们不仅要会用,还要知道为什么要这么用,所以接下来壹哥就分析一下PageHelper的分页原理。

1. Mybatis对插件的处理过程

要想彻底搞明白PageHelper的分页原理,我们得先来看看Mybatis对插件是怎么处理的,毕竟PageHelper就是一个插件,这个插件是要被安装插入到Mybatis体系里才会执行的。就好比PageHelper是一个移动硬盘,而Mybatis则提供了一个USB接口供其插入,但我们需要了解一下这个USB接口是怎么处理这些外部的硬件的。

在Mybatis中是通过拦截器来进行拦截处理的,内部真正执行Sql语句的有四个插件对象,分别如下:

Mybatis对以上4个对象的执行关系如下图所示,大家可以参考:

2. 自定义插件

MyBatis支持的所有插件,都是基于拦截以上四大对象来实现的。如果一个插件想要接入到Mybatis中,首先就要实现一个Interceptor接口,然后重写其中的三个方法(这里必须要实现Interceptor接口,否则无法被拦截)。比如下面壹哥自定义了一个Interceptor插件,代码如下:

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

    /**
     * 这个方法会直接覆盖原有方法
     */
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        //调用原方法
        System.out.println("成功拦截了Executor的query方法,在这里我可以做点什么");
        return invocation.proceed();
    }

    @Override
    public Object plugin(Object target) {
        //把被拦截对象生成一个代理对象
        return Plugin.wrap(target,this);
    }

    @Override
    public void setProperties(Properties properties) {
        //可以自定义一些属性
        System.out.println("自定义属性:name->" + properties.getProperty("name"));
    }
}

@Intercepts注解用来声明当前类是一个拦截器,后面的@Signature用于标识需要拦截的方法签名,通过以下三个参数来确定:

然后我们在mybatis-config中对上述插件进行配置即可,如下:

<plugins>
  <plugin interceptor="com.yyg.mybatis.plugin.MyPlugin">
    <property name="name" value="一一哥"/>
  </plugin>
</plugins>

如果在这里配置了property属性,我们就可以在setProperties中获取到。完成以上两步,我们就完成了一个插件的配置了。

3. PageInterceptor插件

PageHelper插件其实就是遵循了以上开发规则实现的一个第三方插件,它的运行也需要符合上述要求,这样Mybatis才能接受运行PageHelper。那么PageHelper中负责分页的插件是哪个呢?其实是PageInterceptor类!我们先来看看该拦截器的源码:

@SuppressWarnings({"rawtypes", "unchecked"})
@Intercepts(
    {
        @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
        @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}),
    }
)
public class PageInterceptor implements Interceptor {
    //缓存count查询的ms
    protected Cache<String, MappedStatement> msCountMap = null;
    private Dialect dialect;
    //默认的分页处理器类
    private String default_dialect_class = "com.github.pagehelper.PageHelper";
    private Field additionalParametersField;
    private String countSuffix = "_COUNT";

    //拦截器的核心方法
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        try {
            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];
            }
            List resultList;
            //调用方法判断是否需要进行分页,如果不需要,直接返回结果
            if (!dialect.skip(ms, parameter, rowBounds)) {
                //反射获取动态参数
                String msId = ms.getId();
                Configuration configuration = ms.getConfiguration();
                Map<String, Object> additionalParameters = (Map<String, Object>) additionalParametersField.get(boundSql);
                //判断是否需要进行 count 查询
                if (dialect.beforeCount(ms, parameter, rowBounds)) {
                    String countMsId = msId + countSuffix;
                    Long count;
                    //先判断是否存在手写的 count 查询
                    MappedStatement countMs = getExistedMappedStatement(configuration, countMsId);
                    if(countMs != null){
                        count = executeManualCount(executor, countMs, parameter, boundSql, resultHandler);
                    } else {
                        countMs = msCountMap.get(countMsId);
                        //自动创建
                        if (countMs == null) {
                            //根据当前的 ms 创建一个返回值为 Long 类型的 ms
                            countMs = MSUtils.newCountMappedStatement(ms, countMsId);
                            msCountMap.put(countMsId, countMs);
                        }
                        count = executeAutoCount(executor, countMs, parameter, boundSql, rowBounds, resultHandler);
                    }
                    //处理查询总数
                    //返回 true 时继续分页查询,false 时直接返回
                    if (!dialect.afterCount(count, parameter, rowBounds)) {
                        //当查询总数为 0 时,直接返回空的结果
                        return dialect.afterPage(new ArrayList(), parameter, rowBounds);
                    }
                }
                //判断是否需要进行分页查询
                if (dialect.beforePage(ms, parameter, rowBounds)) {
                    //生成分页的缓存 key
                    CacheKey pageKey = cacheKey;
                    //处理参数对象
                    parameter = dialect.processParameterObject(ms, parameter, boundSql, pageKey);
                    //调用方言获取分页 sql
                    String pageSql = dialect.getPageSql(ms, boundSql, parameter, rowBounds, pageKey);
                    BoundSql pageBoundSql = new BoundSql(configuration, pageSql, boundSql.getParameterMappings(), parameter);
                    //设置动态参数
                    for (String key : additionalParameters.keySet()) {
                        pageBoundSql.setAdditionalParameter(key, additionalParameters.get(key));
                    }
                    //执行分页查询
                    resultList = executor.query(ms, parameter, RowBounds.DEFAULT, resultHandler, pageKey, pageBoundSql);
                } else {
                    //不执行分页的情况下,也不执行内存分页
                    resultList = executor.query(ms, parameter, RowBounds.DEFAULT, resultHandler, cacheKey, boundSql);
                }
            } else {
                //rowBounds用参数值,不使用分页插件处理时,仍然支持默认的内存分页
                resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);
            }
            return dialect.afterPage(resultList, parameter, rowBounds);
        } finally {
            dialect.afterAll();
        }
    }

    /**
     * 执行手动设置的 count 查询,该查询支持的参数必须和被分页的方法相同
     */
    private Long executeManualCount(Executor executor, MappedStatement countMs,
                                   Object parameter, BoundSql boundSql,
                                   ResultHandler resultHandler) throws IllegalAccessException, SQLException {
        ......
    }

    /**
     * 执行自动生成的 count 查询
     */
    private Long executeAutoCount(Executor executor, MappedStatement countMs,
                                   Object parameter, BoundSql boundSql,
                                   RowBounds rowBounds, ResultHandler resultHandler) throws IllegalAccessException, SQLException {
        ......
    }

    /**
     * 尝试获取已经存在的在 MS,提供对手写count和page的支持
     */
    private MappedStatement getExistedMappedStatement(Configuration configuration, String msId){
        ......
    }

    @Override
    public Object plugin(Object target) {
       ......
    }

    @Override
    public void setProperties(Properties properties) {
        //缓存 count ms
        msCountMap = CacheFactory.createCache(properties.getProperty("msCountCache"), "ms", properties);
        String dialectClass = properties.getProperty("dialect");
        if (StringUtil.isEmpty(dialectClass)) {
            dialectClass = default_dialect_class;
        }
        try {
            Class<?> aClass = Class.forName(dialectClass);
            dialect = (Dialect) aClass.newInstance();
        } catch (Exception e) {
            throw new PageException(e);
        }
        dialect.setProperties(properties);

        String countSuffix = properties.getProperty("countSuffix");
        if (StringUtil.isNotEmpty(countSuffix)) {
            this.countSuffix = countSuffix;
        }

        try {
            //反射获取 BoundSql 中的 additionalParameters 属性
            additionalParametersField = BoundSql.class.getDeclaredField("additionalParameters");
            additionalParametersField.setAccessible(true);
        } catch (NoSuchFieldException e) {
            throw new PageException(e);
        }
    }

}

在上面源码的intercept()方法中,有如下一段代码:

其中的dialect.getPageSql()是用于生成分页查询sql的方法,该方法是在AbstractHelperDialect类中实现的。所以接下来我们看看分页的sql语句到底是怎么生成的。

4. getPageSql方法

这就是AbstractHelperDialect类中的getPageSql方法源码,用于生成分页查询sql语句。

@Override
public String getPageSql(MappedStatement ms, BoundSql boundSql, Object parameterObject, RowBounds rowBounds, CacheKey pageKey) {
        String sql = boundSql.getSql();
        Page page = getLocalPage();
        //支持 order by
        String orderBy = page.getOrderBy();
        if (StringUtil.isNotEmpty(orderBy)) {
            pageKey.update(orderBy);
            sql = OrderByParser.converToOrderBySql(sql, orderBy);
        }
        if (page.isOrderByOnly()) {
            return sql;
        }
        return getPageSql(sql, page, pageKey);
}

从上面这段源码中,我们可以看出,会在分页sql语句中考虑添加order by操作。该方法的最后是返回getPageSql()方法,但这个方法确实一个抽象方法,如下图所示:

5. getPageSql抽象方法的实现类

既然是抽象方法,那就会有人实现该方法,在PageHelper插件中,那么到底有哪些地方实现了该抽象方法呢?我们看下图:

我们可以看出,在壹哥的这个版本中,有7个具体的实现类,我这里使用的是MySQL数据库,所以壹哥就找到MySqlDialect这个方言类来看看内部具体是怎么实现的。

6. 拼接分页语句

@Override
 public String getPageSql(String sql, Page page, CacheKey pageKey) {
     StringBuilder sqlBuilder = new StringBuilder(sql.length() + 14);
     sqlBuilder.append(sql);
     if (page.getStartRow() == 0) {
         sqlBuilder.append(" LIMIT ? ");
     } else {
         sqlBuilder.append(" LIMIT ?, ? ");
     }
     pageKey.update(page.getPageSize());
     return sqlBuilder.toString();
}

从上面的源码中可以看出,PageHelper就是在这里针对MySQL数据库进行分页语句拼接的,而且这里也没有使用什么高深的代码,就是用很普通的StringBuilder拼接的sql语句和limit关键字。所以由此看见,我们只需要在前端向后端传递过来正常的分页参数pageNum和pageSize的值,PageHelper分页插件就会自动把这两个参数,结合limit关键字自动拼接成分页语句。关于MySQL的分页实现,壹哥在上面的章节中刚讲完limit的用法和原理,相信你还没有忘记,这样就是PageHelper可以自动实现分页功能的底层原理!

7. PageHelper优化机制

虽然现在我们已经明白了PageHelper是怎么自动实现分页功能的了,但还有一些其他的地方需要我们注意,比如PageHelper对分页的优化机制。我们知道,PageHelper要求我们在分页之前,必须先调用PageHelper.startPage()这样的代码,这是为什么呢?我们先来看看源码。

在startPage()方法的源码内部,我们可以看到getLocalPage()和setLocalPage()方法,这是干嘛的呢?我们看看源码:

我们可以看到在这里使用了本地线程容器ThreadLocal,用于保存分页数据。关于ThreadLocal,很多地方叫做线程本地变量,也有些地方叫做线程本地存储,它可以为变量在每个线程中都创建一个副本,每个线程都可以访问自己内部的副本变量,这里壹哥就不再专门讲解ThreadLocal了。

PageHelper首先将前端传递的参数保存到page这个对象中,接着将page的副本存放入ThreadLoacl中,这样可以保证分页的时候,参数互不影响,接着利用了mybatis提供的拦截器,取得ThreadLocal的值,重新拼装分页SQL,完成分页。

8. 分页流程小节(重点)

总之,PageHelper执行分页的流程大致如下:

以上就是壹哥给大家总结的PageHelper执行原理和流程,你明白了吗?

四. 结语

至此,壹哥就带各位把MySQL的分页功能复习了一遍,现在你对分页还有什么疑惑吗?可以在评论区给壹哥留言,或者私信我也可以的哦。下一篇,壹哥会讲解数据库中主键的生成及其注意事项,敬请关注。

举报

相关推荐

0 条评论