源码分析PageHelper分页后list属性为null问题

PageHelper是github(pagehelper/Mybatis-PageHelper)上的一款基于mybatis拦截器的分页插件,在分页查询代码中使用该插件可以免去自己计算分页和写分页语句的麻烦。

简单调用如下:

1
2
3
4
5
// pageNum为页码,pageSize为每页数据条数
PageHelper.startPage(pageNum, pageSize); ①
// 查询数据列表
List<?> list = service.getList(); ②
PageInfo<?> pageInfo = new PageInfo<>(list); ③

代码片段1 PageHelper简单调用

执行后得到pageInfo有如下数据结构。其中字段list为查出来的数据对象数组,total为总条数,pageNum为当前页码,pages为总页数等等(字段可以参考源码不再赘述)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"list": [
// 对象数组
],
"total": 1,
"pageNum": 1,
"pageSize": 10,
"pages": 1,
"size": 1,
"firstPage": 1,
"lastPage": 1,
"prePage": 0,
"nextPage": 0,
"hasPreviousPage": false,
"hasNextPage": false,
"isFirstPage": true,
"isLastPage": true,
"startRow": 1,
"endRow": 1,
"navigatePages": 8,
"navigatepageNums": [
1
]
}

看起来很不错,但是在使用过程中可能会遇到一类问题:pageInfo.list为null的情况。断点调试会发现代码片段1中的代码②行执行后list有数据,但是到了③行pageInfo执行后,其属性list就是null了。

造成上述问题的原因有如下2点:

  1. 更改了代码②行的list的引用
  2. 不是第一条执行的查询sql

下面依次分析。

1. 更改了代码②行的list的引用

源码分析

更改了list的引用,比如对查出来的list的元素做了修改,并将list变量指向了另一个List对象,这种情况下得到的pageInfo.list就是null。比如下面代码的操作:

1
2
3
4
5
6
7
8
9
10
11
PageHelper.startPage(pageNum, pageSize);
List<?> list = service.getList();
List<Object> listNew = new ArrayList<>();
for (Object obj : list) {
// 修改obj代码略,得到objNew
Object objNew = toNewObj(obj);
listNew.add(objNew);
}
// 改变list的引用
list = listNew; ④
PageInfo<?> pageInfo = new PageInfo<>(list);

代码片段2 更改了list的引用

实际编码中经常会有这种操作,那该如何解决呢?下面从源码来看看其实现机制,回到代码片段1,执行②行得到list没有任何问题,然后③行构造pageInfo对象出了问题,来看看图1,是PageInfo类的构造函数代码(代码片段1中③行调用的是重载方法,默认navigatePages为8):

图1 PageInfo构造函数(方法后部分无关紧要的代码省略)

可以看到构造时判断了list参数是否为Page类型,如果是才会执行list属性的赋值,否则保留属性初始值,即null。而代码片段2中④行list是List类型的对象,不是Page对象,所以最终得到的pageInfo的list属性为null。

解决方案

问题找到了,那么将④行代码的List定义成Page类型,然后再将各个属性拷贝到新的对象即可。

1
2
3
4
5
6
7
8
9
10
11
PageHelper.startPage(pageNum, pageSize);
List<?> list = service.getList();
// 查询出来的List对象为Page对象,先将引用转换为Page类型
Page<Object> page = (Page) list;
// 创建新的Page对象
Page<Object> pageNew = new Page<>();
// 拷贝转换后的列表,Page继承自ArrayList
pageNew.addAll(toNewObjList(list));
// 拷贝属性,其他属性拷贝代码略
pageNew.setTotal(page.getTotal());
return new PageInfo<>(pageNew);

Page对象只提供了如图2所示的setter方法,如果需要拷贝其他属性,需要自定义类型并继承Page类,再添加setter方法,另外,在项目中也可以编写工具类来处理种种转换问题。

图2 Page类的setter方法

2. 不是第一条执行的查询sql

源码分析

当代码片段1中②行的service.getList()要执行多个sql查询,而需要分页的sql不是第一条时,也会导致pageInfo.list为null。比如下面的service.getList()代码:

1
2
3
4
5
// 查询当前的业务id
String id = dao.getCurrentId();
// 再用业务id查询列表
List<?> list = dao.getList(id);
return list;

代码片段3 service.getList()的代码

可以看到在分页的sql执行前还执行了一条sql(查询当前id的sql),就是这条先执行的sql让最终pageinfo的list属性为null。经过断点跟踪,发现最根本的原因和第一种原因是一样的,即list不是Page的对象,但是为什么不是Page对象呢?

先大概说下PageHelper实现原理,即利用Mybatis的拦截器实现的分页。在sql查询前进行拦截,然后执行sql的count查询和limit参数计算,再将sql和limit语句一并提交到Mysql服务器进行查询,最终得到分页后的数据。PageHelper就是拦截器对象,实现了org.apache.ibatis.plugin.Interceptor接口,重写了接口的intercept方法,图3是intercept方法源码:

图3 PageHelper类的intercept方法

跟踪方法processPage,进入到图4代码(注意后面的clearLocalPage()也是关键点):

图4 SqlUtil类的processPage方法

跟踪方法_processPage,进入到图5代码:

图5 SqlUtil类的_processPage方法

可以看到,该方法进入到了PageHelper的逻辑部分,有个if条件判断,前一个分支代码会执行invocation.process(),而后一个分支代码会执行Page page = getPage(rowBounds),最终会返回Page对象。(从图5末尾执行到返回Page对象之间的代码冗长,截图略)

if判断的关键就是前半句判断语句SqlUtil.getLocalPage(),如果该代码返回null则返回原始查询结果,否则返回分页后的Page对象,跟踪getLocalPage()进入到图6代码:

图6 SqlUtil类的getLocalPage方法

可以看到从LOCAL_PAEG中获得Page对象,而LOCAL_PAEG是ThreadLocal对象,对LOCAL_PAEG的set方法的调用进行反向查询,发现图7代码:

图7 SqlUtil类的setLocalPage方法

继续反向查询setLocalPage方法,发现图8代码:

图8 PageHelper类的startPage方法

这个startPage方法不正是代码片段1的①行调用的吗,也就是说执行startPage方法后就可以让PageHelper返回Page对象。

回到图5的_processPage方法代码,当代码执行完成后回到图4的processPage方法,最终会执行图4末尾处的clearLocalPage方法,跟踪该方法,有如下代码:

图9 SqlUtil类的clearLocalPage方法

可以看到,当processPage方法执行(即sql执行)完毕后会清除LOCAL_PAEG对象,如果清除了LOCAL_PAEG对象,那么PageHelper就不会返回Page对象了,清除时机是每次执行完成一次sql后。

那么现在PageHelper分页的执行流程清楚了:

  1. 查询前执行PageHelper.startPage,LOCAL_PAEG会设值。

  2. Mybatis拦截器(即PageHelper)拦截查询sql,执行SqlUtil._processPage方法时,判断LOCAL_PAEG是否为null,是则返回原结果对象,否则返回Page。因为上一步对LOCAL_PAEG进行了设值,那么_processPage会执行分页和返回Page对象的代码。

  3. sql执行完毕后执行clearLocalPage清除LOCAL_PAEG。

  4. 获得查询结果list,并将其构造为PageInfo对象,得到最终有分页信息的数据对象。

那么回到代码片段3(service.getList)代码,PageHelper.startPage执行后,紧接的sql是dao.getCurrentId(),因此该sql将执行分页,执行完成后清除LOCAL_PAEG,然后再执行第二条sql,即dao.getList(id),此时LOCAL_PAEG已经清除,那么将不会执行分页,返回的对象也不是Page对象,所以PageInfo构造后得到的list也为null了。

解决方案

可以看出PageHelper.startPage是一个执行分页的开关,当设置后紧随其后的那条查询sql将启用分页。 那么将代码按如下修改即可。

  1. 调用的代码:
    1
    2
    3
    4
    // 这里不再调用startPage
    // 查询列表业务
    List<?> list = service.getList();
    PageInfo<?> pageInfo = new PageInfo<>(list);
  2. 代码片段2(service.getList)修改为:
    1
    2
    3
    4
    5
    6
    7
    // 查询当前的业务id
    String id = dao.getCurrentId();
    // 再用业务id查询列表
    // startPage改到此处调用
    PageHelper.startPage(pageNum, pageSize);
    List<?> list = dao.getList(id);
    return list;