本文基于若依3.9.2、SpringBoot3版本。

PageHelper 分页封装:TableDataInfo 与 startPage() 的工作原理
任何一个管理后台都离不开列表页,而列表页绕不开分页:前端既要展示当前页数据,还得知道总条数好渲染分页条。分页看似简单,真动手做却发现涉及读参数、拼排序、加 LIMIT、统计 total、包装返回结构,一整套琐碎逻辑。若依把「开启一次分页查询」和「封装分页结果」这两件事,收敛成了 Controller 里的两个方法,业务层完全不用关心分页细节。
常规分页的 SQL 写法
先看在不借助任何框架的情况下,分页本质是什么。以 MySQL 为例,查询第 2 页(每页 10 条)需要两条 SQL:
- 一条数总数,用于计算总页数:
sql
select count(*) from sys_user where status = '0';
- 一条查当前页数据,用
LIMIT offset, size跳过前面已经展示过的行:
sql
select * from sys_user where status = '0' order by create_time desc limit 10, 10;
这里的 limit 10, 10 第一个数 offset 是偏移量,等于 (pageNum - 1) * pageSize,第二个数是每页条数。这就是分页的全部------本质上只是「对同一份数据做了一次总数统计 + 一次范围截取」。
但手写这套逻辑有几处麻烦:
- 两条 SQL 要成对写:数总数和查列表必须参数一致,容易漏改。
- offset 要自己算 :
(pageNum - 1) * pageSize拼接进 SQL,容易算错。 - 排序字段来自前端 :直接拼进
order by有 SQL 注入风险。 - 非法页码要自己判断:负数、超大页码会返回空页。
- 返回结构要自己封装:前端还得约定 total 和 rows 的字段名。
若依要解决的,就是把这五件繁琐的事全部接管,业务代码只表达「我要分页查询」这个意图。
PageHelper 的分页原理:拦截器五步
若依自己并不实现分页------真正的核心能力由第三方插件 PageHelper 提供,若依只是围绕它做参数与结果的封装。所以先讲清 PageHelper 如何把上面那两条 SQL 自动化。
PageHelper 的分页基于 MyBatis 的拦截器机制。starter 启动时通过 PageHelperAutoConfiguration 自动把 PageInterceptor 注册成 MyBatis 插件,所以在常见的 mybatis-config.xml 里看不到手动注册的痕迹。整个工作流程分五步:
- 存参数 :
PageHelper.startPage(pageNum, pageSize, orderBy)把分页参数封装成一个Page对象,存入当前线程的 ThreadLocal(PageHelper 内部叫LOCAL_PAGE)。分页参数因此不需要在方法间传递,它藏在当前线程上。 - 拦截查询 :随后执行的下一条 MyBatis 查询会被
PageInterceptor拦截(拦截的目标是Executor.query),拦截器从 ThreadLocal 取出这个 Page。 - 改写 SQL :拦截器先统计总记录数------PageHelper 的 count 解析器是「智能」的:能识别出简单查询时直接改写成
select count(0),复杂查询才包成子查询,并会去掉ORDER BY(它对 total 没有意义还拖慢性能);再把原 SQL 拼上数据库方言对应的分页语法(MySQL 是LIMIT offset, size)执行真正的分页查询。 - 包装结果 :查询结果被包装成
Page对象返回。Page<E>继承自ArrayList<E>,所以它本身就是一个 List,但同时携带 total、pageNum、pageSize 等分页信息。 - 自动清理:拦截器执行完后清除 ThreadLocal 里的 Page,这就是分页「只对下一次查询生效、用完即弃」的原因。
对照前面的手写分页:第 3 步对应「数总数的 SQL + 查列表的 SQL」,第 1、2 步解决了「成对写 SQL、算 offset」的重复劳动,第 4 步把 total 藏进了返回的 List。后面 getDataTable() 能从返回结果取出 total,靠的就是第 4 步。
配置文件:几个值得注意的开关
PageHelper 的行为受配置文件控制:
yaml
pagehelper:
helperDialect: mysql # 指定数据库方言,决定分页用 LIMIT 语法
supportMethodsArguments: true # 支持通过 Mapper 方法参数传递分页参数
params: count=countSql # count 查询的参数标识,可在 XML 里自定义 countSql
supportMethodsArguments: true表示除了依赖 ThreadLocal,也支持直接在 Mapper 方法参数里传Page或RowBounds等对象,PageHelper 识别后自动分页,无需先调startPage();params: count=countSql是把 count 映射成一个名为countSql的参数,当 XML 里手写了数总数的语句时,就能通过这个参数引用。
若依分页用到了哪些类
PageHelper 的机制清楚了,再看若依在它之上包了什么。先总览一下若依分页涉及的关键类与方法,各司其职,后面再逐个拆解:
| 类 / 方法 | 位置 | 作用 |
|---|---|---|
BaseController |
common 模块 | 对外暴露 startPage()、getDataTable() 两个入口,是所有 Controller 的基类 |
PageUtils |
common 模块 | 对 PageHelper 的收口封装,发起分页、清理分页,屏蔽 PageHelper 细节 |
TableSupport |
common 模块 | 从 HTTP 请求读出分页参数,构建 PageDomain 对象 |
PageDomain |
common 模块 | 分页参数的载体,内化前端适配、字段转换、默认值兜底 |
TableDataInfo |
common 模块 | 分页查询的结果封装,含 code/msg/rows/total |
PageHelper |
第三方插件 | 拦截 MyBatis 查询,改写 SQL 加 LIMIT、统计 total(核心能力) |
整条链路的数据流是:Controller 调 startPage() → PageUtils 读参数并调 PageHelper → PageHelper 拦截下一条查询改写 SQL → 查询返回 Page 对象 → getDataTable() 封装成 TableDataInfo。
下面从 startPage() 入口开始,沿着这条链路逐环节拆解。
startPage():开启分页
以用户管理列表接口为例,分页查询只差这么几步:
java
public TableDataInfo list(SysUser user) {
startPage();
List<SysUser> userList = userService.selectUserList(user);
return getDataTable(userList);
}
startPage() 定义在 BaseController 中,本身就是一行委托,没有别的逻辑:
java
protected void startPage() {
PageUtils.startPage();
}
真正做事的逻辑落在 PageUtils。它是若依对 PageHelper 的收口封装,直接 extends PageHelper,提供两个静态方法,分别对应 BaseController 暴露的两个分页入口:
java
public class PageUtils extends PageHelper {
public static void startPage() {
PageDomain pageDomain = TableSupport.buildPageRequest();
Integer pageNum = pageDomain.getPageNum();
Integer pageSize = pageDomain.getPageSize();
String orderBy = SqlUtil.escapeOrderBySql(pageDomain.getOrderBy());
Boolean reasonable = pageDomain.getReasonable();
PageHelper.startPage(pageNum, pageSize, orderBy).setReasonable(reasonable);
}
public static void clearPage() {
PageHelper.clearPage();
}
}
startPage() 做的事可以拆成四步:
- 用
TableSupport.buildPageRequest()从请求中构建一个PageDomain对象,拿到分页参数。 - 排序字段交给 PageHelper 之前,先经过
SqlUtil.escapeOrderBySql()安全过滤。 - 调用
PageHelper.startPage(pageNum, pageSize, orderBy)真正开启分页。 - 通过
.setReasonable(reasonable)把分页合理化开关挂到本次分页上。
extends PageHelper 的用意:工具类只有静态方法、继承并无实际的多态意义 ,这更多是风格选择------让 PageUtils 看起来像 PageHelper 的扩展。但这层封装本身是有价值的:把「读请求参数 + 安全过滤 + 调用 PageHelper」收口在一个方法里,BaseController 不直接依赖 PageHelper 的细节,分页逻辑集中一处便于维护。
排序字段的 SQL 注入防护
前面提到手写分页的麻烦之一,就是排序字段来自前端可能被注入。排序字段来自前端 query 参数(orderByColumn、isAsc),最终要拼进 SQL 的 order by 后面,若直接拼接,攻击者可以传 create_time; delete from sys_user 这类内容注入 SQL。escapeOrderBySql 的做法是白名单过滤,只允许字母、数字、下划线、空格、逗号、小数点(这样能支持 create_time desc, update_time asc 这类多字段排序),不符合规范或超过 500 字符就抛异常。
这一步是若依对外部输入进 SQL 时做的关键防御,和查询条件走 MyBatis #{} 预编译是两条并行的安全护栏。
分页合理化:reasonable
另一个手写的麻烦------非法页码,靠 reasonable 解决。reasonable 是分页参数合理化(rationalization):当 pageNum <= 0 时自动修正为 1,当 pageNum > 总页数 时自动修正为最后一页。它解决的是非法页码问题------这里需要注意下,前端传负数页码会返回空数据,传远超总页数的页码(比如总共有 3 页却翻到第 99 页)在 MySQL 下只是 LIMIT 偏移量过大、返回空页,并不会抛 SQL 异常;合理化把这些页码修正到合法范围,避免前端拿到本不该出现的空页。
TableSupport:从请求读取参数
startPage() 第一步就是通过 TableSupport.buildPageRequest() 构建 PageDomain。TableSupport 是 PageDomain 的工厂,定义了五个常量,对应 PageDomain 的五个字段:
java
public static final String PAGE_NUM = "pageNum";
public static final String PAGE_SIZE = "pageSize";
public static final String ORDER_BY_COLUMN = "orderByColumn";
public static final String IS_ASC = "isAsc";
public static final String REASONABLE = "reasonable";
这些常量是作为参数名,从 HTTP 请求中取出对应参数:
java
public static PageDomain getPageDomain() {
PageDomain pageDomain = new PageDomain();
pageDomain.setPageNum(Convert.toInt(ServletUtils.getParameter(PAGE_NUM), 1));
pageDomain.setPageSize(Convert.toInt(ServletUtils.getParameter(PAGE_SIZE), 10));
pageDomain.setOrderByColumn(ServletUtils.getParameter(ORDER_BY_COLUMN));
pageDomain.setIsAsc(ServletUtils.getParameter(IS_ASC));
pageDomain.setReasonable(ServletUtils.getParameterToBool(REASONABLE));
return pageDomain;
}
public static PageDomain buildPageRequest() {
return getPageDomain();
}
这就是默认值 1、10 的来源------Convert.toInt(参数, 默认值) 在参数缺失时回落到兜底值。buildPageRequest() 只是 getPageDomain() 的别名,两者等价。
这层封装把「从 HTTP 请求取参数」的细节收敛在 TableSupport 里,业务代码只需调一个方法就能拿到分页对象,不用到处写 request.getParameter("pageNum")。
PageDomain:分页参数的载体
上一节的 pageNum、pageSize 等参数,最终落到 PageDomain 里被统一携带。它就是分页参数的封装体,对应前端列表组件常用的五个参数:
java
private Integer pageNum; // 当前页数
private Integer pageSize; // 每页显示记录数
private String orderByColumn; // 排序列
private String isAsc = "asc"; // 排序方向 desc 或者 asc
private Boolean reasonable = true; // 分页参数合理化
五个字段中,isAsc 的 set 方法是有讲究的,做了前端兼容:
java
public void setIsAsc(String isAsc) {
if (StringUtils.isNotEmpty(isAsc)) {
if ("ascending".equals(isAsc)) {
isAsc = "asc";
} else if ("descending".equals(isAsc)) {
isAsc = "desc";
}
this.isAsc = isAsc;
}
}
ElementUI 表格的排序组件传给后端的是 ascending/descending 两个值,而 SQL 认的是 asc/desc,所以这里专门做了转换,让前端无需改动就能直接排序。
getOrderBy() 则负责把两者拼成最终的排序表达式:
java
public String getOrderBy() {
if (StringUtils.isEmpty(orderByColumn)) {
return "";
}
return StringUtils.toUnderScoreCase(orderByColumn) + " " + isAsc;
}
toUnderScoreCase() 把驼峰转成下划线:前端传 createTime,这里转成数据库列名 create_time,再拼上方向,最终得到 "create_time desc"。这样前端可以用驼峰字段名,后端排序时自动适配数据库的下划线列名。
reasonable 的 get 方法则提供了默认值兜底:
java
public Boolean getReasonable() {
if (StringUtils.isNull(reasonable)) {
return Boolean.TRUE;
}
return reasonable;
}
若对象没设置该字段,就默认返回 true。这样即便前端没传 reasonable,也能拿到一个安全的默认值(开启合理化)。------从这些细节可以看出,PageDomain 不只是简单的参数容器,还内化了前端适配、字段转换、默认值兜底这些边界处理。
getDataTable():封装分页结果
startPage() 埋好分页参数并执行查询后,拿到的是 PageHelper 包装的 Page 对象,getDataTable() 再把它封装成规定结构:
java
protected TableDataInfo getDataTable(List<?> list) {
TableDataInfo rspData = new TableDataInfo();
rspData.setCode(HttpStatus.SUCCESS);
rspData.setMsg("查询成功");
rspData.setRows(list);
rspData.setTotal(new PageInfo(list).getTotal());
return rspData;
}
rows:当前页数据,就是传入的 list。total:通过new PageInfo(list).getTotal()取得------PageInfo是 PageHelper 提供的分页结果包装类,封装了 total、pageNum、pageSize、pages 等信息,这里只取 total。这里存在一个隐式的健壮性:如果传入的 list 不是Page(比如没调startPage()的全量查询),new PageInfo(list).getTotal()依然不报错 ------它退化为list.size()作为 total。所以getDataTable()既能封装分页结果,误用到未分页的列表上也只是 total 变成了当前条数,不会抛异常。code、msg:固定为 200(HttpStatus.SUCCESS)与「查询成功」,由BaseController定死,调用方无需传入。
TableDataInfo 的结构与分工
TableDataInfo 是分页查询的专属返回对象,只有四个字段:
java
private long total; // 总记录数
private List<?> rows; // 列表数据
private int code; // 消息状态码
private String msg; // 消息内容
返回前端的 JSON 是这个形态:
json
{
"code": 200,
"msg": "查询成功",
"rows": [
{ "userId": 1, "userName": "admin" },
{ "userId": 2, "userName": "ry" }
],
"total": 2
}
前端列表组件按 rows 渲染当前页数据、按 total 渲染分页条、按 code/msg 判断请求状态。
这里需要注意下 TableDataInfo 与 AjaxResult 的分工:两者结构相似,但列表查询接口直接用 TableDataInfo,没有再套一层 AjaxResult 。AjaxResult 用于单对象或增删改等操作结果,结构是 {code, msg, data};TableDataInfo 专用于列表分页查询,结构是 {code, msg, rows, total}。前端对这两套结构有各自固定的取值逻辑,不能混用。
| 返回对象 | 应用场景 | 结构 |
|---|---|---|
AjaxResult |
单对象、增删改等操作结果 | {code, msg, data} |
TableDataInfo |
列表分页查询,专用于展示 rows + total |
{code, msg, rows, total} |
分页与全量的边界:有没有 startPage()
同一个查询方法,分页还是全量,只由查询前有没有 startPage() 决定 。用户列表的 list 走分页、export 走全量:
java
// list:分页
startPage();
List<SysUser> list = userService.selectUserList(user);
return getDataTable(list);
// export:全量
List<SysUser> list = userService.selectUserList(user);
util.exportExcel(response, list, "用户数据");
export 不调 startPage(),PageHelper 对该次查询不注入分页参数,原样执行全量查询。这解释了为何 startPage() 必须紧挨着查询调用------它只对下一次查询生效。
预留的兜底能力:startOrderBy 与 clearPage
除了主流程的 startPage() 和 getDataTable(),BaseController 还提供两个分页相关方法,它们当前在项目中都没有被调用,同属分页机制的一部分,是框架预留的能力。
startOrderBy() 只设置排序、不分页:
java
protected void startOrderBy() {
PageDomain pageDomain = TableSupport.buildPageRequest();
if (StringUtils.isNotEmpty(pageDomain.getOrderBy())) {
String orderBy = SqlUtil.escapeOrderBySql(pageDomain.getOrderBy());
PageHelper.orderBy(orderBy);
}
}
与 startPage() 调 PageHelper.startPage(...) 不同,这里调 PageHelper.orderBy(orderBy),只往 ThreadLocal 挂排序条件、不挂分页参数,所以随后的查询会带 order by 但不会被加 LIMIT。适用「需要全量数据、但要按某字段排序」的场景,比如下拉选项全量加载、按排序字段展示。
clearPage() 用于手动清理 ThreadLocal 里的分页参数:
java
protected void clearPage() {
PageUtils.clearPage();
}
它委托给 PageUtils.clearPage(),最终调 PageHelper.clearPage()。这里需要区分两个场景:正常路径下,PageHelper 的 PageInterceptor 在执行完查询后会在 finally 块里调用方言的 afterAll() 清理 ThreadLocal,即便查询本身抛异常也会清理------所以「查询抛异常」并不会留下残留。
clearPage() 真正兜底的是根本没有触发拦截器清理 的情形:startPage() 之后没有执行任何 MyBatis 查询,比如 startPage() 与查询之间的代码先抛了异常、或分支逻辑跳过了查询,此时拦截器从未运行,ThreadLocal 里的分页参数就会残留,等线程被复用时会污染下一次查询的 SQL。这种时候才需要手动 clearPage()。
PageUtils.clearPage() 对应的就是 PageHelper.clearPage(),手动清空当前线程的分页参数,逻辑很轻。两个方法的分工也清晰:startPage() 是开启分页的入口、clearPage() 是兜底清理。
思考
为什么把分页收敛成 startPage + getDataTable 两步 :分页本身的逻辑极其繁琐------读前端参数、拼排序、处理字段名大小写转换、SQL 注入防护、翻页合理化、统计 total、包装返回结构,但这些在若依里被压成 Controller 里相邻的两行。其价值在于让业务代码只表达「我要分页查询」这个意图,余下的细节全部下沉到 common 模块。代价是这两行之间有隐含约定:startPage() 必须紧挨查询,中间不能插入别的查询。这个约定靠命名和管理员习惯约束,没有强制的编译期保障,是若依在表达力与隐式耦合间的取舍。
为什么用 ThreadLocal 传递分页参数 :分页参数与查询语句之间是「一次请求、一对」的关系,若把它作为参数显式下传,需要穿透 Controller、Service、Mapper 三层,污染每个方法签名。ThreadLocal 让参数「搭便车」随当前线程传递,做到了对业务代码无侵入,也解释了为何它「只对下一次查询生效、用完即弃」------正是拦截器执行完主动清理 ThreadLocal 的结果。代价是分页参数对方法签名不可见、隐式存在,一旦 startPage() 之后没有执行任何查询、清理未触发,线程被复用时残留的分页参数就会串扰到下一次请求,这也是 clearPage() 作为兜底存在的原因。
为什么合理化和排序过滤值得单独做:这两处都是对「外部输入进入 SQL」的防御。合理化处理非法页码、避免返回空页,排序过滤处理恶意排序列、防 SQL 注入。它们都不是 PageHelper 的默认行为,而是若依在封装层主动补齐的边界控制。这提示一个封装原则:引入第三方插件后,不能只做薄薄的转发,还要结合业务输入补齐插件不负责的安全与健壮性校验。