ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring Boot 集成 PageHelper 的完整实践(含 AOP 统一分页)

Spring Boot 集成 PageHelper 的完整实践(含 AOP 统一分页) 1. 引言在实际项目中分页查询是高频需求。PageHelper作为 MyBatis 的通用分页插件能零侵入地实现物理分页。本文将介绍两种集成方式常规方式在 Service 层手动调用PageHelper.startPage()。进阶方式通过 AOP 自定义注解实现分页逻辑与业务代码解耦并统一封装返回格式。2. 基础集成依赖与配置2.1 添加 Maven 依赖dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version最新版本/version /dependency2.2 推荐配置application.propertiespagehelper.helper-dialectmysql pagehelper.paramscountcountSql pagehelper.reasonabletrue pagehelper.support-methods-argumentstruereasonable当pageNum ≤ 0查第一页pageNum 总页数查最后一页。support-methods-arguments允许 Mapper 方法参数直接传递Page对象非必须。3. 常规用法无 AOP在 Service 层调用 Mapper 前执行PageHelper.startPage()并用PageInfo封装结果。Service public class UserService { Autowired private UserMapper userMapper; public PageInfoUser findUsers(int pageNum, int pageSize) { PageHelper.startPage(pageNum, pageSize); // ①开启分页 ListUser 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 分页参数 DTOData 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 ResponseDtoT { private Integer code; private String message; private LocalDateTime resTime; private T data; public ResponseDtoT success(T data) { ResponseDtoT responseDto new ResponseDto(); responseDto.setCode(0); responseDto.setData(data); responseDto.setResTime(LocalDateTime.now()); return responseDto; } public ResponseDtoT error(Integer code, String message) { ResponseDtoT responseDto new ResponseDto(); responseDto.setCode(code); responseDto.setMessage(message); responseDto.setResTime(LocalDateTime.now()); return responseDto; } public ResponseDtoT error(BizException bizException){ ResponseDtoT 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 ThreadLocalObject 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(ClassT 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. 特殊场景允许通过注解配置不分页pageNum0且pageSize0 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 统一响应处理ResponseBodyAdviceSlf4j RestControllerAdvice(value {你的controller包路径}) RequiredArgsConstructor public class MyResponseAdvice implements ResponseBodyAdviceObject { 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); ResponseDtoObject responseWrapper new ResponseDto(); Object wrappedBody; if (pageInfoDTO ! null) { // 分页响应 Object items (body instanceof Page? page) ? page.getResult() : body; MapString, 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); // 降级处理返回错误信息 ResponseDtoObject 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 { ResponseDtoObject 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{statusxxx, messagexxx} */ 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 ListTestTableModel selectAll(); }注意由于PageHelper会拦截并返回Page对象所以实际运行中selectAll()返回的是Page实例但方法签名可写为List切面中会进行类型判断5.2 ControllerRestController RequestMapping(/api/test) public class TestController { Autowired private TestTableDao dao; GetMapping(/list) // 使用注解 public ListTestTableModel list() { return dao.selectAll(); } }5.3 请求与响应请求GET /api/test/list?pageNum2pageSize5orderByname 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或设置pageNum0, pageSize0触发生效。当reasonabletrue时页码合理化会覆盖部分异常建议根据业务决定是否启用。若使用orderBy请确保拼接的字符串符合 SQL 语法防止注入风险推荐使用白名单校验。
返回列表