PageHelper介绍

PageHelper是基于mybatis提供的一个第三方分页插件,在基于mybatis的项目中使用非常方便,详细可查看官网:https://pagehelper.github.io/

PageHelper使用

先简单介绍下使用:
maven依赖

<dependency>
    <groupId>com.github.pagehelper</groupId>
    <artifactId>pagehelper-spring-boot-starter</artifactId>
    <version>1.2.8</version>
</dependency>
// 设置分页参数,第一页,分页条数为10
PageHelper.startPage(1, 10);
// 开始查询
List<FastName> list = fastNameMapper.queryNameByTopicTitle();
// 将查询结果包装到PageInfo,PageInfo中包含了页码,查询结果,当前页码等信息
PageInfo pageInfo = new PageInfo<>(list);

通过上述代码可以看到使用非常方便,而且可以做到和业务代码解耦,如果不需要分页了直接删除分页相关代码即可
使用PageHelper的注意点:
1、调用静态方法PageHelper.startPage后只有下一次查询会使用分页,调用过一次查询分页参数自动清除
2、PageHelper.startPage必须放在查询之前,我们必须知道是调用查询时使用到了分页,是属于服务端分页,而不是客户端分页
笔者推荐使用用法

PageInfo pageInfo = PageHelper.startPage(1, 10).doSelectPageInfo( 
() -> fastNameMapper.queryNameByTopicTitle());

即:一次性设置分页、查询、包装为PageInfo,具体原因后面分析

PageHelper原理

我们看到上面的代码调用了静态方法PageHelper.startPage之后我们在后续查询即可使用到分页参数,那么这是怎么做到的呢,为什么查询时我们不需要把参数传入我们的查询即可使用呢?
首先笔者想到的肯定是在某个地方设置了参数,然后在查询时可以拿到,那么顺其自然很容易就想到了java的juc包下提供的ThreadLocal,ThreadLocal相关的知识可以查看:ThreadLocal详解
接下来我们就来看下源码:

我们先看下静态方法:startPage

   public static <E> Page<E> startPage(int pageNum, int pageSize) {
        return startPage(pageNum, pageSize, DEFAULT_COUNT);
    }
public static <E> Page<E> startPage(int pageNum, int pageSize, boolean count) {
        return startPage(pageNum, pageSize, count, (Boolean)null, (Boolean)null);
    }
  public static <E> Page<E> startPage(int pageNum, int pageSize, boolean count, Boolean reasonable, Boolean pageSizeZero) {
        Page<E> page = new Page(pageNum, pageSize, count);
        page.setReasonable(reasonable);
        page.setPageSizeZero(pageSizeZero);
        Page<E> oldPage = getLocalPage();
        if (oldPage != null && oldPage.isOrderByOnly()) {
            page.setOrderBy(oldPage.getOrderBy());
        }
		// @核心代码
        setLocalPage(page);
        return page;
    }
	protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal();
    protected static boolean DEFAULT_COUNT = true;
    
    protected static void setLocalPage(Page page) {
    	// @核心代码
        LOCAL_PAGE.set(page);
    }

可以看到和我们想的一样,根据分页参数保存了一个Page设置到ThreadLocal中保存起来。

接下来我们看一个核心的类:PageInterceptor

@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 {
    private volatile Dialect dialect;
    private String countSuffix = "_COUNT";
    protected Cache<String, MappedStatement> msCountMap = null;
    private String default_dialect_class = "com.github.pagehelper.PageHelper";

    @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];
            }
            checkDialectExists();

            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);
                    }
                }
                // @1 核心代码1
                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){
            	// @2 核心代码2
                dialect.afterAll();
            }
        }
    }
    xxxxxx......
}

这个类有点长,我们只看核心代码:
1、我们看到最上面的注解:@Intercepts,这是mybatis提供的插件功能,method = "query"说明是针对query方法的插件,简单的可以理解为:这个类可以拦截所有基于mybatis的查询,因为查询接口最后都会调用到query方法,更多插件相关的知识可以查阅mybatis官网
2、上面的代码做了一些是否要分页等判断,我们先姑且不关注,直接关注标记@1的核心代码。ExecutorUtil.pageQuery,这是一个分页查询方法

public static  <E> List<E> pageQuery(Dialect dialect, Executor executor, MappedStatement ms, Object parameter,
                             RowBounds rowBounds, ResultHandler resultHandler,
                             BoundSql boundSql, CacheKey cacheKey) throws SQLException {
    //判断是否需要进行分页查询
    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(ms.getConfiguration(), pageSql, boundSql.getParameterMappings(), parameter);

        Map<String, Object> additionalParameters = getAdditionalParameter(boundSql);
        //设置动态参数
        for (String key : additionalParameters.keySet()) {
            pageBoundSql.setAdditionalParameter(key, additionalParameters.get(key));
        }
        //执行分页查询
        return executor.query(ms, parameter, RowBounds.DEFAULT, resultHandler, pageKey, pageBoundSql);
    } else {
        //不执行分页的情况下,也不执行内存分页
        return executor.query(ms, parameter, RowBounds.DEFAULT, resultHandler, cacheKey, boundSql);
    }
}

关注核心代码@核心代码 dialect.getPageSql

 @Override
    public String getPageSql(MappedStatement ms, BoundSql boundSql, Object parameterObject, RowBounds rowBounds, CacheKey pageKey) {
        return autoDialect.getDelegate().getPageSql(ms, boundSql, parameterObject, rowBounds, pageKey);
    }

getPageSql方法

  @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);
    }

getPageSql方法

getPageSql可以有多个重载,可以看到是基于不同数据库给出的实现方案,我们就先看下MysqlDialet

 @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 ?, ? ");
        }
        return sqlBuilder.toString();
    }

看到这里大概流程已经走完了,无非就是从ThreadLocal中取出Page,然后拦截sql,在后面加上分页参数LIMIT

总结:流程大致为:
1、设置分页参数到ThreadLocal中
2、调用查询,基于插件(拦截器)拦截query方法,从ThreadLocal中获取分页参数,针对不同的数据库类型使用不同的数据库分页方案
3、根据结果包装返回PageInfo,这里注意的是调用了查询方法返回的List实际上是PageHelper的一个内部实现类Page,Page继承自ArrayList,保存了分页使用的参数

调用PageInfo pageInfo = new PageInfo<>(list)

public PageInfo(List<T> list, int navigatePages) {
        super(list);
        // @核心代码 由于查询后返回的具体实现类是Page,会进入这个逻辑
        if (list instanceof Page) {
            Page page = (Page) list;
            // 从Page中拿到各种参数,保证为我们需要的PageInfo
            this.pageNum = page.getPageNum();
            this.pageSize = page.getPageSize();

            this.pages = page.getPages();
            this.size = page.size();
            //由于结果是>startRow的,所以实际的需要+1
            if (this.size == 0) {
                this.startRow = 0;
                this.endRow = 0;
            } else {
                this.startRow = page.getStartRow() + 1;
                //计算实际的endRow(最后一页的时候特殊)
                this.endRow = this.startRow - 1 + this.size;
            }
        } else if (list instanceof Collection) {
            this.pageNum = 1;
            this.pageSize = list.size();

            this.pages = this.pageSize > 0 ? 1 : 0;
            this.size = list.size();
            this.startRow = 0;
            this.endRow = list.size() > 0 ? list.size() - 1 : 0;
        }
        if (list instanceof Collection) {
            this.navigatePages = navigatePages;
            //计算导航页
            calcNavigatepageNums();
            //计算前后页,第一页,最后一页
            calcPage();
            //判断页面边界
            judgePageBoudary();
        }
    }

至此大工告成

PageHelper踩坑

1、上述我们推荐的调用方式是一次性设置分页查询、查询结果、包装结果。
原因如下:
ThreadLocal是把参数保存到当前线程,那么想象一下,如果调用了静态方法PageHelper.startPage后,如果程序抛出了异常,那么后续会发生什么?笔者之前的思想是那么只不过是ThreadLocal中的参数没有被清空而已,只要等待线程被回收即可。

直到在业务代码中排查问题时出现一个问题:业务代码是根据主键查询数据,明明主键id是对的,但是会出现偶发性问题,通过打印日志发现,方法之前明明没有调用PageHelper.startPage,但是却使用了分页逻辑。笔者当时是比较惊讶的,难道PageHelper有线程安全问题?

那么我们理一下逻辑,ThreadLocal不就是为了解决使用共享数据时在不同线程相互不受影响吗?那么必定是不存在线程问题的。那么问题的原因必定是出问题的代码使用的线程的ThreadLocal中提前被设置了,即线程被共享了,但是我们明明没有使用到线程池相关的功能。

那么是不是tomcat在处理请求时使用了线程共享呢?才会导致我们的问题呢,答案是tomcat确实使用了线程池技术,我们设置application配置文件中tomcat的最大线程数为1:server.tomcat.max-threads=1,多次调用接口打印日志:System.out.println(“thread id:”+ Thread.currentThread().getId());
结果如下:
thread id:96
thread id:96
thread id:96
thread id:96
thread id:96
thread id:96
当设置最大线程数为1,多次请求都使用了同一个线程,也就模拟出上述问题的所在了。

2、由上可见,使用ThreadLocal要注意的是使用完后尽可能的去除参数,避免在业务的其他地方受影响
PageHelper是在使用完查询时清空ThreadLocal的参数的,我们可以在PageInterceptor的@2 核心代码2 处看到 dialect.afterAll();在方法内部清空了参数

@Override
public void afterAll() {
    //这个方法即使不分页也会被执行,所以要判断 null
    AbstractHelperDialect delegate = autoDialect.getDelegate();
    if (delegate != null) {
        delegate.afterAll();
        autoDialect.clearDelegate();
    }
    // 清空参数
    clearPage();
}

public static void clearPage() {
	// 清空参数
    LOCAL_PAGE.remove();
}

由此笔者的分享到此为止

【作者水平有限,如有错误欢迎指正】

Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐