PageHelper 分页封装:TableDataInfo 与 startPage() 的工作原理

本文基于若依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 里看不到手动注册的痕迹。整个工作流程分五步:

  1. 存参数PageHelper.startPage(pageNum, pageSize, orderBy) 把分页参数封装成一个 Page 对象,存入当前线程的 ThreadLocal(PageHelper 内部叫 LOCAL_PAGE)。分页参数因此不需要在方法间传递,它藏在当前线程上。
  2. 拦截查询 :随后执行的下一条 MyBatis 查询会被 PageInterceptor 拦截(拦截的目标是 Executor.query),拦截器从 ThreadLocal 取出这个 Page。
  3. 改写 SQL :拦截器先统计总记录数------PageHelper 的 count 解析器是「智能」的:能识别出简单查询时直接改写成 select count(0),复杂查询才包成子查询,并会去掉 ORDER BY(它对 total 没有意义还拖慢性能);再把原 SQL 拼上数据库方言对应的分页语法(MySQL 是 LIMIT offset, size)执行真正的分页查询。
  4. 包装结果 :查询结果被包装成 Page 对象返回。Page<E> 继承自 ArrayList<E>,所以它本身就是一个 List,但同时携带 total、pageNum、pageSize 等分页信息。
  5. 自动清理:拦截器执行完后清除 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 方法参数里传 PageRowBounds 等对象,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 参数(orderByColumnisAsc),最终要拼进 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() 构建 PageDomainTableSupportPageDomain 的工厂,定义了五个常量,对应 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:分页参数的载体

上一节的 pageNumpageSize 等参数,最终落到 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 变成了当前条数,不会抛异常。
  • codemsg:固定为 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 判断请求状态。

这里需要注意下 TableDataInfoAjaxResult 的分工:两者结构相似,但列表查询接口直接用 TableDataInfo,没有再套一层 AjaxResultAjaxResult 用于单对象或增删改等操作结果,结构是 {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 的默认行为,而是若依在封装层主动补齐的边界控制。这提示一个封装原则:引入第三方插件后,不能只做薄薄的转发,还要结合业务输入补齐插件不负责的安全与健壮性校验。

相关推荐
多敲代码防脱发17 分钟前
Alibaba Sentinel(熔断、限流)(国内下载Sentinel)
java·开发语言·sentinel
.Hypocritical.20 分钟前
【SpringBoot】配置文件加载位置与优先级详解
java·spring boot·后端
JAVA面经实录9171 小时前
RabbitMQ 完整面试题答案
java·rabbitmq·java-rabbitmq
xcl09258 小时前
商超智能运营实战:数据驱动提升门店效率与体验指南
java·mfc·宠物
Flynt10 小时前
OpenJDK禁AI代码半年了,现在执行得怎么样?答案是:全靠自觉
java·开源·ai编程
古法安卓15 小时前
Android-日志系统源码解析
android·java·android studio
小夏coding16 小时前
从"一把梭"到"精妙拆解" —— 滑动窗口计时框架的设计演进
java·后端
MacroZheng17 小时前
同事问我:"Claude Code经常失忆,不怕它把项目搞炸?",我:"怕,三个Markdown文件给它装个永不丢失的外置大脑!"
java·人工智能·后端
十年Java程序媛18 小时前
深度实战:JDK21 虚拟线程 + HikariCP 生产正确配比、坑点、监控全套
java·spring boot