Spring Boot + PageHelper 实现分页,总结得很全了!

本文来自作者 "臣不贰" 投稿。

CSDN:https://blog.csdn.net/NOT_TWO_CHEN/article/details/109230267

简书:https://www.jianshu.com/p/a24a9ff323c9

一. 开发准备

1. 开发工具

  • IntelliJ IDEA 2020.2.3

2. 开发环境

  • Red Hat Open JDK 8u256
  • Apache Maven 3.6.3

3. 开发依赖

  • SpringBoot
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-web</artifactId>
</dependency>
  • MyBatis
<dependency>
	<groupId>org.mybatis.spring.boot</groupId>
	<artifactId>mybatis-spring-boot-starter</artifactId>
	<version>2.1.3</version>
</dependency>
  • PageHelper
<dependency>
	<groupId>com.github.pagehelper</groupId>
	<artifactId>pagehelper-spring-boot-starter</artifactId>
	<version>1.3.0</version>
</dependency>

二. 技术文档

1. 基于 SpringBoot

2. 基于 MyBatis

3. 集成 PageHelper

三. 应用讲解

1. 基本使用

在实际项目运用中,PageHelper的使用非常便利快捷,仅通过PageInfo + PageHelper两个类,就足以完成分页功能,然而往往这种最简单的集成使用方式,却在很多实际应用场景中,没有得到充分的开发利用.

接下来是我们最常见的使用方式:

public PageInfo<ResponseEntityDto> page(RequestParamDto param) {
	PageHelper.startPage(param.getPageNum(), param.getPageSize());
	List<ResoinseEntityDto> list = mapper.selectManySelective(param);
	PageInfo<ResponseEntityDto> pageInfo = (PageInfo<ResponseEntityDto>)list;
	return pageInfo;
} 

在某种程度上而言, 上述写法的确是符合 PageHelper 的使用规范 :

	在集合查询前使用`PageHelper.startPage(pageNum,pageSize)`,并且*中间不能穿插执行其他SQL*

但是作为 Developer 的我们, 往往只有在追求完美和极致的道路上才能够寻得突破和机遇;
以下是合理且规范的基本使用:

public PageInfo<ResponseEntityDto> page(RequestParamDto param) {
	return PageHelper.startPage(param.getPageNum(), param.getPageSize())
			  .doSelectPageInfo(() -> list(param))
} 
public List<ResponseEntityDto> list(RequestParamDto param) {
	return mapper.selectManySelective(param);
}

FAQ

1. 为什么要重新声明一个 list 函数?

答: 往往在很多实际业务应用场景中, 分页查询是基于大数据量的表格展示需求来进行的.
然而很多时候,譬如: 内部服务的互相调用,OpenAPI的提供.

甚至在某些前后端分离联调的业务场景中, 是同样需要一个非分页集合查询接口来提供服务的.
另外, 暂时以上因素抛开不谈, 我们可以根据上述写法来定义和规范某些东西

譬如: 分页和集合查询的分离和解耦(解耦详情请看进阶使用),
分页请求的请求和响应与实际业务参数的分离(详情请看进阶使用) 等等...

2. doSelectPageInfo是什么?

答: doSelectPageInfoPageHelper.startPage()函数返回的默认Page实例内置的函数, 该函数可以用以Lambda的形式通过额外的Function来进行查询而不需要再进行多余的PageInfoList转换, 而doSelectPageInfo的参数则是PageHelper内置的Function(ISelect)接口用以达到转换PageInfo的目的

3. 这种写法的代码量看起来不少反多?

答: 正如同①中所描述的, 就代码量而言, 确实没有更进一步的简化, 但是再某些业务场景中, 在已具有list函数接口的情况下, 是一种更直观的优化 (优化详情请看进阶使用)

2. 进阶使用

先看代码, 再谈解析:

import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;

import java.util.List;

/**

  • @param <Param> 泛型 request

  • @param <Result> 泛型 response
    */
    public interface BaseService<Param, Result> {

    /**

    • 分页查询
    • @param param 请求参数 DTO
    • @return 分页集合
      */
      default PageInfo<Result> page(PageParam<Param> param) {
      return PageHelper.startPage(param).doSelectPageInfo(()-> list(param.getParam()));
      }

    /**

    • 集合查询
    • @param param 查询参数
    • @return 查询响应
      */
      List<Result> list(Param param);
      }

可以看到BaseService可以作为全局Service通用接口的封装和声明,而作为通用分页接口 page 函数却在此处利用interface特有关键字default 直接声明了page函数的方法体body

import com.github.pagehelper.IPage;
import lombok.Data;
import lombok.experimental.Accessors;

@Data // 为省略冗余代码使用 lombok 实际应有常规 Getter/Setter Construction toString 等
@Accessors(chain = true) // 此 lombok 注解是为了实现 Entity 伪 Build 譬如: entity.setX(x).setY(y)
public class PageParam<T> implements IPage {

<span class="hljs-comment">//  description = "页码", defaultValue =  1</span>
<span class="hljs-keyword">private</span> <span class="hljs-type">Integer</span> <span class="hljs-variable">pageNum</span> <span class="hljs-operator">=</span> <span class="hljs-number">1</span>;

<span class="hljs-comment">//	description = "页数", defaultValue = 20</span>
<span class="hljs-keyword">private</span> <span class="hljs-type">Integer</span> <span class="hljs-variable">pageSize</span> <span class="hljs-operator">=</span> <span class="hljs-number">20</span>;

<span class="hljs-comment">//	description = "排序", example = "id desc"</span>
<span class="hljs-keyword">private</span> String orderBy;

<span class="hljs-comment">//  description = "参数"</span>
<span class="hljs-keyword">private</span> T param;

<span class="hljs-keyword">public</span> PageParam&lt;T&gt; <span class="hljs-title function_">setOrderBy</span><span class="hljs-params">(String orderBy)</span> {
    <span class="hljs-built_in">this</span>.orderBy = orderBy; <span class="hljs-comment">// 此处可优化 优化详情且看解析</span>
    <span class="hljs-keyword">return</span> <span class="hljs-built_in">this</span>;
}

}

BaseService中我们看到了一个新的PageParam, 参考了PageInfo用以包装 / 声明 / 分离分页参数和业务参数, 且参数类型为泛型, 即支持任何数据类型的业务参数
同时也可以看到PageParam实现了IPage接口, 并且多了一个orderBy属性字段

import common.base.BaseService;
import dto.req.TemplateReqDto;
import dto.resp.TemplateRespDto;

public interface TemplateService extends BaseService<TemplateReqDto, TemplateeRespDto> {
// 同为 interface 接口, 业务 Service 只需要继承 BaseService
// 并根据实际使用场景声明请求参数和响应结果的 Entity 实体即可
}

在实际应用中, 只需要声明我们通用的业务查询请求参数和响应结果即可

import dto.req.TemplateReqDto;
import dto.resp.TemplateRespDto;
import service.TemplateService;
import persistence.mapper.TemplateMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.List;

@Slf4j // 基于 lombok 自动生成 logger 日志记录实例
@Service // SpringBoot 中注册 Service Bean 的注解
@RequiredArgsConstructor // 基于 lombok 根据类所有 final 属性生成构造函数 即可完成 Spring 构造注入
public class TemplateServiceImpl implements TemplateService {

<span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> TemplateMapper mapper;

<span class="hljs-meta">@Override</span>
<span class="hljs-keyword">public</span> List&lt;TemplateRespDto&gt; <span class="hljs-title function_">list</span><span class="hljs-params">(TemplateReqDto param)</span> {
    <span class="hljs-keyword">return</span> mapper.selectManySelective(param) <span class="hljs-comment">// 可根据实际情况将实体做转换</span>
}

}

实现类中也只需要重写list方法体, 将实际业务场景中需要处理的业务逻辑处理和查询方法写入其中, 并不需要关心分页功能

@Slf4j	// 同上
@RestController	// SpringBoot 中注册 Controller Bean 的注解
@RequiredArgsConstructor	// 同上
public class TemplateController {
<span class="hljs-keyword">public</span> <span class="hljs-keyword">final</span> TemplateService service;

<span class="hljs-comment">/**
 * 分页查询
 *
 * <span class="hljs-doctag">@param</span> pageParam 分页查询参数
 * <span class="hljs-doctag">@return</span> 分页查询响应
 */</span>
<span class="hljs-meta">@PostMapping(path = "page")</span>
<span class="hljs-keyword">public</span> PageInfo&lt;Result&gt; <span class="hljs-title function_">page</span><span class="hljs-params">(<span class="hljs-meta">@RequestBody</span> PageParam&lt;Param&gt; pageParam)</span> {
    <span class="hljs-keyword">return</span> service.page(pageParam);
}

<span class="hljs-comment">/**
 * 集合查询
 *
 * <span class="hljs-doctag">@param</span> listParam 集合查询参数
 * <span class="hljs-doctag">@return</span> 集合查询响应
 */</span>
<span class="hljs-meta">@PostMapping(path = "list")</span>
<span class="hljs-keyword">public</span> List&lt;Result&gt; <span class="hljs-title function_">list</span><span class="hljs-params">(<span class="hljs-meta">@RequestBody</span> Param listParam)</span> {
    <span class="hljs-keyword">return</span> service.list(listParam);
}

}

最后编码Controller接口时, 也只需要直接调用service.page即可, 而请求参数直接用PageParam包装, 将分页参数和业务参数分离, 在前后端接口联调中, 保持这种分离规范, 可以很大程度上的降低沟通和开发成本

FAQ

1. BaseService作为interface,page为什么可以声明方法体?

答: Java8中新特性之一就是为interface接口类增加了static/default方法, 即声明方法后, 其子类或实现都将默认具有这些方法, 可以直接调用
而在此处为Page方法声明default是因为page函数只关注分页参数和分页响应, 脱离了业务场景, 方法体大相径庭, 所以索性抽象定义出来, 免去了其实现的复杂冗余过程

2. PageParam的声明有什么意义? 实现IPage是为了什么?

答: PageParam是参考PageInfo编写的类 ( 不确定往后PageHelper是否会封装此类, 兴许我可以提个Issue上去, 也参与开源框架的开发 )
编写此类的目的就是为了分离分页和业务数据, 让开发者专注于业务的实现和开发, 同时也是对分页查询API的一种规范, 无论是请求还是响应都将分页相关的数据抽离出来, 单独使用
而实现IPage则是因为IPage作为PageHelper内置的interface, 在不了解它更多意义上的作用前, 可以作为我们分页参数声明的一种规范, 而IPage中也只声明了三个方法, 分别是pageNum/pageSize/orderByGetter方法, 另外在源码分析中, 我将会提到实现此接口更深层的意义

3. PageParam中除了常规的pageNum/pageSize, 为什么还需要一个orderBy?

答: 常规的分页查询中只需要pageNum/pageSize即可完成分页的目的, 但是往往伴随着分页查询的还有筛选排序, 而orderBy则是专注基于 SQL 的动态传参排序

4. orderBy如何使用? 会有什么问题吗?

答: orderBypageNum/pageSize一样, 都是Pagehelper通过MyBatis拦截器, 在 query 查询中注入进去的, 所以在前端传参时,orderBy参数应为数据库column desc/asc这种形式, 多字段排序则可以用逗号 (,) 拼接, 譬如: columnA desc,columnB,
但是另外一方面又存在两个问题, 第一就是大多数数据库表字段设计中, 都会使用蛇形 case 命名, 而非常规开发中的驼峰 case 命名, 所以存在一层转换, 而这种转换可以分配给前端传参时, 也可以分配给后端接参时.
第二就是这样赤裸裸的将排序字段暴露在接口中, 会存在order by SQL 注入的风险, 所以在实际使用过程中, 我们需要通过某些手段去校验和排查orderBy的传参是否合法, 譬如用正则表达式匹配参数值只能含有order by语法中必要的值, 例如字段名,desc or asc, 不允许包含特殊字符 / 数据库关键字等

5. pageNum/pageSize一定需要给默认值吗?

答: 通过阅读PageHelper源码, 我们得知在Page查询参数为 null 时, 它并不会赋予它们默认值, 并不进行额外的处理, 以至于导致分页失败, 而给默认值, 也是为了谨防前后端调试接口过程中可能会出现的各种意外

3. 源码分析

首先我们看PageHelper.startPage(param)过程中发生了什么 :

public static <E> Page<E> startPage(Object params) {
	Page<E> page = PageObjectUtil.getPageFromObject(params, true);
	Page<E> oldPage = getLocalPage();
	if (oldPage != null && oldPage.isOrderByOnly()) {
		page.setOrderBy(oldPage.getOrderBy());
	}
	setLocalPage(page);
	return page;
}

这是PageHelper继承 (extend) 的抽象类PageMethod中的一个静态方法

再看代码第一行 Page<E> page = PageObjectUtil.getPageFromObject(params, true)发生了什么:

public static <T> Page<T> getPageFromObject(Object params, boolean required) {
	if (params == null) {
		throw new PageException("无法获取分页查询参数!");
	} else if (params instanceof IPage) {
		IPage pageParams = (IPage)params;
		Page page = null;
		if (pageParams.getPageNum() != null && pageParams.getPageSize() != null) {
			page = new Page(pageParams.getPageNum(), pageParams.getPageSize());
		}
		if (StringUtil.isNotEmpty(pageParams.getOrderBy())) {
			if (page != null) {
				page.setOrderBy(pageParams.getOrderBy());
			} else {
				page = new Page();
				page.setOrderBy(pageParams.getOrderBy());
				page.setOrderByOnly(true);
			}
		}
		return page;
	} else {
        ... // 此处我只截取了部分代码片段, 以上是较为重要的一块
	}
}

可以看到在此方法中, 会先判断params是否为 null, 再而通过instanceof判断是否为IPage的子类或实现类
如果以上两个if/else 皆不满足, 则PageHelper则会在我省略贴出的代码中通过大量的反射代码来获取pageNum/pageSize以及orderBy.
总所皆知, 反射在 Java 中虽然广泛应用, 并且作为语言独有特性之一, 深受广大开发者的喜爱, 但是反射在某种程度上, 是需要性能成本的, 甚至于现阶段很多主流的框架和技术, 都在尽量减少反射的运用, 以防止框架性能过差, 被市场淘汰.
那么到此为止, 我们也终于解释并知道了为什么PageParam要实现IPage接口了, 在此处的代码中可以直接通过接口获取到分页参数, 而不需要通过有损性能的反射获取PageHelper需要的参数

继续看startPage中的后续代码:

public abstract class PageMethod {
    protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal();
    protected static boolean DEFAULT_COUNT = true;
<span class="hljs-keyword">public</span> <span class="hljs-title function_">PageMethod</span><span class="hljs-params">()</span> {
}

<span class="hljs-keyword">protected</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">setLocalPage</span><span class="hljs-params">(Page page)</span> {
    LOCAL_PAGE.set(page);
}

<span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> &lt;T&gt; Page&lt;T&gt; <span class="hljs-title function_">getLocalPage</span><span class="hljs-params">()</span> {
    <span class="hljs-keyword">return</span> (Page)LOCAL_PAGE.get();
}
...
...

}

可以看到PageHelper继承的抽象类PageMethod中声明了一个Page的线程本地变量, 而getLocalPage()则是为了获取当前线程中的Page
而接下来if (oldPage != null && oldPage.isOrderByOnly())则是判断是否存在旧分页数据
此处的isOrderByOnly通过getPageFromObject()函数我们可以知道, 当只存在orderBy参数时, 即为true
也就是说, 当存在旧分页数据并且旧分页数据只有排序参数时, 就将旧分页数据的排序参数列入新分页数据的排序参数
然后将新的分页数据page存入本地线程变量中
实际应用场景中, 这种情况还是比较少, 仅排序而不分页, 所以某种角度上而言, 我们仅当了解便好

接下来再看doSelectPageInfo(ISelect) 中发生了什么:

public <E> PageInfo<E> doSelectPageInfo(ISelect select) {
	select.doSelect();
	return this.toPageInfo();
}

可以看到, 该方法的实现非常简单明了, 就是通过注册声明ISelect接口由开发自定义集合查询方式并由它内部执行, 随后便返回PageInfo实体
前面我们有提到,PageHelper基于MyBatis拦截器达到分页的目的, 那么为什么此处的ISelect.doSelect()执行, 就可以返回PageInfo实体呢?
实际上这便是拦截器的妙用所在, 在select.doSelect()执行时, 会触发PageHelper自定义的MyBatis查询拦截器, 并通过解析 SQL 和 SQL 参数, 根据数据库类型, 进行分页, 譬如 MySQL 的limit,Oracle 的Rownum等,
同时还会在我们定义的查询 SQL 之前,PageHelper会重新生成一条 select count(*) 的 SQL 率先执行, 已达到它定义Page内置分页参数的目的

@Intercepts({@Signature(
    type = Executor.class,
    method = "query",
    args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
), @Signature(
    type = Executor.class,
    method = "query",
    args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}
)})
public class PageInterceptor implements Interceptor {
    private volatile Dialect dialect;
    private String countSuffix = "_COUNT";
    protected Cache<String, MappedStatement> msCountMap = null;
    private String default_dialect_class = "com.github.pagehelper.PageHelper";
<span class="hljs-keyword">public</span> <span class="hljs-title function_">PageInterceptor</span><span class="hljs-params">()</span> {
}

<span class="hljs-keyword">public</span> Object <span class="hljs-title function_">intercept</span><span class="hljs-params">(Invocation invocation)</span> <span class="hljs-keyword">throws</span> Throwable {
...
...
}

}

以上便是PageHelper内置的自定义MyBatis拦截器, 因代码量过多, 为了保证不违反本博文文不对题的原则, 此处不再做多余讲解, 如有需要, 我可以另行写一篇博客单独解释并讲解MyBatis拦截器的概念和原理, 深度解析MyBatis源码

拓展

PageHelper不仅有pageNum/pageSize/orderBy这几个参数, 更还有pageSizeZero, reasonable参数等用以更进阶的分页查询定义, 如需更深入的了解, 我可以另行写一遍进阶 PageHelper 使用, 此文只作为寻常开发使用讲解

四. 总结

PageHelper 作为 GitHub 上现在近 10K 的开源分页框架, 也许代码深度和广度不及主流市场框架和技术, 虽然在功能的实现和原理上, 造轮子的难度不高, 源码也很清晰, 但是在很大程度上解决了很多基于 MyBatis 的分页技术难题, 简化并提示了广大开发者的效率, 这才是开发者们在开发的路上应该向往并为之拼搏的方向和道路.
而我们作为受益者, 也不应当仅仅是对其进行基本的使用, 开发之余, 我们也应该关注一些框架的拓展, 对框架的底层有一定程度上的了解, 并为之拓展和优化

此处再次放上 PageHelper 的开源仓库!

https://mvnrepository.com/artifact/com.github.pagehelper/pagehelper-spring-boot-starter

我是臣不贰, 你, 认识我了吗?

近期热文推荐:

1.Java 15 正式发布, 14 个新特性,刷新你的认知!!

2.终于靠开源项目弄到 IntelliJ IDEA 激活码了,真香!

3.我用 Java 8 写了一段逻辑,同事直呼看不懂,你试试看。。

4.吊打 Tomcat ,Undertow 性能很炸!!

5.《Java 开发手册(嵩山版)》最新发布,速速下载!

觉得不错,别忘了随手点赞 + 转发哦!