基于 MyBatis PageHelper 自定义 PageUtil 的分页实践指南


一、序列化的核心价值与分页流程设计

1. 序列化的核心作用

在分页场景中,序列化(如 PageSerializable)不仅是简单的数据格式转换,更是系统性能与安全的重要保障。其核心价值体现在以下方面:

  • 数据精简与传输优化

    通过 PageSerializable 或自定义的 PageVO,过滤掉分页插件内部生成的中间数据(如 countSql、分页参数对象),仅保留 total(总条目数)和 list(当前页数据)。
    示例对比

    • 未序列化时返回的 PageInfo 对象包含 10+ 字段(如 pageNumpageSizepagesprePage 等),但前端通常仅需 totallist
    • 序列化后响应数据量减少 50% 以上,显著降低网络传输开销。
  • 安全性与隐私保护

    避免直接暴露 SQL 执行细节(如 orderBy 字段、count 语句),防止潜在的信息泄露风险。

  • 标准化接口规范

    统一所有分页接口的响应格式(如 codemsgdata 包裹 PageVO),提升前后端协作效率。

2. 分页全流程解析

完整的分页流程可分为 参数接收、校验、SQL 拦截、分页查询、结果封装 五个阶段,以下是详细步骤分解:

  1. 参数接收

    • 前端通过 HTTP 请求传递分页参数(pageNumpageSize)和排序规则(columnsorders)。
    • 后端通过 PageDTO 对象接收参数,并添加 @Valid 注解触发自动校验。
  2. 参数校验

    java 复制代码
    public class PageDTO implements IPage {
        @NotNull(message = "pageNum 不能为空")
        private Integer pageNum;
        
        @NotNull(message = "pageSize 不能为空")
        @Max(value = 500, message = "每页大小不能超过500")
        private Integer pageSize;
    }
    • 校验内容
      • 非空校验(pageNumpageSize 必填)。
      • 数值范围校验(如 pageSize <= 500 防止内存溢出)。
    • 校验方式
      通过 Spring Validation 或自定义工具类实现。
  3. SQL 拦截与改写

    PageHelper 通过 MyBatis 插件机制拦截 SQL 执行流程,核心步骤如下:

    • Step 1: 设置分页参数

      java 复制代码
      PageHelper.startPage(pageDTO.getPageNum(), pageDTO.getPageSize());

      将分页参数存储到 ThreadLocal 中,供后续插件读取。

    • Step 2: 解析原始 SQL
      拦截 Executor.query() 方法,获取待执行的 SQL 语句。

    • Step 3: 动态拼接 SQL

      • 拼接 LIMIT 子句 :根据 pageNumpageSize 计算偏移量。

        sql 复制代码
        -- 原始 SQL
        SELECT * FROM user WHERE shop_id = 1;
        
        -- 改写后 SQL
        SELECT * FROM user WHERE shop_id = 1 LIMIT 0, 10;
      • 自动生成 COUNT 查询 :用于计算总条目数。

        sql 复制代码
        SELECT COUNT(*) FROM user WHERE shop_id = 1;
  4. 分页查询执行

    • 主查询 :执行带有 LIMIT 的 SQL,获取当前页数据。
    • COUNT 查询 :自动执行并填充总条目数(total)。
  5. 结果封装

    java 复制代码
    public class PageVO<T> {
        private Long total;    // 总条目数
        private List<T> list;  // 当前页数据
    }
    • PageHelper 返回的 PagePageSerializable 转换为前端友好的 PageVO
    • 关键计算 :总页数 pages = ceil(total / pageSize)

二、核心代码实现与原理剖析

1. DTO 与 VO 的详细设计
  • PageDTO:分页请求参数载体

    java 复制代码
    public class PageDTO implements IPage {
        @NotNull(message = "pageNum 不能为空")
        @ApiModelProperty(value = "当前页", required = true)
        private Integer pageNum;
    
        @NotNull(message = "pageSize 不能为空")
        @ApiModelProperty(value = "每页大小", required = true)
        @Max(value = 500, message = "每页大小不能超过500")
        private Integer pageSize;
    
        @ApiModelProperty(value = "排序字段数组,用逗号分割")
        private String[] columns;
    
        @ApiModelProperty(value = "排序字段方式,用逗号分割,ASC正序,DESC倒序")
        private String[] orders;
    
        // 实现 IPage 接口方法(供 PageHelper 调用)
        @Override
        public Integer getPageNum() { return this.pageNum; }
        @Override
        public Integer getPageSize() { return this.pageSize; }
    }
    • 作用:统一接收分页参数,支持动态排序规则。
    • 校验扩展 :可通过 @Pattern 校验 columnsorders 的合法性。
  • PageVO:分页响应结构

    java 复制代码
    public class PageVO<T> {
        @ApiModelProperty("总页数")
        private Integer pages;
    
        @ApiModelProperty("总条目数")
        private Long total;
    
        @ApiModelProperty("结果集")
        private List<T> list;
    
        // 计算总页数
        public static Integer calcPages(Long total, Integer pageSize) {
            if (total == 0 || pageSize == 0) return 0;
            return (int) Math.ceil((double) total / pageSize);
        }
    }
    • 扩展性 :可添加 pageNumpageSize 字段,满足部分前端需求。
2. 分页工具类 PageUtil 实现
java 复制代码
public class PageUtil {
    /**
     * 执行分页查询并封装结果
     * @param pageDTO 分页参数
     * @param select  查询逻辑(Lambda 或匿名类)
     */
    public static <T> PageVO<T> doPage(PageDTO pageDTO, ISelect select) {
        // 1. 参数校验
        validatePageSize(pageDTO.getPageSize());
        
        // 2. 启动分页(PageHelper 在此处拦截 SQL)
        Page<T> page = PageHelper.startPage(pageDTO.getPageNum(), pageDTO.getPageSize());
        
        // 3. 动态拼接排序(防止 SQL 注入)
        applyOrderBy(pageDTO);
        
        // 4. 执行查询(触发分页逻辑)
        PageSerializable<T> simplePageInfo = page.doSelectPageSerializable(select);
        
        // 5. 封装结果
        return buildPageVO(simplePageInfo, pageDTO);
    }

    // 校验每页大小合法性
    private static void validatePageSize(Integer pageSize) {
        if (pageSize > PageDTO.MAX_PAGE_SIZE) {
            throw new IllegalArgumentException("每页大小不能超过 " + PageDTO.MAX_PAGE_SIZE);
        }
    }

    // 动态拼接 ORDER BY 子句
    private static void applyOrderBy(PageDTO pageDTO) {
        if (pageDTO.getColumns() == null || pageDTO.getOrders() == null) return;
        
        // 校验字段与排序方式数量一致
        if (pageDTO.getColumns().length != pageDTO.getOrders().length) {
            throw new IllegalArgumentException("排序字段与排序方式数量不匹配");
        }
        
        // 拼接排序语句(示例:name ASC, age DESC)
        String orderBy = IntStream.range(0, pageDTO.getColumns().length)
                .mapToObj(i -> {
                    String column = safeColumn(pageDTO.getColumns()[i]);
                    String order = safeOrder(pageDTO.getOrders()[i]);
                    return column + " " + order;
                })
                .collect(Collectors.joining(", "));
        
        PageHelper.orderBy(orderBy);
    }

    // 防止 SQL 注入(字段白名单校验)
    private static String safeColumn(String column) {
        Set<String> allowedColumns = Set.of("name", "age", "create_time");
        if (!allowedColumns.contains(column)) {
            throw new IllegalArgumentException("非法排序字段: " + column);
        }
        return column;
    }

    // 校验排序方式合法性
    private static String safeOrder(String order) {
        if (!"ASC".equalsIgnoreCase(order) && !"DESC".equalsIgnoreCase(order)) {
            throw new IllegalArgumentException("非法排序方式: " + order);
        }
        return order;
    }

    // 构建 PageVO
    private static <T> PageVO<T> buildPageVO(PageSerializable<T> pageInfo, PageDTO pageDTO) {
        PageVO<T> pageVO = new PageVO<>();
        pageVO.setList(pageInfo.getList());
        pageVO.setTotal(pageInfo.getTotal());
        pageVO.setPages(PageVO.calcPages(pageInfo.getTotal(), pageDTO.getPageSize()));
        return pageVO;
    }
}
3. 关键代码详解:ISelect 接口与 Lambda 实现
  • ISelect 接口源码

    java 复制代码
    @FunctionalInterface
    public interface ISelect {
        void doSelect();
    }
    • 函数式接口 :仅有一个抽象方法 doSelect(),可用 Lambda 表达式替代匿名类。
  • Lambda 表达式本质

    以下两种写法完全等价:

    java 复制代码
    // 写法1:Lambda 表达式
    PageUtil.doPage(pageDTO, () -> userMapper.selectByShopId(shopId));
    
    // 写法2:匿名内部类
    PageUtil.doPage(pageDTO, new ISelect() {
        @Override
        public void doSelect() {
            userMapper.selectByShopId(shopId);
        }
    });
    • 执行时机doSelect() 方法在 doSelectPageSerializable() 中触发,确保分页参数已设置到 ThreadLocal。

三、PageHelper 的 SQL 拦截机制

1. 核心原理

PageHelper 基于 MyBatis 的 插件(Interceptor) 机制,通过动态代理修改 SQL 执行流程:

  1. 拦截目标方法
    拦截 Executor.query(),在 SQL 执行前修改语句。
  2. ThreadLocal 传递参数
    PageHelper.startPage() 将分页参数存储到 ThreadLocal<Page> 中,插件从该变量读取参数。
  3. SQL 解析与改写
    • 使用 JSqlParser 解析原始 SQL,生成语法树。
    • 插入 LIMIT 子句和 ORDER BY 子句(如指定排序)。
  4. 执行 COUNT 查询
    自动生成 SELECT COUNT(*) FROM (...) 语句获取总条目数。
2. 拦截流程示例

原始 SQL

sql 复制代码
SELECT id, name, age FROM user WHERE shop_id = 1

拦截后流程

  1. 生成 COUNT 查询

    sql 复制代码
    SELECT COUNT(*) FROM (SELECT id, name, age FROM user WHERE shop_id = 1) tmp_count
  2. 改写主查询

    sql 复制代码
    SELECT id, name, age FROM user WHERE shop_id = 1 LIMIT 0, 10

四、最佳实践与扩展建议

1. 分页性能优化
  • 禁用 COUNT 查询
    对于极大数据量场景,可通过 PageHelper.startPage(1, 10, false) 跳过 COUNT 查询。
  • 手动优化 COUNT SQL
    在 Mapper 中自定义 @SelectProvider 生成高效的 COUNT 语句。
2. 复杂排序支持
  • 多字段排序
    前端传递 columns=name,age&orders=ASC,DESC,后端拼接为 ORDER BY name ASC, age DESC
  • 嵌套排序
    使用 PageHelper.orderBy("(age - 10) DESC") 支持表达式排序。
3. 异常处理
  • 全局异常拦截
    捕获 IllegalArgumentException 并返回标准错误响应。
  • SQL 注入防御
    通过白名单校验 columns 字段(如 safeColumn() 方法)。

五、总结

通过 DTO 参数规范、VO 响应封装、PageUtil 工具类 的分层设计,结合 PageHelper 的 SQL 拦截机制,开发者可以:

  1. 减少重复代码:统一分页逻辑,Service 层只需关注业务查询。
  2. 提升安全性:参数校验、排序字段白名单、SQL 注入防御层层把关。
  3. 优化性能:自动化的 SQL 改写与结果封装,降低开发复杂度。

最终实现 高效、安全、易维护 的分页功能,满足大多数企业级应用的需求。

相关推荐
天上掉下来个程小白3 小时前
登录-10.Filter-登录校验过滤器
spring boot·后端·spring·filter·登录校验
SomeB1oody3 小时前
【Rust中级教程】2.8. API设计原则之灵活性(flexible) Pt.4:显式析构函数的问题及3种解决方案
开发语言·后端·性能优化·rust
Asthenia04123 小时前
Mybatis-Interceptor参数_Invocation解析——公共字段填充设计思路&&阿里规约
后端
Hamm6 小时前
封装一个优雅的自定义的字典验证器,让API字典参数验证更湿滑
java·spring boot·后端
刘立军6 小时前
本地大模型编程实战(22)用langchain实现基于SQL数据构建问答系统(1)
人工智能·后端·llm
刘立军6 小时前
本地大模型编程实战(21)支持多参数检索的RAG(Retrieval Augmented Generation,检索增强生成)(5)
人工智能·后端·llm
m0_748250746 小时前
Spring Boot 多数据源解决方案:dynamic-datasource-spring-boot-starter 的奥秘(上)
java·spring boot·后端
总是学不会.9 小时前
EasyExcel 使用指南:基础操作与常见问题
java·开发语言·数据库·后端·mysql
️○-9 小时前
后端之JPA(EntityGraph+JsonView)
java·数据库·后端·数据库架构