Github PageHelper 原理解析

任何服务对数据库的日常操作,都离不开增删改查。如果一次查询的纪录很多,那我们必须采用分页的方式。对于一个Springboot项目,访问和查询MySQL数据库,持久化框架可以使用MyBatis,分页工具可以使用github的 PageHelper。我们来看一下PageHelper的使用方法:

 1 // 组装查询条件
 2 ArticleVO articleVO = new ArticleVO();
 3 articleVO.setAuthor("刘慈欣");
 4 
 5 // 初始化返回类
 6 // ResponsePages类是这样一种返回类,其中包括返回代码code和返回消息msg
 7 // 还包括返回的数据和分页信息
 8 // 其中,分页信息就是 com.github.pagehelper.Page<?> 类型
 9 ResponsePages<List<ArticleVO>> responsePages = new ResponsePages<>();
10 
11 // 这里为了简单,写死分页参数。正确的做法是从查询条件中获取
12 // 假设需要获取第1页的数据,每页20条记录
13 // com.github.pagehelper.Page<?> 类的基本字段如下
14 // pageNum: 当前页
15 // pageSize: 每页条数
16 // total: 总记录数
17 // pages: 总页数
18 com.github.pagehelper.Page<?> page = PageHelper.startPage(1, 20);
19 
20 // 根据条件获取文章列表
21 List<ArticleVO> articleList = articleMapper.getArticleListByCondition(articleVO);
22 
23 // 设置返回数据
24 responsePages.setData(articleList);
25 
26 // 设置分页信息
27 responsePages.setPage(page);

  

如代码所示,page 是组装好的分页参数,即每页显示20条记录,并且显示第1页。然后我们执行mapper的获取文章列表的方法,返回了结果。此时我们查看 responsePages 的内容,可以看到 articleList 中有20条记录,page中包括当前页,每页条数,总记录数,总页数等信息。
 
使用方法就是这么简单,但是仅仅知道如何使用还不够,还需要对原理有所了解。下面就来看看,PageHelper 实现分页的原理。
 
我们先来看看 startPage 方法。进入此方法,发现一堆方法重载,最后进入真正的 startPage 方法,有5个参数,如下所示:

 1 /**
 2  * 开始分页
 3  *
 4  * @param pageNum      页码
 5  * @param pageSize     每页显示数量
 6  * @param count        是否进行count查询
 7  * @param reasonable   分页合理化,null时用默认配置
 8  * @param pageSizeZero true 且 pageSize=0 时返回全部结果,false时分页, null时用默认配置
 9  */
10 public static <E> Page<E> startPage(int pageNum, int pageSize, boolean count, Boolean reasonable, Boolean pageSizeZero) {
11     Page<E> page = new Page<E>(pageNum, pageSize, count);
12     page.setReasonable(reasonable);
13     page.setPageSizeZero(pageSizeZero);
14     // 当已经执行过orderBy的时候
15     Page<E> oldPage = SqlUtil.getLocalPage();
16     if (oldPage != null && oldPage.isOrderByOnly()) {
17         page.setOrderBy(oldPage.getOrderBy());
18     }
19     SqlUtil.setLocalPage(page);
20     return page;
21 }

  

getLocalPage 和 setLocalPage 方法做了什么操作?我们进入基类 BaseSqlUtil 看一下:

 1 package com.github.pagehelper.util;
 2 ...
 3 
 4 public class BaseSqlUtil {
 5     // 省略其他代码
 6 
 7     private static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<Page>();
 8     
 9     /**
10      * 从 ThreadLocal<Page> 中获取 page
11      */ 
12     public static <T> Page<T> getLocalPage() {
13         return LOCAL_PAGE.get();
14     }
15     
16     /**
17      * 将 page 设置到 ThreadLocal<Page>
18      */
19     public static void setLocalPage(Page page) {
20         LOCAL_PAGE.set(page);
21     }
22 
23     // 省略其他代码
24 }

 

原来是将 page 放入了 ThreadLocal<Page> 中。ThreadLocal 是每个线程独有的变量,与其他线程不影响,是放置 page 的好地方。
setLocalPage 之后,一定有地方 getLocalPage,我们跟踪进入代码来看。
 
有了MyBatis动态代理的知识后,我们知道最终执行SQL的地方是 MapperMethod 的 execute 方法,作为回顾,我们来看一下:

 1 package org.apache.ibatis.binding;
 2 ...
 3 
 4 public class MapperMethod {
 5 
 6     public Object execute(SqlSession sqlSession, Object[] args) {
 7         Object result;
 8         if (SqlCommandType.INSERT == command.getType()) {
 9             // 省略
10         } else if (SqlCommandType.UPDATE == command.getType()) {
11             // 省略
12         } else if (SqlCommandType.DELETE == command.getType()) {
13             // 省略
14         } else if (SqlCommandType.SELECT == command.getType()) {
15             if (method.returnsVoid() && method.hasResultHandler()) {
16                 executeWithResultHandler(sqlSession, args);
17                 result = null;
18             } else if (method.returnsMany()) {
19                 /**
20                  * 获取多条记录
21                  */
22                 result = executeForMany(sqlSession, args);
23             } else if ...
24                 // 省略
25         } else if (SqlCommandType.FLUSH == command.getType()) {
26             // 省略
27         } else {
28             throw new BindingException("Unknown execution method for: " + command.getName());
29         }
30         ...
31         
32         return result;
33     }
34 }

  

由于执行的是select操作,并且需要查询多条纪录,所以我们进入 executeForMany 这个方法中,然后进入 selectList 方法,然后是 executor.query 方法。再然后突然进入到了 mybatis 的 Plugin 类的 invoke 方法,这是为什么?
 
这里就必须提到 mybatis 提供的 Interceptor 接口。Intercept 机制让我们可以将自己制作的分页插件 intercept 到查询语句执行的地方,这是MyBatis对外提供的标准接口。借助于Java的动态代理,标准的拦截器可以拦截在指定的数据库访问流程中,执行拦截器自定义的逻辑,比如在执行SQL之前拦截,拼装一个分页的SQL并执行。
 
让我们回到MyBatis初始化的时候,我们发现 MyBatis 为我们组装了 sqlSessionFactory,所有的 sqlSession 都是生成自这个 Factory。在这篇文章中,我们将重点放在 interceptorChain 上。程序启动时,MyBatis 或者是 mybatis-spring 会扫描代码中所有实现了 interceptor 接口的插件,并将它们以【拦截器集合】的方式,存储在 interceptorChain 中。如下所示:

# sqlSessionFactory 中的重要信息

sqlSessionFactory
    configuration
        environment        
        mapperRegistry
            config         
            knownMappers   
        mappedStatements   
        resultMaps         
        sqlFragments       
        interceptorChain   # MyBatis拦截器调用链
            interceptors   # 拦截器集合,记录了所有实现了Interceptor接口,并且使用了invocation变量的类

  

如果MyBatis检测到有拦截器,它就会在拦截器指定的执行点,首先执行 Plugin 的 invoke 方法,唤醒拦截器,然后执行拦截器定义的逻辑。因此,当 query 方法即将执行的时候,其实执行的是拦截器的逻辑。
 
MyBatis官网的说明:
MyBatis 允许你在已映射语句执行过程中的某一点进行拦截调用。默认情况下,MyBatis 允许使用插件来拦截的方法调用包括:
  • Executor (update, query, flushStatements, commit, rollback, getTransaction, close, isClosed)
  • ParameterHandler (getParameterObject, setParameters)
  • ResultSetHandler (handleResultSets, handleOutputParameters)
  • StatementHandler (prepare, parameterize, batch, update, query)
 
如果想了解更多拦截器的知识,可以看文末的参考资料。
 
我们回到主线,继续看Plugin类的invoke方法:

 1 package org.apache.ibatis.plugin;
 2 ...
 3 
 4 public class Plugin implements InvocationHandler {
 5     ...
 6 
 7     public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
 8         try {
 9            Set<Method> methods = signatureMap.get(method.getDeclaringClass());
10            if (methods != null && methods.contains(method)) {
11                // 执行拦截器的逻辑
12                return interceptor.intercept(new Invocation(target, method, args));
13            }
14            return method.invoke(target, args);
15        } catch (Exception e) {
16            throw ExceptionUtil.unwrapThrowable(e);
17        }
18    }
19    ...
20 }

 

我们去看 intercept 方法的实现,这里我们进入【PageHelper】类来看:

 1 package com.github.pagehelper;
 2 ...
 3 
 4 /**
 5  * Mybatis - 通用分页拦截器
 6  */
 7 @SuppressWarnings("rawtypes")
 8 @Intercepts(@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}))
 9 public class PageHelper extends BasePageHelper implements Interceptor {
10     private final SqlUtil sqlUtil = new SqlUtil();
11 
12     @Override
13     public Object intercept(Invocation invocation) throws Throwable {
14         // 执行 sqlUtil 的拦截逻辑
15         return sqlUtil.intercept(invocation);
16     }
17 
18     @Override
19     public Object plugin(Object target) {
20         return Plugin.wrap(target, this);
21     }
22 
23     @Override
24     public void setProperties(Properties properties) {
25         sqlUtil.setProperties(properties);
26     }
27 }

 

可以看到最终调用了 SqlUtil 的intercept 方法,里面的 doIntercept 方法是 PageHelper 原理中最重要的方法。跟进来看:

  1 package com.github.pagehelper.util;
  2 ...
  3 
  4 public class SqlUtil extends BaseSqlUtil implements Constant {
  5     ...
  6     
  7     /**
  8      * 真正的拦截器方法
  9      *
 10      * @param invocation
 11      * @return
 12      * @throws Throwable
 13      */
 14     public Object intercept(Invocation invocation) throws Throwable {
 15         try {
 16             return doIntercept(invocation);  // 执行拦截
 17         } finally {
 18             clearLocalPage();  // 清空 ThreadLocal<Page>
 19         }
 20     }
 21     
 22     /**
 23      * 真正的拦截器方法
 24      *
 25      * @param invocation
 26      * @return
 27      * @throws Throwable
 28      */
 29     public Object doIntercept(Invocation invocation) throws Throwable {
 30         // 省略其他代码
 31         
 32         // 调用方法判断是否需要进行分页
 33         if (!runtimeDialect.skip(ms, parameterObject, rowBounds)) {
 34             ResultHandler resultHandler = (ResultHandler) args[3];
 35             // 当前的目标对象
 36             Executor executor = (Executor) invocation.getTarget();
 37             
 38             /**
 39              * getBoundSql 方法执行后,boundSql 中保存的是没有 limit 的sql语句
 40              */
 41             BoundSql boundSql = ms.getBoundSql(parameterObject);
 42             
 43             // 反射获取动态参数
 44             Map<String, Object> additionalParameters = (Map<String, Object>) additionalParametersField.get(boundSql);
 45             // 判断是否需要进行 count 查询,默认需要
 46             if (runtimeDialect.beforeCount(ms, parameterObject, rowBounds)) {
 47                 // 省略代码
 48                 
 49                 // 执行 count 查询
 50                 Object countResultList = executor.query(countMs, parameterObject, RowBounds.DEFAULT, resultHandler, countKey, countBoundSql);
 51                 Long count = (Long) ((List) countResultList).get(0);
 52                 
 53                 // 处理查询总数,从 ThreadLocal<Page> 中取出 page 并设置 total
 54                 runtimeDialect.afterCount(count, parameterObject, rowBounds);
 55                 if (count == 0L) {
 56                     // 当查询总数为 0 时,直接返回空的结果
 57                     return runtimeDialect.afterPage(new ArrayList(), parameterObject, rowBounds);
 58                 }
 59             }
 60             // 判断是否需要进行分页查询
 61             if (runtimeDialect.beforePage(ms, parameterObject, rowBounds)) {
 62                 /**
 63                  * 生成分页的缓存 key
 64                  * pageKey变量是分页参数存放的地方
 65                  */
 66                 CacheKey pageKey = executor.createCacheKey(ms, parameterObject, rowBounds, boundSql);
 67                 /**
 68                  * 处理参数对象,会从 ThreadLocal<Page> 中将分页参数取出来,放入 pageKey 中
 69                  * 主要逻辑就是这样,代码就不再单独贴出来了,有兴趣的同学可以跟进验证
 70                  */
 71                 parameterObject = runtimeDialect.processParameterObject(ms, parameterObject, boundSql, pageKey);
 72                 /**
 73                  * 调用方言获取分页 sql
 74                  * 该方法执行后,pageSql中保存的sql语句,被加上了 limit 语句
 75                  */
 76                 String pageSql = runtimeDialect.getPageSql(ms, boundSql, parameterObject, rowBounds, pageKey);
 77                 BoundSql pageBoundSql = new BoundSql(ms.getConfiguration(), pageSql, boundSql.getParameterMappings(), parameterObject);
 78                 //设置动态参数
 79                 for (String key : additionalParameters.keySet()) {
 80                     pageBoundSql.setAdditionalParameter(key, additionalParameters.get(key));
 81                 }
 82                 /**
 83                  * 执行分页查询
 84                  */
 85                 resultList = executor.query(ms, parameterObject, RowBounds.DEFAULT, resultHandler, pageKey, pageBoundSql);
 86             } else {
 87                 resultList = new ArrayList();
 88             }
 89         } else {
 90             args[2] = RowBounds.DEFAULT;
 91             // 不需要分页查询,执行原方法,不走代理
 92             resultList = (List) invocation.proceed();
 93         }
 94         /**
 95          * 主要逻辑:
 96          * 从 ThreadLocal<Page> 中取出 page
 97          * 将 resultList 塞进 page,并返回
 98          */
 99         return runtimeDialect.afterPage(resultList, parameterObject, rowBounds);
100     }
101     ...
102 }

 

Count 查询语句 countBoundSql 被执行了,分页查询语句 pageBoundSql 也被执行了。然后从 ThreadLocal<Page> 中将page 取出来,设置记录总数,每页条数等信息,同时也将查询到的记录塞进page,最后返回。再之后就是mybatis的常规后续操作了。

 

知识拓展

我们来看看 PageHelper 支持哪些数据库的分页操作:
  1. Oracle
  2. Mysql
  3. MariaDB
  4. SQLite
  5. Hsqldb
  6. PostgreSQL
  7. DB2
  8. SqlServer(2005,2008)
  9. Informix
  10. H2
  11. SqlServer2012
  12. Derby
  13. Phoenix
 
原来 PageHelper 支持这么多数据库,那么持久化工具mybatis为什么不一口气把分页也做了呢?
其实mybatis也有自带的分页方法:RowBounds。RowBounds简单地来说包括 offset 和 limit。实现原理是将所有符合条件的记录获取出来,然后丢弃 offset 之前的数据,只获取 limit 条数据。这种做法效率低下,个人猜想mybatis只想把数据库连接和SQL执行这方面做精做强,至于如分页之类的细节,本身提供Intercept接口,让第三方实现该接口来完成分页。PageHelper 就是这样的第三方分页插件。甚至你可以实现该接口,制作你自己的业务逻辑,拦截到任何MyBatis允许你拦截的地方。
 

总结

PageHelper 的分页原理,最核心的部分是实现了 MyBatis 的 Interceptor 接口,从而将分页参数拦截在执行sql之前,拼装出分页sql到数据库中执行。
初始化的时候,因为 PageHelper 的 SqlUtil 中实例化了 intercept 方法,因此MyBatis 将它视作一个拦截器,记录在 interceptorChain 中。
执行的时候,PageHelper首先将 page 需求记录在 ThreadLocal<Page> 中,然后在拦截的时候,从 ThreadLocal<Page> 中取出 page,拼装出分页sql,然后执行。
同时将结果分页信息(包括当前页,每页条数,总页数,总记录数等)设置回page,让业务代码可以获取。
 

参考资料

 

创作时间:2019-11-20 21:21

 

posted @ 2019-11-20 21:23  gkmeteor  阅读(6583)  评论(1编辑  收藏  举报