1. 引言
在实际项目中,分页查询是高频需求。PageHelper作为 MyBatis 的通用分页插件,能零侵入地实现物理分页。本文将介绍两种集成方式:
常规方式:在 Service 层手动调用
PageHelper.startPage()。进阶方式:通过 AOP + 自定义注解,实现分页逻辑与业务代码解耦,并统一封装返回格式。
2. 基础集成(依赖与配置)
2.1 添加 Maven 依赖
<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>最新版本</version> </dependency>2.2 推荐配置(application.properties)
pagehelper.helper-dialect=mysql pagehelper.params=count=countSql pagehelper.reasonable=true pagehelper.support-methods-arguments=truereasonable:当pageNum ≤ 0查第一页,pageNum > 总页数查最后一页。support-methods-arguments:允许 Mapper 方法参数直接传递Page对象(非必须)。
3. 常规用法(无 AOP)
在 Service 层,调用 Mapper 前执行PageHelper.startPage(),并用PageInfo封装结果。
@Service public class UserService { @Autowired private UserMapper userMapper; public PageInfo<User> findUsers(int pageNum, int pageSize) { PageHelper.startPage(pageNum, pageSize); // ①开启分页 List<User> list = userMapper.selectAll(); // ②执行查询(自动被分页) return new PageInfo<>(list); // ③封装分页信息 } }注意:
PageHelper.startPage()仅对紧随其后的第一个Mapper 方法生效,并自动清除ThreadLocal上下文。
4. 进阶方案:AOP + 自定义注解实现统一分页
当项目中有大量分页接口时,手动写PageHelper.startPage()显得冗余。我们可以通过AOP 切面+自定义注解@MyPage将分页逻辑提取出来,实现业务代码的无污染。
4.1 设计思路
在 Controller 或 Service 方法上标注
@MyPage,并指定分页参数来源(请求参数或注解默认值)。切面拦截该方法,解析
pageNum、pageSize、orderBy,调用PageHelper.startPage()。执行原方法(返回
Page类型),将分页元数据存入ThreadLocal。全局响应处理器(
ResponseBodyAdvice)读取ThreadLocal中的元数据,构造统一的分页 JSON 格式。4.2 关键组件代码
4.2.1 分页参数 DTO
@Data public class PageInfoDto { private Integer pageNum; private Integer pageSize; private Long total; private Integer pages; }4.2.2 统一响应包装
import lombok.Getter; import lombok.Setter; import java.time.LocalDateTime; @Getter @Setter public class ResponseDto<T> { private Integer code; private String message; private LocalDateTime resTime; private T data; public ResponseDto<T> success(T data) { ResponseDto<T> responseDto = new ResponseDto<>(); responseDto.setCode(0); responseDto.setData(data); responseDto.setResTime(LocalDateTime.now()); return responseDto; } public ResponseDto<T> error(Integer code, String message) { ResponseDto<T> responseDto = new ResponseDto<>(); responseDto.setCode(code); responseDto.setMessage(message); responseDto.setResTime(LocalDateTime.now()); return responseDto; } public ResponseDto<T> error(BizException bizException){ ResponseDto<T> responseDto =new ResponseDto<>(); responseDto.setCode(bizException.getStatus()); responseDto.setMessage(bizException.getMessage()); responseDto.setResTime(LocalDateTime.now()); return responseDto; } }4.2.3 自定义分页注解
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface MyPage { int pageNum() default 1; // 默认页码(请求参数优先) int pageSize() default 10; // 默认每页大小 String pageNumParam() default "pageNum"; String pageSizeParam() default "pageSize"; String orderByParam() default "orderBy"; String orderBy() default ""; // 默认排序,如 "id desc" int maxPageSize() default 100; // 防止恶意超大页 }4.2.4 ThreadLocal 工具类(存储分页元数据)
import java.util.Objects; /** * 线程局部上下文工具类 * <p> * 用于在同一线程内传递临时数据(如分页信息、用户会话等)。 * 数据与当前线程绑定,线程之间互不干扰。 * </p> * <p> * 重要:每次使用完毕后必须调用 clean() 方法, * 特别是在线程池环境下,否则可能导致内存泄漏或数据错乱。 * </p> * * @author your-name * @since 1.0 */ public class ThreadLocalContext { private static final ThreadLocal<Object> THREAD_LOCAL = new ThreadLocal<>(); /** * 向当前线程存储一个对象 * * @param value 要存储的对象,不能为 null * @param <T> 对象类型 * @throws NullPointerException 如果 value 为 code */ public static <T> void set(T value) { Objects.requireNonNull(value, "Thread local value must not be null"); THREAD_LOCAL.set(value); } /** * 获取当前线程存储的对象,并按指定类型返回 * * @param type 期望的类型 Class * @param <T> 返回类型 * @return 存储的对象,如果不存在则返回 null * 如果对象类型与 type 不匹配,将抛出 ClassCastException */ public static <T> T get(Class<T> type) { Object value = THREAD_LOCAL.get(); if (value == null) { return null; } return type.cast(value); } /** * 获取当前线程存储的原始对象(不进行类型转换) * * @return 存储的对象,可能为 null */ public static Object get() { return THREAD_LOCAL.get(); } /** * 清理当前线程存储的对象 * <p> * 必须在线程处理完请求后调用,以释放内存,防止线程池复用导致的数据污染。 * </p> */ public static void clean() { THREAD_LOCAL.remove(); } }4.2.5 分页切面(核心)
/** * 分页处理切面:自动拦截@MyPage注解标记的方法,处理分页参数并封装分页信息 */ @Aspect @Component public class PageAspect { // 定义切入点:拦截所有被@MyPage注解标记的方法 @Pointcut("@annotation(com.example.annotations.MyPage)") public void pagePointCut() { } /** * 环绕通知:处理分页逻辑 */ @Around("pagePointCut()") public Object around(ProceedingJoinPoint joinPoint) throws Throwable { Method method = ((MethodSignature) joinPoint.getSignature()).getMethod(); MyPage myPage = method.getAnnotation(MyPage.class); int maxPageSize = myPage.maxPageSize(); // 局部变量,线程安全 ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); // 解析分页参数(优先请求参数,其次注解参数) int[] pageParams = resolvePageParams(requestAttributes, myPage, maxPageSize, method); if (pageParams == null) { // 不分页场景,直接执行目标方法 return joinPoint.proceed(); } int pageNum = pageParams[0]; int pageSize = pageParams[1]; // 排序字段:优先从请求参数获取,若不存在则使用注解默认值 String orderBy = resolveOrderBy(requestAttributes, myPage.orderByParam(), myPage.orderBy()); // 执行分页查询并处理结果 return executePagination(joinPoint, pageNum, pageSize, orderBy, method); } /** * 解析分页参数(pageNum和pageSize) * 必须同时提供完整参数对,避免混合使用不同来源的参数 */ private int[] resolvePageParams(ServletRequestAttributes requestAttributes, MyPage myPage, int maxPageSize, Method method) { // 1. 尝试从请求参数获取完整参数对 if (requestAttributes != null) { HttpServletRequest request = requestAttributes.getRequest(); String pageNumParam = myPage.pageNumParam(); String pageSizeParam = myPage.pageSizeParam(); String pageNumStr = request.getParameter(pageNumParam); String pageSizeStr = request.getParameter(pageSizeParam); boolean hasPageNum = StringUtils.hasText(pageNumStr); boolean hasPageSize = StringUtils.hasText(pageSizeStr); if (hasPageNum && hasPageSize) { return parseAndValidateParams(pageNumStr, pageSizeStr, maxPageSize, method); } else if (hasPageNum || hasPageSize) { throw new BizException(ExceptionEnums.PAGE_PARAMS_MISSING, String.format("方法[%s] 分页参数不完整:必须同时提供 %s 和 %s", method.getName(), pageNumParam, pageSizeParam)); } } // 2. 尝试从注解获取完整参数对 int annoPageNum = myPage.pageNum(); int annoPageSize = myPage.pageSize(); if (annoPageNum > 0 && annoPageSize > 0) { validateParams(annoPageNum, annoPageSize, maxPageSize, method); return new int[]{annoPageNum, annoPageSize}; } // 3. 特殊场景:允许通过注解配置"不分页"(pageNum=0且pageSize=0) if (annoPageNum == 0 && annoPageSize == 0) { return null; } // 4. 无有效参数对 throw new BizException(ExceptionEnums.PAGE_PARAMS_MISSING, String.format("方法[%s] 未提供有效分页参数", method.getName())); } /** * 解析排序字段:优先从请求参数获取,若不存在则使用默认值 */ private String resolveOrderBy(ServletRequestAttributes requestAttributes, String orderByParam, String defaultOrderBy) { if (requestAttributes == null) { return defaultOrderBy; } HttpServletRequest request = requestAttributes.getRequest(); String orderByValue = request.getParameter(orderByParam); return (StringUtils.hasText(orderByValue)) ? orderByValue : defaultOrderBy; } /** * 解析并校验请求参数中的分页值(字符串转数字+合法性校验) */ private int[] parseAndValidateParams(String pageNumStr, String pageSizeStr, int maxPageSize, Method method) { try { int pageNum = Integer.parseInt(pageNumStr); int pageSize = Integer.parseInt(pageSizeStr); validateParams(pageNum, pageSize, maxPageSize, method); return new int[]{pageNum, pageSize}; } catch (NumberFormatException e) { throw new BizException(ExceptionEnums.PAGE_PARAMS_FORMAT_ERROR, String.format("方法[%s] 分页参数必须为数字", method.getName())); } } /** * 校验分页参数合法性(必须为正数,且限制最大页大小) */ private void validateParams(int pageNum, int pageSize, int maxPageSize, Method method) { if (pageNum <= 0 || pageSize <= 0) { throw new BizException(ExceptionEnums.PAGE_PARAMS_FORMAT_ERROR, String.format("方法[%s] 分页参数不合法:pageNum=%d, pageSize=%d(必须为正数)", method.getName(), pageNum, pageSize)); } if (pageSize > maxPageSize) { throw new BizException(ExceptionEnums.PAGE_PARAMS_FORMAT_ERROR, String.format("方法[%s] 分页参数不合法:pageSize=%d(最大支持%d)", method.getName(), pageSize, maxPageSize)); } } /** * 执行分页查询并封装结果 */ private Object executePagination(ProceedingJoinPoint joinPoint, int pageNum, int pageSize, String orderBy, Method method) throws Throwable { try { // 启动分页 PageHelper.startPage(pageNum, pageSize, orderBy); // 执行目标方法 Object result = joinPoint.proceed(); // 处理返回结果 return handlePageResult(result, pageNum, method); } finally { // 确保PageHelper的ThreadLocal被清理,防止内存泄漏或影响后续操作 PageHelper.clearPage(); } } /** * 处理分页查询结果,封装分页信息 */ private Object handlePageResult(Object result, int pageNum, Method method) { if (!(result instanceof Page<?> page)) { throw new BizException(ExceptionEnums.PAGE_RETURN_TYPE_ERROR, String.format("方法[%s] 返回值必须为 com.github.pagehelper.Page 类型", method.getName())); } // 优化:当总记录数为0且请求页码>1时,视为页码超出范围 if (page.getTotal() == 0 && pageNum > 1) { throw new BizException(ExceptionEnums.PAGE_NUM_OUT_OF_RANGE, String.format("方法[%s] 页码超出范围:当前页码=%d, 总页数=0", method.getName(), pageNum)); } // 当有数据时,校验页码范围 if (page.getPages() > 0 && pageNum > page.getPages()) { throw new BizException(ExceptionEnums.PAGE_PARAMS_FORMAT_ERROR, String.format("方法[%s] 页码超出范围:当前页码=%d, 总页数=%d", method.getName(), pageNum, page.getPages())); } // 封装分页信息到ThreadLocal(由ResponseAdvice使用,请确保在请求结束后清理ThreadLocalContext) PageInfoDto pageInfoDto = new PageInfoDto(); pageInfoDto.setPageNum(page.getPageNum()); pageInfoDto.setPageSize(page.getPageSize()); pageInfoDto.setTotal(page.getTotal()); pageInfoDto.setPages(page.getPages()); ThreadLocalContext.set(pageInfoDto); return page; } }4.2.6 统一响应处理(ResponseBodyAdvice)
@Slf4j @RestControllerAdvice(value = {"你的controller包路径"}) @RequiredArgsConstructor public class MyResponseAdvice implements ResponseBodyAdvice<Object> { private final ObjectMapper objectMapper; @Value("${dto.enabled}") private boolean enabled; @Override public boolean supports(@NonNull MethodParameter returnType, @NonNull Class<? extends HttpMessageConverter<?>> converterType) { return enabled; } @Override public Object beforeBodyWrite(Object body, @NonNull MethodParameter returnType, @NonNull MediaType selectedContentType, @NonNull Class<? extends HttpMessageConverter<?>> selectedConverterType, @NonNull ServerHttpRequest request, @NonNull ServerHttpResponse response) { try { // 避免二次包装异常处理器的返回值 if (body instanceof ResponseDto) { return body; } // 构建响应对象 PageInfoDto pageInfoDTO = ThreadLocalContext.get(PageInfoDto.class); ResponseDto<Object> responseWrapper = new ResponseDto<>(); Object wrappedBody; if (pageInfoDTO != null) { // 分页响应 Object items = (body instanceof Page<?> page) ? page.getResult() : body; Map<String, Object> result = new HashMap<>(); result.put("pageNum", pageInfoDTO.getPageNum()); result.put("pageSize", pageInfoDTO.getPageSize()); result.put("total", pageInfoDTO.getTotal()); result.put("pages", pageInfoDTO.getPages()); result.put("items", items); wrappedBody = responseWrapper.success(result); } else { wrappedBody = responseWrapper.success(body); } // 处理字符串转换器场景 - 使用 Jackson 序列化 if (selectedConverterType == StringHttpMessageConverter.class) { response.getHeaders().setContentType(MediaType.APPLICATION_JSON); try { return objectMapper.writeValueAsString(wrappedBody); } catch (JsonProcessingException e) { log.error("JSON序列化失败,对象类型: {}", wrappedBody.getClass().getName(), e); // 降级处理:返回错误信息 ResponseDto<Object> errorResponse = new ResponseDto<>(); errorResponse.error(500, "数据序列化失败: " + e.getMessage()); try { return objectMapper.writeValueAsString(errorResponse); } catch (JsonProcessingException ex) { // 最坏情况,返回简单错误信息 return "{\"code\":500,\"msg\":\"系统错误\"}"; } } } return wrappedBody; } finally { ThreadLocalContext.clean(); } } /** * 全局异常处理 */ @ExceptionHandler public Object handleException(Exception ex) { try { ResponseDto<Object> responseWrapper = new ResponseDto<>(); if (ex instanceof BizException bizException) { return responseWrapper.error(bizException); } return responseWrapper.error(500, ex.getMessage()); } finally { // 异常处理中也需清理ThreadLocal,避免残留 ThreadLocalContext.clean(); } } }4.3 异常处理(统一风格)
切面中会抛出
BizException(自定义业务异常),由全局异常处理器捕获并返回规范格式。import lombok.Getter; /** * 业务异常类,封装业务逻辑中产生的异常信息 * 包含状态码(status)和描述信息(message),支持通过枚举统一管理异常 */ @Getter public class BizException extends RuntimeException { /** * 异常状态码(可对应HTTP状态码或自定义业务码) */ private final Integer status; /** * 异常描述信息 */ private final String message; /** * 通过状态码和消息直接创建异常(不推荐,建议优先使用枚举) * 访问权限设为protected,限制外部随意创建非规范异常 * * @param status 异常状态码 * @param message 异常描述信息 */ public BizException(Integer status, String message) { super(message); // 调用父类构造,确保异常栈携带消息 this.status = status; this.message = message; } /** * 通过枚举创建异常(推荐) * 从枚举中获取统一管理的状态码和消息,保证异常规范 * * @param exceptionEnums 异常枚举,包含预定义的status和message */ public BizException(ExceptionEnums exceptionEnums) { super(exceptionEnums.getMessage()); this.status = exceptionEnums.getStatus(); this.message = exceptionEnums.getMessage(); } /** * 通过枚举+额外描述创建异常(推荐) * 在枚举基础消息上追加详细描述(如具体参数、数据ID等) * * @param exceptionEnums 异常枚举,包含预定义的status和message * @param addDescription 额外描述信息,会拼接在枚举消息后(格式:枚举消息 + ",详情:" + 额外描述) */ public BizException(ExceptionEnums exceptionEnums, String addDescription) { super(buildMessage(exceptionEnums.getMessage(), addDescription)); this.status = exceptionEnums.getStatus(); this.message = buildMessage(exceptionEnums.getMessage(), addDescription); } /** * 统一消息拼接格式 * * @param baseMessage 基础消息(来自枚举) * @param addDescription 额外描述 * @return 拼接后的完整消息 */ private static String buildMessage(String baseMessage, String addDescription) { return baseMessage + ",详情:" + addDescription; } /** * 重写toString,包含状态码和消息,便于日志输出 * * @return 异常字符串表示(格式:BizException{status=xxx, message='xxx'}) */ @Override public String toString() { return "BizException{" + "status=" + status + ", message='" + message + '\'' + '}'; } }import lombok.Getter; /** * 异常码枚举 * 业务码(status)全局唯一,HTTP 状态码(httpStatus)用于接口响应 */ @Getter public enum ExceptionEnums { // ===================== 系统级异常(1000~1999)===================== /** * 服务器内部错误 */ SYSTEM_ERROR(500, "Internal server error, please contact administrator"), /** * 参数校验失败 */ PARAM_VALID_ERROR(400, "Parameter verification failed"), /** * 资源未找到 */ RESOURCE_NOT_FOUND( 404, "The requested resource does not exist"), /** * 权限不足 */ PERMISSION_DENIED(403, "No operational permission"), /** * 请求方式错误 */ METHOD_NOT_ALLOWED(405, "Unsupported request method"), // ===================== 分页模块异常(1100~1199)===================== /** * 分页参数缺失(pageNum或pageSize为空) */ PAGE_PARAMS_MISSING(1101, "Pagination parameters 'pageNum' or 'pageSize' are missing"), /** * 分页方法返回值类型错误(必须是Page类型) */ PAGE_RETURN_TYPE_ERROR(1102, "Method annotated with @MyPage must return com.github.pagehelper.Page type"), /** * 分页参数格式错误 */ PAGE_PARAMS_FORMAT_ERROR(1103, "Invalid pagination parameter format"), /** * 页码超出范围 */ PAGE_NUM_OUT_OF_RANGE(1104, "Page number out of range"), // ===================== 未知异常(9999)===================== /** * 未知异常(兜底) */ UNKNOWN_ERROR(9999, "Unknown error"); private final int status; // 业务码 private final String message; // 错误描述 ExceptionEnums(int status, String message) { this.status = status; this.message = message; } }5. 使用示例
5.1 Mapper 接口
@Mapper public interface TestTableDao { // 直接返回 List,但切面会将其包装为 Page 对象(需确保 MyBatis 返回的是 Page) @MyPage List<TestTableModel> selectAll(); }注意:由于
PageHelper会拦截并返回Page对象,所以实际运行中selectAll()返回的是Page实例,但方法签名可写为List,切面中会进行类型判断
5.2 Controller
@RestController @RequestMapping("/api/test") public class TestController { @Autowired private TestTableDao dao; @GetMapping("/list") // 使用注解 public List<TestTableModel> list() { return dao.selectAll(); } }5.3 请求与响应
请求:GET /api/test/list?pageNum=2&pageSize=5&orderBy=name asc
响应:
{ "code": 0, "message": null, "resTime": "2026-08-26T10:42:13", "data": { "total": 1002, "pages": 101, "pageSize": 5, "pageNum": 2, "items": [ /* 数据列表 */ ] } }6. 优化点总结(对比原方案)
参数解析更严谨:请求参数与注解默认值优先级清晰,且强制参数对完整,避免混合使用。
线程安全与清理:切面内
finally块确保PageHelper.clearPage()执行,响应处理器中清理ThreadLocalContext,防止内存泄漏。异常处理细化:针对页码超出范围、参数格式错误等场景给出明确业务异常,方便前端处理。
排序支持:通过
orderBy参数动态传递,增强灵活性。代码可读性:将参数解析、校验等抽取为私有方法,切面主流程更清晰。
类型安全:
PageInfoDto存储元数据,避免使用Map,降低出错风险。
7. 注意事项
AOP 方式要求被拦截方法的返回值必须是
Page类型(或能转型为Page),否则会抛出异常。若方法不需要分页(如导出全部数据),可不在方法上标注
@MyPage,或设置pageNum=0, pageSize=0触发生效。当
reasonable=true时,页码合理化会覆盖部分异常,建议根据业务决定是否启用。若使用
orderBy,请确保拼接的字符串符合 SQL 语法,防止注入风险(推荐使用白名单校验)。