Spring Boot整合PageHelper自定义分页注解,告别重复分页代码

发布时间:2026/9/15 23:25:39
Spring Boot整合PageHelper自定义分页注解,告别重复分页代码 分页这个功能说大不大说小不小但它几乎是每个业务系统里出现频率最高的通用操作之一。前几天项目里要批量调整一批列表接口的查询参数我翻着Service层里到处散落的PageHelper.startPage和PageInfo包装代码突然意识到一个问题这个用了好几个月的分页插件用起来还是不够“顺手”。趁着项目正在整合Spring Boot MyBatis Plus PageHelper这套组合我干脆给PageHelper包了一层自定义分页注解把分页参数解析、startPage调用、结果封装这些样板逻辑统一收口到一个地方业务代码里只留一个注解声明。这篇文章就把整个方案的思路、完整代码和实战中踩过的坑一起讲清楚希望对同样在维护中后台列表查询功能的朋友有帮助。为什么要折腾一个注解出来而不是老老实实继续用PageHelper的原生方式原因很简单分页本身不复杂但分页调用方式的重复和散乱会让后续维护变得很被动。如果你现在还在Controller层或者Service层一行一行写startPage这篇文章值得花十分钟看完。1. 分页的日常之痛为什么业务代码总被分页逻辑污染1.1 同一个分页代码你写了多少遍先看一个典型的列表查询接口。表面上它只负责“查用户列表”但为了满足分页需求Service层里往往会长这样public PageInfoUserVO queryUserList(UserQueryParam param) { PageHelper.startPage(param.getPageNum(), param.getPageSize()); ListUser users userMapper.selectUserList(param); PageInfoUser pageInfo new PageInfo(users); return convertToVO(pageInfo); }这代码本身没什么问题但如果系统里有几十个类似的查询接口每个方法里都要重复这三板斧startPage → 查询 → 包装PageInfo。更有意思的是不同开发者的习惯还不一样有的人喜欢把startPage放在Controller层有的人放在Service层还有人把PageInfo包装放在Controller返回前处理这就导致同一套代码在项目里有好几种写法。等到后面需求变了比如要求对所有列表接口统一加一个“最大单页条数限制”或者分页参数从pageNum/pageSize改成current/size你就得满项目去找这些散落的startPage和PageInfo一个一个改。这种时候你才体会到分页逻辑虽然简单但它和业务逻辑纠缠在一起之后改造代价并不低。1.2 PageHelper和MyBatis Plus分页插件到底该选哪个很多项目同时用着MyBatis Plus和PageHelper分页方案上会纠结一下。这里先把两个东西的原理摊开看对比项MyBatis Plus分页插件PageHelper分页触发方式Mapper方法入参必须携带IPage对象查询前调用startPageThreadLocal传递侵入性需要修改Mapper方法签名传入Page参数不改方法签名对已有代码改动小复杂SQL支持多表、子查询也可以解析但依赖自身的SQL解析器基于jsqlparser解析覆盖面更广结果获取通过Page对象获取total通过PageInfo获取total适合场景新项目Mapper方法可以统一定义老项目改造、SQL复杂、不想改方法签名坦白讲如果你的项目已经深度使用MyBatis Plus并且所有Mapper查询方法都愿意接受IPage参数那直接用MP自带的分页插件就够了没必要再引PageHelper。但现实中很多项目是“中途接手”的Mapper接口和XML里已经写好了大量ListUser selectXxx(QueryParam param)这样的方法为了分页去改所有方法签名风险和改动量都大。这时候PageHelper的startPage模式就很有优势——它不碰方法签名只是在查询前踩一脚油门下一条SQL自动带上limit。我在实际规划时选择PageHelper还有一个原因是它对复杂统计SQL的支持更省心不用像MP分页插件那样去研究每个SQL的解析结果是否正常。既然定了用PageHelper接下来自然就轮到“如何把startPage调用也省掉”这件事。1.3 自定义注解要解决的不只是“少写两行”有人可能会说用PageHelper本来也就多写一行startPage再包一层注解意义大吗如果只看单个方法确实只是少了一行代码。但把视角拉到整个项目层面自定义注解带来的核心价值是统一入口和约束规范。先说统一入口。分页参数解析、默认页码、每页条数上限、排序字段过滤、clearPage()兜底清理这些逻辑如果放在业务方法里每个方法都要重复一遍放到注解切面里整个项目只有一个地方需要维护。比如某天产品说“所有列表页默认每页改成20条”你只需要改注解的默认值而不是满项目替换数字。再说约束规范。没有注解之前一个查询方法做不做分页完全看开发者的心情和习惯。有了注解之后方法上有没有AutoPage一眼就能看出来代码评审的时候也更容易把关。这种“声明式”的效果和Transactional管理事务是一个道理事务逻辑收口到框架业务代码只关注“要什么”。2. 自定义分页注解的整体设计边界、职责与调用链2.1 注解加在Controller、Service还是Mapper区别很大设计注解方案时第一个要决策的问题是注解标在哪一层。我先后对比了三种选择标注在Controller方法上Controller是HTTP入口参数天然可以从RequestParam绑定分页注解在这里拦截最直接。但问题是Controller层适合做参数接收和响应包装把分页这种数据访问逻辑放在这里会让层次关系变模糊。而且Controller方法经常还会做参数校验、权限判断切面拦截时的职责容易越界。标注在Mapper方法上从SqlSession层面拦截分页确实精确但Spring AOP默认对Mapper接口的代理对象支持不友好需要额外处理。更复杂的是Mapper方法可能是被多个Service在内部调用的一个Mapper方法分页不代表这个业务方法就应该分页这种“分页边界”很难由Mapper自身决定。标注在Service方法上Service是业务逻辑的边界查询一个列表、有没有分页需求在Service方法签名上表达得最清楚。同时Service方法通常是一个完整业务事务的入口方法内置数据库查询startPage和查询之间不会有跨线程、跨方法乱入的情况。我最终选了Service层。**注解的职责边界就定位为解决Service方法在执行列表查询前的分页参数准备以及查询后的结果包装。**这一步思路清晰了后面的代码都好写。2.2 分页参数从哪来方法入参解析的三种形态注解定义好之后切面执行时需要拿到两个最基本的值页码pageNum和每页条数pageSize。这两个值从哪来我梳理了项目里常见的三种形态并分别做了支持自定义PageParam对象最常见也最推荐。Service方法入参里放一个PageParam对象里面包含pageNum、pageSize、orderBy等字段。Spring MVC在Controller层会自动把请求参数绑定到PageParam上切面只需遍历方法入参数组找到PageParam类型的参数即可。Map类型参数有的老接口直接用MapString, Object接收动态查询条件分页参数也塞在同一个Map里。切面从Map里取pageNum和pageSize两个key没有就使用默认值。直接从HttpServletRequest取通过RequestContextHolder拿到当前请求对象读取request.getParameter(pageNum)。这种方式对参数的依赖最小但破坏了分层而且异步线程里RequestContextHolder可能拿不到值测试环境下也需要mock我在设计时把它作为兜底方案但没有优先推荐。用自定义PageParam还有一个额外的好处可以顺手在对象里带上orderBy排序字段、maxPageSize上限校验这些扩展能力。比如前端传来pageSize10000PageHelper如果直接透传这个值数据库压力会巨大在切面里做一次上限拦截超过阈值就回退成默认值就能避免很多手滑操作。2.3 分页核心调用链解析参数、startPage、清理线程变量整个注解切面的处理流程其实是在模拟手动分页的调用链路只是把顺序固定下来并加上了兜底。设计时我严格按下面这个顺序执行解析方法入参得到pageNum、pageSize和可选的orderBy。校验参数pageNum小于1时纠正为1pageSize超过上限时纠正为上限值排序字段做白名单校验。调用PageHelper.startPage(pageNum, pageSize, count)打开分页开关。调用PageHelper.orderBy(orderBy)如果传了合法的排序字段。执行pjp.proceed()让目标Service方法正常跑起来。此时PageHelper的ThreadLocal里已经存在分页参数下一次执行的Mapper查询SQL会被自动拼接limit和count。在finally块中调用PageHelper.clearPage()确保ThreadLocal不残留分页参数。按注解配置决定是否包装返回结果。如果wrapPageResult true且返回类型是List就把list包装成PageInfo返回。这里有个关键点必须强调PageHelper的分页参数只能作用于“下一次”查询。所以切面必须在proceed()执行之前完成startPage并且proceed()内部第一次执行的Mapper查询就是目标分页查询。如果Service方法内部在列表查询之前还有别的查询比如先查一条配置、再查列表那分页就会作用到前一个查询上这也是很多“分页没生效”问题的根源。3. 完整实现Spring Boot MyBatis Plus PageHelper整合3.1 环境准备与依赖版本选择先交代一下实现时的环境。我用的是Spring Boot 2.7.x数据库MySQL项目里同时存在MyBatis Plus和PageHelper的依赖。dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependency更早之前我遇到过MP分页插件和PageHelper混用导致的SQL重复limit问题所以这一版明确只让PageHelper承担分页职能不在接入配置里加MyBatis Plus的PaginationInnerInterceptor。MP还是照常用它的BaseMapper、LambdaQueryWrapper只是分页时用PageHelper而不是MP自带的分页器。application.yml里我做了这样的配置pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true params: pageNumpageNum;pageSizepageSize;countcountSqlreasonable: true表示页码溢出时自动纠正为第一页或最后一页比如请求pageNum99但总共只有5页不会查出一个空列表而是自动回退到最后一页这对前端用户体验很友好。support-methods-arguments是PageHelper官方提供的能力可以直接从Mapper方法参数里识别Page对象和Request对象但因为我们自己在注解切面里调用了startPage这个开关其实可有可无留着主要是兼容一些其他直接调用Mapper的场景。3.2 注解定义AutoPage的三要素注解本身很简单但属性的设计需要想清楚因为每个属性将来都对应一种分页行为。我定义的注解长这样import java.lang.annotation.*; Target({ElementType.METHOD}) Retention(RetentionPolicy.RUNTIME) Documented public interface AutoPage { // 是否执行count查询默认true boolean count() default true; // 默认页码默认1 int defaultPageNum() default 1; // 默认每页条数默认10 int defaultPageSize() default 10; // 每页条数上限超出后会回退为该值 int maxPageSize() default 100; // 是否将List结果自动包装为PageInfo默认false boolean wrapPageResult() default false; }你可能注意到我这里没有直接写死“排序字段”的属性。排序本身有SQL注入风险在设计上我倾向于把排序字段放在PageParam对象里由切面统一做白名单校验而不是在注解上声明死排序字段这样更灵活也更安全。count、defaultPageNum、defaultPageSize、maxPageSize四个属性分别回答了分页过程中的四个问题要不要查总数、没传页码怎么办、没传条数怎么办、传了超大条数怎么办。wrapPageResult则决定了返回给调用方的形态默认不包装把自由度留给业务代码。3.3 切面实现分页参数的解析与拦截逻辑接下来是核心部分——切面。我用的Spring AOP的Around注解拦截所有标记了AutoPage的方法。完整代码如下import com.github.pagehelper.PageHelper; import com.github.pagehelper.PageInfo; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.reflect.MethodSignature; import org.springframework.stereotype.Component; import java.lang.reflect.Method; import java.util.List; import java.util.Map; import java.util.regex.Pattern; Aspect Component public class AutoPageAspect { // 排序字段白名单只允许字母、数字、下划线、点、逗号和空格以及最后紧跟asc/desc private static final Pattern ORDER_BY_PATTERN Pattern.compile(^[A-Za-z0-9_\\.,\\s](asc|desc|ASC|DESC)?$); Around(annotation(autoPage)) public Object handleAutoPage(ProceedingJoinPoint pjp, AutoPage autoPage) throws Throwable { PageParam pageParam resolvePageParam(pjp.getArgs(), autoPage); // 每页条数上限控制 if (pageParam.getPageSize() autoPage.maxPageSize()) { pageParam.setPageSize(autoPage.maxPageSize()); } if (pageParam.getPageNum() 1) { pageParam.setPageNum(autoPage.defaultPageNum()); } // 开启分页 PageHelper.startPage(pageParam.getPageNum(), pageParam.getPageSize(), autoPage.count()); // 排序字段可选加入前必须做白名单校验 if (pageParam.getOrderBy() ! null !pageParam.getOrderBy().trim().isEmpty()) { String orderBy pageParam.getOrderBy().trim(); if (ORDER_BY_PATTERN.matcher(orderBy).matches()) { PageHelper.orderBy(orderBy); } else { throw new IllegalArgumentException(非法的排序字段: orderBy); } } try { Object result pjp.proceed(); // 按需包装PageInfo if (autoPage.wrapPageResult() result instanceof List) { return new PageInfo((List?) result); } return result; } finally { // 兜底清理线程变量防止ThreadLocal污染后续查询 PageHelper.clearPage(); } } /** * 从方法入参中解析分页参数。 * 支持三种形态PageParam对象、Map中包含pageNum/pageSize、HttpServletRequest请求参数。 */ private PageParam resolvePageParam(Object[] args, AutoPage autoPage) { PageParam pageParam null; if (args ! null) { for (Object arg : args) { if (arg null) { continue; } if (arg instanceof PageParam) { pageParam (PageParam) arg; break; } if (arg instanceof Map) { Map?, ? map (Map?, ?) arg; Object pageNum map.get(pageNum); Object pageSize map.get(pageSize); if (pageNum ! null pageSize ! null) { pageParam new PageParam(); pageParam.setPageNum(Integer.parseInt(pageNum.toString())); pageParam.setPageSize(Integer.parseInt(pageSize.toString())); break; } } } } if (pageParam null) { pageParam new PageParam(); } if (pageParam.getPageNum() null) { pageParam.setPageNum(autoPage.defaultPageNum()); } if (pageParam.getPageSize() null) { pageParam.setPageSize(autoPage.defaultPageSize()); } return pageParam; } }切面里有两个细节容易被忽略实际项目中我会额外检查第一PageHelper.clearPage()必须放在finally里而不是只有正常路径才执行。因为一旦proceed()内部抛异常ThreadLocal里的分页参数不会被自动清理虽然PageHelper设计上在查询后会自动清空但异常中断时可能留有残留这个残留会污染线程池中下一个请求的查询导致莫名其妙的“SQL多了个limit”。第二PageHelper.orderBy()这个方法并不是无限制可用的。如果SQL里本身带了order by再调用orderBy会报错Failed to process order by SQL如果SQL里是union、group by、distinct这类复杂结构orderBy方法也可能解析失败。所以我更推荐把排序逻辑放在XML或者LambdQueryWrapper里自己控制PageHelper.orderBy只作为辅助手段。3.4 实际使用效果Service和Controller的代码对比设备好注解和切面后业务代码的改动是非常直观的。这是一段改造前的Service方法Service public class UserQueryService { public PageInfoUserVO queryUserList(UserQueryParam param) { PageHelper.startPage(param.getPageNum(), param.getPageSize()); ListUser users userMapper.selectUserList(param); PageInfoUser pageInfo new PageInfo(users); ListUserVO voList BeanCopyUtil.copyList(users, UserVO.class); pageInfo.setList(voList); return pageInfo; } }改造之后Service public class UserQueryService { AutoPage(wrapPageResult false) public ListUser queryUserList(UserQueryParam param) { return userMapper.selectUserList(param); } }Controller层保持不变还是把Service返回的List包装成统一响应格式RestController RequestMapping(/user) public class UserController { GetMapping(/list) public ResultPageInfoUser list(UserQueryParam param) { ListUser users userQueryService.queryUserList(param); return Result.ok(new PageInfo(users)); } }如果你希望Controller更省事切面里可以直接把wrapPageResult设为true让注解把List包装成PageInfo返回。两种方式我都在项目里用过最终倾向于默认不包装原因是Service层返回的数据后续可能还需要做对象转换、脱敏、补充字段如果提前包装成PageInfo再改List里的元素会比较绕不如先返回List由调用方在返回前统一包装。这种方式让Service方法回归了它最自然的形态——一个普通的数据查询方法分页变成了一个可选的声明特性而不是代码逻辑的一部分。4. 实战中踩过的坑与排查方法4.1 PageHelper与MyBatis Plus分页插件冲突导致分页失效这是我第一次实践时遇到的头号大坑。项目里原本接入了MyBatis Plus的分页拦截器后来又加了PageHelper结果部分接口里的SQL出现了limit 10, 10 limit 0, 10这种诡异的双重limit直接数据库语法报错。排查后发现原因Mapper方法参数里如果传了IPage对象MyBatis Plus分页插件会处理一次分页而PageHelper又因为support-methods-arguments或显式startPage在ThreadLocal里设置了分页参数也会处理一次。两个分页插件叠加SQL被拼了两次limit。解决思路就是明确分页职责避免同时启用两个分页器。项目中没有再配置MyBatis Plus的PaginationInnerInterceptor所有分页统一走PageHelper。如果你确实需要在某些接口里用MP分页插件另一些用PageHelper那就必须在调用链上做好隔离避免同一个Mapper方法同时触达两种分页机制。另外还要注意依赖冲突。PageHelper会引入jsqlparserMyBatis Plus分页插件内部也使用jsqlparser如果两个模块的版本不一致可能会在解析SQL时抛出NoClassDefFoundError或Set operation not supported yet异常。遇到这类问题用Maven依赖树检查jsqlparser版本把两者拉齐到同一个版本即可。4.2 startPage位置不对导致分页没有生效有同事反馈同一个Service方法里注解标注后分页却不生效查询出来的还是全量数据。我让他把Mapper查询之前的代码都列出来发现他在列表查询前面先执行了一次字典查询AutoPage public ListUser queryUserList(UserQueryParam param) { ListDict dicts dictMapper.selectAll(); // 这条SQL先执行分页被它消费掉了 return userMapper.selectUserList(param); // 这条SQL执行时ThreadLocal里已经没分页参数了 }这就是PageHelper“只影响下一次查询”的特性导致的。startPage放在什么地方决定了它作用到哪条SQL。如果proceed()执行后方法内部第一条SQL不是目标列表查询分页就会跑偏。结合这个案例我总结了三条排查建议确认PageHelper.startPage和第一次Mapper查询之间没有其他数据库操作确认在同一个线程内完成startPage和查询不能跨线程、不能异步排除MyBatis二级缓存干扰如果命中了缓存查询根本不会走到Executor层分页自然无从谈起。4.3 count查询慢、总数不对与数据权限兼容问题分页的count查询是另一个容易翻车的地方。PageHelper默认会从原SQL中生成一条简化版的count SQL把select部分替换成select count(0)并去掉order by但join、group by、distinct这些结构处理起来并不总是高效。我碰到过一个慢查询案例列表SQL本身有多个left join数据量倒不大但count的解析结果没有去掉多余的join表导致count执行了近一秒。最后通过countSuffix自定义了一个更精简的count查询问题才解决。配置方式是在application.yml里加pagehelper: count-suffix: _COUNT然后在XML里手写一个对应的selectUserList_COUNT方法专门做轻量count。还有一个容易被忽略的问题是count结果和list结果不一致。比如系统里启用了逻辑删除和部门数据权限列表SQL里通过SQL注入拼了权限条件但count SQL解析时没有完整保留这些条件就会出现“总数明显偏大”的情况。排查时我会把PageHelper实际执行的count SQL打印出来手动执行一遍对比它的where条件是否和只看数量的预期一致。如果不一致优先在Mapper XML里手动补count方法而不是依赖自动生成。4.4 排序字段注入与orderBy方法限制自定义分页注解加了排序参数之后SQL注入风险也随之而来。前端如果可以直接传orderByname; drop table user这种字符串拼到SQL里问题就大了。所以我在切面里做了一个排序字段白名单校验只允许[A-Za-z0-9_.,\s]加上结尾的asc/desc其余全部拦截。使用PageHelper.orderBy还有一些隐含限制我在实际使用中普遍遇到两种情况与原SQL中的order by冲突如果XML里已经写了order by再调用PageHelper.orderBy会抛出Failed to process order by SQL异常此时应该二选一不要两处都加与union、group by、distinct冲突这些SQL结构下jsqlparser解析排序时可能出错更稳妥的做法是把排序写到XML里或者用MyBatis Plus的QueryWrapper.orderByDesc来完成。从使用频率上看单表查询、简单子查询的场景用PageHelper.orderBy确实方便但一遇到复杂SQL老老实实手动控制排序反而更省心。这也是为什么我在设计注解时把排序参数设计成可选能力而不是强制项。5. 扩展思路从分页到统一查询能力5.1 支持更多查询参数把PageParam做成通用查询协议分页参数已经统一了下一步可以顺势把其他查询级参数也纳入PageParam里。比如我在后续版本里给PageParam加过两个字段public class PageParam { private Integer pageNum 1; private Integer pageSize 10; private String orderBy; private boolean needTotal true; // 某些场景不需要count提升接口性能 private Integer timeout -1; // 查询超时时间毫秒 }needTotal这个字段很实用。有些滚动加载场景下拉加载更多只需要“有下一页”这个布尔结果不需要精确的总数此时可以设置needTotalfalse切面里对应调用PageHelper.startPage(pageNum, pageSize, false)PageHelper就不再生成count查询接口响应速度能明显提升。timeout字段生效需要配合MyBatis的defaultStatementTimeout设置虽然适用范围有限但作为一个统一查询协议的一部分它让前端可以通过一个参数控制查询的防慢SQL阈值在高频列表接口中价值不小。5.2 结合LambdaQueryWrapper注解化改造前面例子主要用的是Mapper XML里的SQL查询但实际上这种注解方案和MyBatis Plus的LambdaQueryWrapper结合起来改造效果同样干净。比如这样一个用Wrapper查询用户的场景AutoPage public ListUser queryByCondition(UserQueryParam param) { LambdaQueryWrapperUser wrapper Wrappers.lambdaQuery(); wrapper.like(StringUtils.hasText(param.getName()), User::getName, param.getName()) .eq(param.getStatus() ! null, User::getStatus, param.getStatus()) .orderByDesc(User::getCreateTime); return userMapper.selectList(wrapper); }这里没有显式调用startPage但注解切面已经完成了分页参数的准备。方法内部唯一的分页痕迹就是返回一个普通的List调用方在Controller里通过PageInfo.of(list)就能获得总数和页码信息。相比MP自带的分页插件必须在Mapper方法入参里塞IPage这种组合更自然LambdaQueryWrapper部分完全可以按原样保留业务代码几乎不需要为分页做额外改动。5.3 注解化改造的边界什么情况下别用注解任何技术方案都有自己的边界自定义分页注解也不是万能的。我整理了三种不建议用注解的情况分页逻辑本身异常复杂的场景。比如分批导出数据时需要一边查询一边记录游标此时分页不是简单的pageNum/pageSize就能表达的还是老老实实手动控制更清晰。统计报表类SQL特别多的项目。这类SQL经常是count、sum、group by组合每条SQL的分页需求差异极大统一加注解反而会引入不必要的隐式行为调试时容易一头雾水。团队对PageHelper机制理解不深的阶段。如果团队新手居多仅靠一个注解隐藏了startPage和clearPage的细节一旦出现“分页没生效”“总数不对”这类问题排查起来还是很费劲的。建议先在团队里把PageHelper原理讲透再引入注解来简化日常编码。就算用了注解我在代码评审时也一定会确认三点方法内部是不是在第一次查询就消费掉了分页参数、排序字段有没有经过白名单校验、finally里有没有兜底清理线程变量。这三点是我自己踩坑踩出来的血泪经验也顺便成了团队里分页相关的通用评审清单。回头再看这个方案它本质上不是发明了什么新技术而是把PageHelper的调用方式重新组织成了一层更贴合业务形态的声明式接口。分页这件事没有变变化的只是它和业务代码之间的边界更清晰了。如果你项目里也到处是startPage和PageInfo包装不妨抽一个时间把这套注解拆出来放到通用模块里后续维护起来会明显轻松不少。