基于Mybatis的分页控制

分页是基于WEB的应用绕不开的话题,一般情况下基于Mybatis的java项目可选的分页方案包括:

  1. 开源项目PageHelper。
  2. 基于Mybatis的RowBounds分页。
  3. Mybatis-plus分页。
  4. 自己实现的分页方案。

今天我们主要分析前两种分页方案,Mybatis-plus的分页放在下一篇文章中分析。除非有特殊原因,一般情况下也不太建议自己再去实现分页方案,因为无论是PageHelper还是Mybatis-plus的分页方案,绝大部分情况下也够用了,没有必要重复造轮子。

物理分页和逻辑分页

一般来讲,分页针对的是执行数据库查询的时候,符合条件的数据有很多、但是前端页面一次不需要展示全部数据的应用场景。

在这一场景下,应用层获取当前页数据的方案自然就分为两种:一种是应用层向数据库获取所有满足条件的数据,然后在应用层内存中对结果集进行过滤、获取到当前页数据后返回给前端。另一种需要数据库的支持,应用层只向数据库请求当前页的数据、获取数据后直接返回给前端。

第一种方式就是我们常说的逻辑分页,也可以叫内存分页。第二种方式就是物理分页。

两种方式的区别其实一目了然,逻辑分页不止是对数据库有内存、性能的压力,而且对于网络传输、应用层内存都会存在性能压力。尤其是在满足条件的数据量特别大(比如10w条)、而当前页需要的数据量比较小(一般情况下都会比较小,比如10条)的情况下,应用层获取到的绝大部分数据都被丢弃了,所以对于数据库服务器内存、网络、应用服务器内存都是一种极大地浪费。

而物理分页由于从数据库获取到的数据量比较小,所以性能压力比较小。

因此,正式项目即使前期能够判断将来数据量不会太大的情况下,也不建议使用逻辑分页方案。

当然,物理分页需要数据库的支持,比如MySQL的limit,Oracle的rownum等等,目前大部分的主流数据库都可以提供类似的支持。

基于Mybatis的RowBounds的分页实现

Mybatis内置提供了基于RowBounds的分页方案,只要我们在mapper接口中提供RowBounds参数,Mybatis自然就可以帮我们实现分页。

但是我们必须要知道,RowBounds是逻辑分页!所以我们用学习了解的态度来研究一下RowBounds分页方案的实现机制,项目中不建议直接使用。

基于RowBounds的分页实现非常简单,只要在mapper接口中设置RowBounds参数即可,比如获取所有用户的接口:

List selectPagedAllUsers(RowBounds rowBounds);

List selectAllUsers();

在mapper.xml文件中上述两个接口对应的sql语句可以完全一样,selectPagedAllUsers是实现分页的接口,selectAllUsers是不分页的接口。

那么我们应该怎么传递RowBounds呢?首先要了解一下RowBounds具体是个什么东东。

其实RowBounds的定义很简单,最重要的两个参数,offset其实就是起始位置,limit可以理解为每页行数。

 private final int offset;
 private final int limit;

  public RowBounds(int offset, int limit) {
    this.offset = offset;
    this.limit = limit;
  }

转换为我们比较熟悉的概念,currentPage表示当前页,pageSize表示每页行数,则Dao层获取分页数据的方法可以这么写:

      
public List getPagedUser(int currentPage,    int pageSize){
    int offset=(currentPage - 1) * pageSize;
    RowBounds rowBounds=new RowBounds(offset,pageSize);
    return UserMapper.selectPagedAllUsers(rowBounds);
}

你可以找一个数据量比较大的表试一下,性能是可以直观感受到的(慢)。

PageHelper的分页原理

PageHelper是利用Mybatis拦截器实现分页的,他的基本原理是:

  1. 应用层在需要分页的查询执行前,设置分页参数。
  2. 使用Mybatis的Executor拦截器拦截所有的query请求。
  3. 在拦截器中检查当前请求是否设置了分页参数,没有设置分页参数则执行原查询返回所有结果。
  4. 如果当前查询设置了分页参数,则执行分页查询:根据数据库类型改造当前的查询sql语句,增加获取当前页数据的sql参数,比如对于mysql数据库,在sql语句中增加limit语句。
  5. 执行改造后的分页查询,获取数据返回。

PageHelper分页的实现#RowBounds方式

Springboot项目应用PageHelper实现分页非常简单,pom.xml中引入依赖即可:


    com.github.pagehelper
    pagehelper-spring-boot-starter
    1.4.1

除了我们前面提到过的,通过PageHelper.startpage()设置分页参数之外,PageHelper还有另外一个设置分页参数的方法就是通过RowBounds。

通过RowBounds进行分页参数的设置从而实现分页的方式这种情况下参数offset-as-page-num会生效,设置为true的话代表RwoBounds的offset会作为pageNum,limt会作为pageSize参数使用。

引入PageHelper之后,再跑上一节RowBounds的那个例子,就会发现分页最终是变成了通过PageHelper实现、而非Mybatis原生的RowBounds实现的。

如果加上我们前面文章说过的打印sql语句、统计sql执行时长的Mybatis拦截器(必须确保打印sql语句的拦截器在PageHelper拦截器之前初始化),会发现打印出来的sql语句多了limit语句。而且,如果我们是对数据量比较大(比如10w条数据)的表执行分页查询的话(比如查询最后一页),会发现通过Mybatis原生RowBounds实现的分页查询要比PageHelper的RowBounds方式慢很多。

PageHelper#RowBounds方式的实现原理

PageHelper的源码不算复杂,跟踪一下就可以发现使用RowBounds传递分页参数的底层原理。

PageHelper分页拦截器执行过程中会调用到PageParameters的getPage方法:

public Page getPage(Object parameterObject, RowBounds rowBounds) {
        Page page = PageHelper.getLocalPage();
        if (page == null) {
            if (rowBounds != RowBounds.DEFAULT) {
                if (offsetAsPageNum) {
                    page = new Page(rowBounds.getOffset(), rowBounds.getLimit(), rowBoundsWithCount);
                } else {
                    page = new Page(new int[]{rowBounds.getOffset(), rowBounds.getLimit()}, rowBoundsWithCount);
                    //offsetAsPageNum=false的时候,由于PageNum问题,不能使用reasonable,这里会强制为false
                    page.setReasonable(false);
                }
                if(rowBounds instanceof PageRowBounds){
                    PageRowBounds pageRowBounds = (PageRowBounds)rowBounds;
                    page.setCount(pageRowBounds.getCount() == null || pageRowBounds.getCount());
                }
            } else if(parameterObject instanceof IPage || supportMethodsArguments){
                try {
                    page = PageObjectUtil.getPageFromObject(parameterObject, false);
                } catch (Exception e) {
                    return null;
                }
            }
            if(page == null){
                return null;
            }
            PageHelper.setLocalPage(page);
        }
        //分页合理化
        if (page.getReasonable() == null) {
            page.setReasonable(reasonable);
        }
        //当设置为true的时候,如果pagesize设置为0(或RowBounds的limit=0),就不执行分页,返回全部结果
        if (page.getPageSizeZero() == null) {
            page.setPageSizeZero(pageSizeZero);
        }
        return page;
    }

可以看到首先还是要去获取通过startPage设置的分页参数,如果获取到的话就不会管RowBounds了。

所以我们要知道PageHelper还是以startPage优先的。

否则如果没有通过startPage方法设置分页参数,系统就会用rowBounds的offset和limit作为分页参数去new一个page对象,同时把page对象设置到PageHelper的LocalPage中并返回,PageHelper的LocatPage是ThreadLocal变量,把新创建的page对象保存在PageHelper的LocalPage中其实就是模拟了startPage的操作,这个操作之后,rowBounds方式传递分页参数就和startPage方式走到同一条道路上去了。

PageHelper#startPage方式

执行分页查询前,应用层调用PageHelper的startPage设置分页参数的方式。

startPage使用了ThreadLocal对象,ThreadLocal是线程级参数,为了避免同一请求线程中的多个查询语句的分页控制不造成互相影响,要求应用层必须正好在需要分页的查询执行前后设置、清空分页参数。

个人认为这是对PageHelper#startPage方式下的一个限制条件,我们在使用PageHelper的时候必须注意、做好控制,否则可能会有意想不到的后果、或者导致应用的bug。

比如,我们要获取到用户A有权限的所有销售订单数据,这个过程中可能至少要有两条sql语句要执行,第一条sql语句获取用户A的权限,第二条sql根据用户A的权限获取销售订单数据,我们需要对第二条获取销售订单数据的sql语句进行分页,伪代码如下:

PageHelper.startPage(1,10);
permission.get(userA); //执行获取用户A权限的sql
sellOrder.getOrder(); //执行获取订单数据的sql

以上伪代码会导致获取用户权限的调用也被分页,这并不是我们想要的结果(也可能会导致bug)。而正确的伪代码应该是:

permission.get(userA); //执行获取用户A权限的sql
PageHelper.startPage(1,10);
sellOrder.getOrder(); //执行获取订单数据的sql
PageHelper.clearPage();

需要注意的不仅仅是startPage的位置,而且还必须要有clearPage的调用,否则,如果不调用clearPage清理分页参数的话,当前交易的后续其他sql也会被影响。

我们从PageHelper的源码角度分析一下startPage的实现原理。

先看一下startPage干了啥:以pageNum和pageSize为参数创建Page对象并调用setLocalPage方法。而setLocalPage方法就是把创建的Page对象存储在ThreadLocal对象中。

public static  Page startPage(int pageNum, int pageSize, boolean count, Boolean reasonable, Boolean pageSizeZero) {
        Page page = new Page(pageNum, pageSize, count);
        page.setReasonable(reasonable);
        page.setPageSizeZero(pageSizeZero);
        //当已经执行过orderBy的时候
        Page oldPage = getLocalPage();
        if (oldPage != null && oldPage.isOrderByOnly()) {
            page.setOrderBy(oldPage.getOrderBy());
        }
        setLocalPage(page);
        return page;
    }

之后在执行查询语句的时候,PageHelper的Executor拦截器PageInterceptor拦截到query方法后,通过ThreadLocal变量就能获取到Page对象从而获取到分页参数,之后就可以重新加工查询的sql语句增加limit语句从而实现分页。

PageHelper#startPage测试

还是在前面有关Mybatis的相关文章中用过的demo的基础上做测试,拦截器也都保留。

controller增加一个方法:

   @ResponseBody
    @RequestMapping("allsellforms")
    public List allSellforms(int pageIndex,int pageSize){
        return sellFormService.getAllSellforms(pageIndex,pageSize);
    }

service层代码省略,调用到dao层(也可以不要Dao,直接在service中调用mapper接口方法):

    public List fetchSellForms(int pageIndex,int pageSize) {
        //RowBounds rowBounds=new RowBounds(pageIndex,pageSize);
        //return sellFormMapper.selectSellForms(rowBounds);
        PageHelper.startPage(pageIndex,pageSize);
        List sellForms=sellFormMapper.selectSellForms();
        PageHelper.clearPage();
        return sellForms;
    }

用controller传递进来的pageIndex和pageSize参数调用startPage,然后执行查询,记得执行查询之后再通过clearPage清理分页参数。

PageHelper#mapper接口传递分页参数

可以通过mapper接口参数来传递分页参数从而实现分页。而且PageHelper可以支持两种参数传递的方式:

  1. 通过实现了IPage接口的对象传递分页信息。
  2. 设置methods-arguments为true,并设置param参数或者使用默认参数名传递分页信息。

IPage接口:实现IPage接口后,PageHelper通过IPage接口的getPageNum()、getPageSize()来获取分页参数。

methods-arguments:设置为true后,可以通过param参数指定pageNum、PageSize等分页信息的属性名称,也可以不指定,则系统采用默认的属性获取分页信息:

pageNum=pageNum;
pageSize=pageSize;
count=countSql;
reasonable=reasonable;
pageSizeZero=pageSizeZero;

小结

Mybatis项目的分页应该是一个刚性需求,绝大部分的项目都需要,PageHelper在绝大部分情况下应该也足够支持项目的分页需求了。

PageHelper提供了非常灵活的分页参数传递方式,其中通过startPage传递分页参数的方式存在一定的限制、某些场景下使用不当可能会导致意想不到的问题,在使用过程中一定要注意。

startPage方式存在的限制,可以在项目中想办法解决,比如可以使用IPage接口的方式、参数对象的方式,也可以在startPage的基础上进行一定的封装,比如可以想办法将分页信息和MapperdStatement的id进行匹配或绑定等等,总之PageHelper已经提供了一定的灵活性,我们在项目上可以根据具体情况进行一定的扩展,实现扬长避短灵活应用。

以上!

上一篇 Mybatis拦截器顺序

你可能感兴趣的:(基于Mybatis的分页控制)