1、引入依赖

只引入 PageHelper 不会自动适配 SpringBoot 失效,还需要整合依赖 pagehelper-spring-boot-autoconfigure

1
2
3
4
5
6
7
8
9
10
11
12
<!--pagehelper分页插件 -->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.3.0</version>
</dependency>
<!-- Mybatis-plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.2.0</version>
</dependency>

2、配置文件配置相应的参数

application.yml

1
2
3
4
5
6
pagehelper:
auto-dialect: on
reasonable: true
support-methods-arguments: true
page-size-zero: true
params: count=countSql

application.properties

1
2
3
4
5
6
7
8
9
10
#分页插件会自动检测当前的数据库链接,自动选择合适的分页方式。 你可以配置helperDialect 属性来指定分页插件是否开启断言。
pagehelper.helper-dialect=on
#分页合理化参数,默认值为 false 。当该参数设置为 true 时, pageNum<=0 时会查询第一页, pageNum>pages (超过总数时),会查询最后一页。
pagehelper.reasonable=true
#支持通过Mapper接口参数传递page参数,默认值为falset
pagehelper.support-methods-arguments=true
#默认值为 false ,当该参数设置为 true 时,如果 pageSize=0 或者 RowBounds.limit =0 就会查询出全部的结果(相当于没有执行分页查询,但是返回结果仍然是 Page 类型)。
pagehelper.pageSizeZero=true
#为了支持 startPage(Object params) 方法,增加了该参数来配置参数映射,用于从对象中根据属性名取值
pagehelper.params=count=countSql

3、使用

1
2
3
4
5
6
7
8
9
10
11
 /**
* 查看后台文章
*
* @param conditionVO 条件
* @return {@link ApiResponse<PageResult<ArticleBackDTO>>} 后台文章列表
*/
@ApiOperation(value = "查看后台文章")
@GetMapping("/admin/articles")
public ApiResponse<PageResult<ArticleBackDTO>> listArticleBacks(ConditionVO conditionVO) {
return ApiResponse.ok(articleService.listArticleBacks(conditionVO));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Override
public PageResult<ArticleBackDTO> listArticleBacks(ConditionVO condition) {
// 查询文章总量
Integer count = articleMapper.countArticleBacks(condition);
if (count == 0) {
return new PageResult<>();
}
// 查询后台文章
PageHelper.startPage(condition.getCurrent(), condition.getSize());
List<ArticleBackDTO> articleBackDTOList = articleMapper.listArticleBacks(condition);
// 查询文章点赞量和浏览量
Map<Object, Double> viewsCountMap = redisService.zAllScore(ARTICLE_VIEWS_COUNT);
Map<String, Object> likeCountMap = redisService.hGetAll(ARTICLE_LIKE_COUNT);
// 封装点赞量和浏览量
articleBackDTOList.forEach(item -> {
Double viewsCount = viewsCountMap.get(item.getId());
if (Objects.nonNull(viewsCount)) {
item.setViewsCount(viewsCount.intValue());
}
item.setLikeCount((Integer) likeCountMap.get(item.getId().toString()));
});
return new PageResult<>(articleBackDTOList, count);

}

4、使用提示

  • 只有紧跟在PageHelper.startPage()方法后的第一个 Mybatis 的查询(Select)方法会被分页。
  • 请不要在系统中配置多个分页插件(使用 Spring 时,mybatis-config.xml 和 Spring 配置方式,请选择其中一种,不要同时配置多个分页插件)!
  • 对于带有 for update 的 sql,会抛出运行时异常,对于这样的 sql 建议手动分页,毕竟这样的 sql 需要重视。
  • 由于嵌套结果方式会导致结果集被折叠,因此分页查询的结果在折叠后总数会减少,所以无法保证分页结果数量正确。

5. 源码分析

我们先来看看 startPage 方法。进入此方法,发现一堆方法重载,最后进入真正的 startPage 方法,有 5 个参数,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 开始分页
*
* @param pageNum 页码
* @param pageSize 每页显示数量
* @param count 是否进行count查询
* @param reasonable 分页合理化,null时用默认配置
* @param pageSizeZero true且pageSize=0时返回全部结果,false时分页,null时用默认配置
*/
public static <E> Page<E> startPage(int pageNum, int pageSize, boolean count, Boolean reasonable, Boolean pageSizeZero) {
Page<E> page = new Page<E>(pageNum, pageSize, count);
page.setReasonable(reasonable);
page.setPageSizeZero(pageSizeZero);
//当已经执行过orderBy的时候
Page<E> oldPage = getLocalPage();
if (oldPage != null && oldPage.isOrderByOnly()) {
page.setOrderBy(oldPage.getOrderBy());
}
setLocalPage(page);
return page;
}

getLocalPagesetLocalPage 方法做了什么操作?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/**
* 基础分页方法
*
* @author liuzh
*/
public abstract class PageMethod {
protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<Page>();
protected static boolean DEFAULT_COUNT = true;

/**
* 设置 Page 参数
*
* @param page
*/
protected static void setLocalPage(Page page) {
LOCAL_PAGE.set(page);
}

/**
* 获取 Page 参数
*
* @return
*/
public static <T> Page<T> getLocalPage() {
return LOCAL_PAGE.get();
}

/**
* 移除本地变量
*/
public static void clearPage() {
LOCAL_PAGE.remove();
}

/**
* 获取任意查询方法的count总数
*
* @param select
* @return
*/
public static long count(ISelect select) {
Page<?> page = startPage(1, -1, true);
select.doSelect();
return page.getTotal();
}

原来是将 page 放入了 ThreadLocal<Page> 中。ThreadLocal是每个线程独有的变量,与其他线程不影响,是放置 page 的好地方。setLocalPage之后,一定有地方 getLocalPage

Spring Boot 为我们做了什么

我们找到 PageHelper 的自动配置类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Configuration
@ConditionalOnBean(SqlSessionFactory.class)
@EnableConfigurationProperties(PageHelperProperties.class)
@AutoConfigureAfter(MybatisAutoConfiguration.class)
public class PageHelperAutoConfiguration {

@Autowired
private List<SqlSessionFactory> sqlSessionFactoryList;

@PostConstruct
public void addPageInterceptor() {
PageInterceptor interceptor = new PageInterceptor();
Properties properties = new Properties();
//先把一般方式配置的属性放进去
properties.putAll(pageHelperProperties());
//在把特殊配置放进去,由于close-conn 利用上面方式时,属性名就是 close-conn 而不是 closeConn,所以需要额外的一步
properties.putAll(this.properties.getProperties());
interceptor.setProperties(properties);
for (SqlSessionFactory sqlSessionFactory : sqlSessionFactoryList) {
// 添加inteceptor到 mybatis 中
sqlSessionFactory.getConfiguration().addInterceptor(interceptor);
}
}

}

我们会发现,Spring Boot 在启动的时候会调用 addPageInterceptor()方法,为所有的 SqlSessionFactory添加 PageHelper 的拦截器。
分页插件的使用,首先是在 Mybatis 里面配置了分页拦截器(PageInterceptor),即在执行相关 SQL 之前会拦截做一点事情,所以应该就是在执行selectList的时候,会自动为 SQL 加上limit 1,8

PageHelper 实际拦截 SQL

Mybatis 拦截器可以对下面 4 种对象进行拦截:

拦截对象 拦截方法 方法作用
Executor update 对应 insert,delete,update 语句
query 对应 select 语句
flushStatements 刷新 Statement
commit 提交事务
rollback 回滚事务
getTransaction 获取事务
close 关闭事务
isClosed 判断事务是否关闭
StatementHandler prepare 预编译 SQL
parameterize 设置参数
batch 批处理
update 对应 insert,delete,update 语句
query 对应 select 语句
ParameterHandler getParameterObject 获取参数
setParameters 设置参数
ResultSetHandler handleResultSets 处理结果集
handleOutputParameters 处理存储过程出参
  • Executor:mybatis 的内部执行器,作为调度核心负责调用 StatementHandler 操作数据库,并把结果集通过 ResultSetHandler 进行自动映射
  • StatementHandler: 封装了 JDBC Statement 操作,是 sql 语法的构建器,负责和数据库进行交互执行 sql 语句
  • ParameterHandler:作为处理 sql 参数设置的对象,主要实现读取参数和对 PreparedStatement 的参数进行赋值
  • ResultSetHandler:处理 Statement 执行完成后返回结果集的接口对象,mybatis 通过它把 ResultSet 集合映射成实体对象

而 PageHelper 就是拦截 Excutor 的 query 方法,来看一下在 stater 中自动加载的 PageInterceptor interceptor = new PageInterceptor();分页拦截器源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
@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";

@Override
public Object intercept(Invocation invocation) throws Throwable {
try {
Object[] args = invocation.getArgs();
MappedStatement ms = (MappedStatement) args[0];
Object parameter = args[1];
RowBounds rowBounds = (RowBounds) args[2];
ResultHandler resultHandler = (ResultHandler) args[3];
Executor executor = (Executor) invocation.getTarget();
CacheKey cacheKey;
BoundSql boundSql;
//由于逻辑关系,只会进入一次
if (args.length == 4) {
//4 个参数时
boundSql = ms.getBoundSql(parameter);
cacheKey = executor.createCacheKey(ms, parameter, rowBounds, boundSql);
} else {
//6 个参数时
cacheKey = (CacheKey) args[4];
boundSql = (BoundSql) args[5];
}
checkDialectExists();
//对 boundSql 的拦截处理
if (dialect instanceof BoundSqlInterceptor.Chain) {
boundSql = ((BoundSqlInterceptor.Chain) dialect).doBoundSql(BoundSqlInterceptor.Type.ORIGINAL, boundSql, cacheKey);
}
List resultList;
//调用方法判断是否需要进行分页,如果不需要,直接返回结果
if (!dialect.skip(ms, parameter, rowBounds)) {
//判断是否需要进行 count 查询
if (dialect.beforeCount(ms, parameter, rowBounds)) {
//查询总数
Long count = count(executor, ms, parameter, rowBounds, null, boundSql);
//处理查询总数,返回 true 时继续分页查询,false 时直接返回
if (!dialect.afterCount(count, parameter, rowBounds)) {
//当查询总数为 0 时,直接返回空的结果
return dialect.afterPage(new ArrayList(), parameter, rowBounds);
}
}
resultList = ExecutorUtil.pageQuery(dialect, executor,
ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);
} else {
//rowBounds用参数值,不使用分页插件处理时,仍然支持默认的内存分页
resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);
}
return dialect.afterPage(resultList, parameter, rowBounds);
} finally {
if(dialect != null){
dialect.afterAll();
}
}
}

其中resultList = ExecutorUtil.pageQuery(。。。);是关键代码,继续看
转到 ExecutorUtil 抽象类的pageQuery方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static <E> List<E> pageQuery(Dialect dialect, Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql, CacheKey cacheKey) throws SQLException {
if(!dialect.beforePage(ms, parameter, rowBounds)) {
return executor.query(ms, parameter, RowBounds.DEFAULT, resultHandler, cacheKey, boundSql);
} else {
parameter = dialect.processParameterObject(ms, parameter, boundSql, cacheKey);
String pageSql = dialect.getPageSql(ms, boundSql, parameter, rowBounds, cacheKey);
BoundSql pageBoundSql = new BoundSql(ms.getConfiguration(), pageSql, boundSql.getParameterMappings(), parameter);
Map<String, Object> additionalParameters = getAdditionalParameter(boundSql);
Iterator var12 = additionalParameters.keySet().iterator();

while(var12.hasNext()) {
String key = (String)var12.next();
pageBoundSql.setAdditionalParameter(key, additionalParameters.get(key));
}

return executor.query(ms, parameter, RowBounds.DEFAULT, resultHandler, cacheKey, pageBoundSql);
}
}

在抽象类 AbstractHelperDialect 的getPageSql获取到对应的 Page 对象

1
2
3
4
5
6
7
8
9
10
public String getPageSql(MappedStatement ms, BoundSql boundSql, Object parameterObject, RowBounds rowBounds, CacheKey pageKey) {
String sql = boundSql.getSql();
Page page = this.getLocalPage();
String orderBy = page.getOrderBy();
if(StringUtil.isNotEmpty(orderBy)) {
pageKey.update(orderBy);
sql = OrderByParser.converToOrderBySql(sql, orderBy);
}
return page.isOrderByOnly()?sql:this.getPageSql(sql, page, pageKey);
}

进入到 MySqlDialect 类的getPageSql方法进行 SQL 封装,根据 page 对象信息增加 Limit。分页的信息就是这么拼装起来的

1
2
3
4
5
6
7
8
9
10
public String getPageSql(String sql, Page page, CacheKey pageKey) {
StringBuilder sqlBuilder = new StringBuilder(sql.length() + 14);
sqlBuilder.append(sql);
if(page.getStartRow() == 0) {
sqlBuilder.append(" LIMIT ? ");
} else {
sqlBuilder.append(" LIMIT ?, ? ");
}
return sqlBuilder.toString();
}

将最后拼装好的 SQL 返回给 DefaultSqlSession 执行查询并返回

1
2
3
4
5
6
7
8
9
10
public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {
try {
MappedStatement ms = configuration.getMappedStatement(statement);
return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER);
} catch (Exception e) {
throw ExceptionFactory.wrapException("Error querying database. Cause: " + e, e);
} finally {
ErrorContext.instance().reset();
}
}

至此整个查询过程完成,原来 PageHelper 的分页功能是通过 Limit 拼接 SQL 实现的。

PageHelper 的分页原理,最核心的部分是实现了 MyBatis 的 Interceptor 接口,从而将分页参数拦截在执行 sql 之前,拼装出分页 sql 到数据库中执行。
初始化自动配置的时候,将 PageInterceptor 初始化并加入 MyBatis 拦截器,记录在 interceptorChain 中。
执行的时候,PageHelper 首先将 page 需求记录在 ThreadLocal<Page> 中,然后在拦截的时候,从 ThreadLocal<Page> 中取出 page,拼装出分页 sql,然后执行。
同时将结果分页信息(包括当前页,每页条数,总页数,总记录数等)设置回 page,让业务代码可以获取。

6. 总结

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