Appearance
PageHelper 两种分页写法对比:一次泛型失真的排查与源码分析
一、问题背景:泛型写错了,接口居然还能正常返回
在一个:
text
Java 8
Spring Boot 2.2.10.RELEASE
MyBatis
pagehelper-spring-boot-starter 1.3.0项目中,对两个分页接口做对比时,发现了一个比较反直觉的现象。
第一种写法声明返回:
java
PageInfo<DemoPageQueryVo>但实际 SQL 查询出来的对象并不是:
text
DemoPageQueryVo甚至把返回值改成:
java
PageInfo<Integer>接口仍然能够返回完整的对象 JSON。
也就是说:
Java 代码里的泛型看起来写成什么,对实际返回的数据几乎没有影响。
而另一种更传统的写法:
java
List<DemoPageQueryResp> list = queryListFromDb(req);
return new PageInfo<>(list);表现却完全符合直觉。
于是问题变成:
为什么同样使用 PageHelper,两种写法在泛型语义上会有这么大的差异?
二、复现环境
本次复现使用:
text
Java 8
Spring Boot 2.2.10.RELEASE
MyBatis
pagehelper-spring-boot-starter 1.3.0Maven 依赖:
xml
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.3.0</version>
</dependency>本文分析基于上述版本环境下的实际行为与源码。
完整示例代码如下:
测试接口:
text
/demo/pageQuery1
/demo/pageQuery2请求示例:
bash
curl -X POST \
-H 'content-type: application/json;charset=UTF-8' \
'http://localhost:8000/demo/pageQuery1' \
-d '{"pageNum":1,"pageSize":2}'以及:
bash
curl -X POST \
-H 'content-type: application/json;charset=UTF-8' \
'http://localhost:8000/demo/pageQuery2' \
-d '{"pageNum":1,"pageSize":2}'这里需要说明:
本文重点分析的是这两种 API 写法与泛型行为,不依赖某个特定业务 SQL。
三、第一种写法:doSelectPageInfo
第一种分页代码如下:
java
public PageInfo<DemoPageQueryVo> pageQuery1(DemoPageQueryReq req) {
PageInfo<DemoPageQueryVo> pageInfo =
PageHelper.startPage(req.getPageNum(), req.getPageSize())
.doSelectPageInfo(() -> {
queryListFromDb(req);
});
return pageInfo;
}这里从代码表面上看,返回值非常明确:
java
PageInfo<DemoPageQueryVo>似乎 PageHelper 最终应该得到:
text
DemoPageQueryVo类型的数据。
但实际并不是这样。
四、第一种异常现象:DemoPageQueryVo 只有 id,JSON 却还有很多字段
例如:
java
DemoPageQueryVo只有:
text
id一个字段。
但接口实际返回 JSON 中却包含 SQL 查询出来的多个字段。
这说明:
PageInfo<DemoPageQueryVo>这个泛型声明并没有把查询结果转换成DemoPageQueryVo。
进一步测试,把返回类型故意写成:
java
PageInfo<Integer>接口依然能够返回原来的完整对象内容。
这个现象说明:
这里的泛型并没有参与运行时查询结果对象的构造。
它更多是在编译期告诉 Java:
text
“把这个 PageInfo 当成装着某种类型来使用”但真实对象到底是什么,仍然取决于 MyBatis 查询结果。
五、第二种写法:显式接收查询结果
第二种代码如下:
java
public PageInfo<DemoPageQueryResp> pageQuery2(DemoPageQueryReq req) {
PageHelper.startPage(req.getPageNum(), req.getPageSize());
List<DemoPageQueryResp> list = queryListFromDb(req);
return new PageInfo<>(list);
}这段代码的类型关系要直观得多:
text
Mapper 查询
↓
List<DemoPageQueryResp>
↓
new PageInfo<>(list)
↓
PageInfo<DemoPageQueryResp>如果:
java
queryListFromDb(req)返回的是:
java
List<DemoPageQueryResp>那么最终:
java
new PageInfo<>(list)的泛型也自然是:
text
DemoPageQueryResp代码阅读者不需要知道 PageHelper 内部还有什么隐式行为,就能直接判断:
SQL 结果是什么类型,分页结果就是什么类型。
六、为什么 doSelectPageInfo 的泛型可以“随便写”
问题的关键可以从本次 pagehelper-spring-boot-starter 1.3.0 对应实现中的 Page#doSelectPageInfo 看出来。
核心逻辑类似:
java
public <E> PageInfo<E> doSelectPageInfo(ISelect select) {
select.doSelect();
return (PageInfo<E>) this.toPageInfo();
}这里有两个非常关键的细节。
第一个是:
java
ISelect第二个是:
java
(PageInfo<E>)先看 ISelect。
它执行查询的入口本身不通过返回值把查询结果类型传给 doSelectPageInfo。
也就是说,调用:
java
.doSelectPageInfo(() -> {
queryListFromDb(req);
})时,doSelectPageInfo 并没有从 lambda 的返回值中获得:
text
DemoPageQueryResp这种类型信息。
查询确实执行了,但实际查询结果是通过 PageHelper 分页机制进入当前 Page 对象,而不是靠这个 lambda 的返回值传回来。
七、真正的查询结果类型仍然由 MyBatis 决定
假设 Mapper XML 中配置的是:
xml
<select id="queryListFromDb"
resultType="com.xxx.DemoPageQueryResp">那么 MyBatis 在执行 SQL 时,真正创建的元素对象就是:
text
DemoPageQueryRespPageHelper 不会因为调用处写了:
java
PageInfo<DemoPageQueryVo>就自动把:
text
DemoPageQueryResp转换成:
text
DemoPageQueryVo同样也不会因为写成:
java
PageInfo<Integer>就把数据库结果转换成:
text
Integer真实数据对象仍然由:
text
Mapper 方法
resultType / resultMap
MyBatis 映射决定。
所以第一种写法真正发生的是:
text
Java 声明:
PageInfo<DemoPageQueryVo>
运行时内容:
DemoPageQueryResp泛型声明和实际元素类型出现了偏离。
八、PageHelper 真正做了什么:分页查询结果本身就是 Page
理解第二种写法时,还有一个非常关键的事实。
调用:
java
PageHelper.startPage(pageNum, pageSize);后,紧跟着执行的 MyBatis 查询会被 PageHelper 拦截。
正常代码看起来是:
java
List<DemoPageQueryResp> list = queryListFromDb(req);但在分页场景下,这个 list 的实际运行时类型通常是:
text
com.github.pagehelper.Page而 Page<E> 本身继承自:
java
ArrayList<E>因此它既可以作为:
java
List<DemoPageQueryResp>使用,又额外保存了:
text
pageNum
pageSize
total
pages
startRow
endRow等分页信息。
这就是为什么下面的代码可以正常拿到总记录数:
java
PageHelper.startPage(req.getPageNum(), req.getPageSize());
List<DemoPageQueryResp> list = queryListFromDb(req);
PageInfo<DemoPageQueryResp> pageInfo = new PageInfo<>(list);因为这里传给 PageInfo 的并不是一个完全普通的 ArrayList,而是 PageHelper 返回的 Page。
九、new PageInfo<>(list) 为什么还能保留总数和页码
第一眼看第二种写法时,还有一个疑问:
java
new PageInfo<>(list)只是把一个 List 传进去,它怎么知道:
text
总记录数
当前页
每页条数
总页数这些信息?
关键仍然在于:
text
list 的运行时类型是 PagePageHelper 官方用法本身就支持:
java
PageHelper.startPage(1, 10);
List<User> list = userMapper.selectAll();
PageInfo<User> pageInfo = new PageInfo<>(list);PageInfo 在构造时能够识别这个列表实际是一个 Page,并读取其中已经保存的分页信息。
因此第二种写法并不是:
查询完成后重新计算了一次分页信息。
而是:
显式拿到 PageHelper 已经生成的分页结果,再把它包装成 PageInfo。
十、这里并不是 PageHelper “不知道查询结果”
原文最初曾把第一种现象概括为:
PageHelper 不知道 select 出来的是什么类型。
这句话容易产生一点误解。
更准确的说法应该是:
doSelectPageInfo的泛型参数E并不是从ISelect的返回类型推导出来的。
PageHelper 当然拿到了真实查询结果,否则也无法生成:
text
Page
PageInfo问题在于:
text
真实查询结果中的对象类型和:
text
doSelectPageInfo 调用点声明的泛型 E之间没有编译器可以验证的强类型关联。
于是下面这种代码在语法上就可能成立:
java
PageInfo<Integer> pageInfo =
PageHelper.startPage(1, 10)
.doSelectPageInfo(() -> queryListFromDb(req));但运行时里面装着的仍可能是:
text
DemoPageQueryResp真正的问题因此不是“PageHelper 没拿到结果”,而是:
API 的泛型声明与实际查询元素类型之间缺少类型约束。
十一、为什么这种写法容易误导维护者
假设维护人员看到:
java
public PageInfo<DemoPageQueryVo> pageQuery1(...)第一反应通常是:
这个方法返回的列表元素就是
DemoPageQueryVo。
这也是 Java 泛型代码通常给人的语义保证。
但在这里,这个推断并不可靠。
真正决定元素类型的可能是另一个地方:
text
Mapper 方法签名
Mapper XML
resultType
resultMap于是阅读一个分页方法时,还必须继续向下追:
text
这个 ISelect 到底执行了什么 SQL?
Mapper 真正返回什么?
PageHelper 里面实际塞进去的对象是什么?这就破坏了方法签名原本应该提供的类型信息。
从维护角度看,这才是第一种写法最大的问题。
十二、为什么更推荐显式 List<T> 的写法
第二种方式:
java
PageHelper.startPage(req.getPageNum(), req.getPageSize());
List<DemoPageQueryResp> list = queryListFromDb(req);
return new PageInfo<>(list);虽然比链式调用多了一行,但它把类型关系直接摆在代码里:
text
queryListFromDb
↓
List<DemoPageQueryResp>
↓
PageInfo<DemoPageQueryResp>这有几个明显好处。
12.1 Mapper 返回类型直接可见
代码里明确出现:
java
List<DemoPageQueryResp>维护人员不需要猜。
12.2 泛型更容易由编译器约束
如果方法真正要返回:
java
PageInfo<DemoPageQueryResp>那么整个类型链条是一致的。
12.3 数据来源更清楚
分页只是:
text
对这次 Mapper 查询进行分页而不是把查询动作隐藏在:
java
doSelectPageInfo(() -> ...)里面。
12.4 更符合普通 Java 代码阅读习惯
先获得数据:
java
List<T>再包装:
java
PageInfo<T>没有额外的泛型错觉。
十三、两种写法真正的区别
可以把两种方式简化成下面的对比。
第一种:
java
PageInfo<DemoPageQueryVo> pageInfo =
PageHelper.startPage(...)
.doSelectPageInfo(() -> queryListFromDb(req));类型关系更像:
text
调用方自己声明 E
↓
ISelect 执行查询
↓
PageHelper 保存真实查询结果
↓
强制转换成 PageInfo<E>第二种:
java
PageHelper.startPage(...);
List<DemoPageQueryResp> list = queryListFromDb(req);
return new PageInfo<>(list);类型关系是:
text
Mapper 返回 List<DemoPageQueryResp>
↓
分页时实际对象为 Page<DemoPageQueryResp>
↓
PageInfo<DemoPageQueryResp>第二种更容易让:
text
方法签名
Mapper 类型
实际数据保持一致。
十四、这次源码排查修正了一个认知
最初理解 PageHelper 时,容易把它想象成:
text
PageHelper 执行查询
↓
拿到普通 List
↓
再包装成 PageInfo实际机制并不是这么简单。
更准确的理解是:
text
PageHelper.startPage
↓
保存本次分页上下文
↓
MyBatis 执行下一次查询
↓
PageHelper 拦截查询
↓
分页结果进入 Page
↓
Page 本身既是 List,又保存分页信息
↓
PageInfo 再从 Page 提取分页元数据而:
java
doSelectPageInfo(...)只是把:
text
执行查询
+
把当前 Page 转成 PageInfo组合到了一个 API 里。
真正需要警惕的不是 PageHelper 的分页能力,而是:
doSelectPageInfo这种ISelect + 泛型强转的 API 形式,无法让编译器验证调用方写下的E是否就是 Mapper 真正返回的元素类型。
十五、统一项目中的分页写法
如果只是讨论:
text
能不能分页两种方式都能工作。
但旧项目长期维护时,更重要的是:
text
代码签名表达的类型和:
text
实际运行数据类型是否保持一致。
因此在项目中更推荐统一使用:
java
PageHelper.startPage(req.getPageNum(), req.getPageSize());
List<DemoPageQueryResp> list = queryListFromDb(req);
return new PageInfo<>(list);并保持:
text
Mapper 返回类型
=
PageInfo<T> 的 T
=
接口真正返回的数据类型这样一个分页方法本身就能够把类型关系说明白。
而不是依赖维护人员了解:
text
ISelect
Page
ThreadLocal
拦截器
未经检查的泛型转换之后,才能判断方法到底返回什么。
十六、一句话规范
这次排查最终可以收敛成一条项目规范:
使用 PageHelper 时,优先显式接收 Mapper 返回的
List<T>,再构造PageInfo<T>;让T来自真实查询结果,而不是在doSelectPageInfo调用处人为指定。
这样做并不是因为:
text
doSelectPageInfo 不能分页而是为了让:
方法签名、Mapper 返回类型和运行时数据保持一致,让分页代码所见即所得。