1. 前言
PageHelper 是 MyBatis 的一个开源、免费、强大的物理分页插件。它在 GitHub 上非常受欢迎,拥有超过 12k 的 star,是国内 Java 开发者社区中最主流的分页解决方案之一。
- 官网/GitHub地址 : github.com/pagehelper/...
- 核心原理 : 通过 MyBatis 提供的拦截器(Interceptor)接口,在 SQL 执行前,动态地拼接上数据库特定的分页语句(如 MySQL 的
LIMIT
,Oracle 的ROWNUM
)。
2. 手动实现分页查询
在没有分页插件的情况下,实现分页需要做两件事:
- 写两条 SQL:一条查询总记录数(
COUNT
),一条查询当前页的数据(带LIMIT
)。 - 手动计算分页参数(如
startIndex = (pageNum - 1) * pageSize
)。
我们以一个简单的用户查询为例。
2.1 定义分页结果封装类
java
@Data
public class PageResult<T> {
// 当前页数据列表
private List<T> list;
// 总记录数
private long total;
// 当前页码
private int pageNum;
// 每页数量
private int pageSize;
// 总页数
private int totalPage;
/**
* 计算总页数的构造方法
*/
public PageResult(List<T> list, long total, int pageNum, int pageSize) {
this.list = list;
this.total = total;
this.pageNum = pageNum;
this.pageSize = pageSize;
// 核心计算:总页数 = (总记录数 + 每页大小 - 1) / 每页大小
this.totalPage = (int) ((total + pageSize - 1) / pageSize);
}
}
2.2 Mapper 接口
java
public interface UserMapper {
/**
* 根据偏移量和限制查询用户列表 (用于分页数据)
* @param offset 起始索引(从0开始)
* @param limit 每页记录数
* @return 用户列表
*/
List<User> selectUserList(@Param("offset") int offset, @Param("limit") int limit);
/**
* 查询用户总数量(用于分页)
* @return 总数量
*/
Long selectUserCount();
}
2.3 Mapper XML 文件
xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mapper.UserMapper">
<select id="selectUserList" resultType="User">
SELECT id, name, email
FROM user
ORDER BY id ASC -- 排序很重要,确保分页结果顺序一致
LIMIT #{offset}, #{limit} -- MySQL 分页语法:LIMIT 起始索引, 记录数
</select>
<select id="selectUserCount" resultType="java.lang.Long">
SELECT COUNT(*)
FROM user
</select>
</mapper>
3. Github PageHelper 实现分页查询
3.1 引入依赖
在项目的 pom.xml
中添加依赖(以 Maven 为例)。
xml
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>2.1.0</version>
</dependency>
对于非 Spring Boot 项目,需要添加 pagehelper
依赖并手动配置 MyBatis 拦截器。
3.2 配置(application.yml)
Spring Boot 已经提供了自动化配置,通常只需少量配置即可。
yaml
# application.yml
pagehelper:
helper-dialect: mysql # 指定数据库方言,不指定时会自动检测
reasonable: true # 分页参数合理化。当pageNum<=0时,查询第一页;当pageNum>总页数时,查询最后一页
support-methods-arguments: true # 支持通过 Mapper 接口参数来传递分页参数
3.3 在代码中使用
这是最核心的部分,用法非常简单。
java
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserMapper userMapper;
@Override
public PageInfo<User> getUserList(int pageNum, int pageSize) {
// 魔法发生在这里!关键代码:启动分页
// 这行代码必须紧贴在执行查询的语句之前,中间不能有其它SQL执行,否则会失效。
PageHelper.startPage(pageNum, pageSize);
// 这是一个普通的查询所有记录的SQL,但会被PageHelper自动分页
List<User> userList = userMapper.selectAllUsers();
// 用 PageInfo 对结果进行包装,PageInfo 包含了非常全面的分页信息
PageInfo<User> pageInfo = new PageInfo<>(userList);
return pageInfo;
}
}
- 注意点: 这里的startPage(pageNum, pageSize)其实有个缺省参数,本质上等于startPage(pageNum, pageSize,true),这里的true的作用是执行一下count计数操作。
3.4 理解返回结果
PageInfo
对象包含了所有分页相关的信息,非常适合直接返回给前端。它的常用属性如下:
java
@RestController
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/users")
public PageInfo<User> getUsers(@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize) {
PageInfo<User> pageInfo = userService.getUserList(pageNum, pageSize);
// pageInfo 包含的信息示例:
// pageInfo.getList() -> 当前页的数据列表 (List<User>)
// pageInfo.getTotal() -> 总记录数 (long)
// pageInfo.getPageNum() -> 当前页码 (int)
// pageInfo.getPageSize() -> 每页显示数量 (int)
// pageInfo.getPages() -> 总页数 (int)
// pageInfo.isIsFirstPage() -> 是否是第一页 (boolean)
// pageInfo.isIsLastPage() -> 是否是最后一页 (boolean)
// pageInfo.getPrePage() -> 上一页页码 (int)
// pageInfo.getNextPage() -> 下一页页码 (int)
// ... 还有更多导航页码等信息
return pageInfo;
}
}
3.5 高级用法
3.5.1 带条件的分页查询
PageHelper 与 MyBatis 的动态 SQL(如 where
条件)完美结合。
java
// 在 Mapper 接口中定义带条件的查询
List<User> selectUsersByName(@Param("name") String name);
// Service 中使用
public PageInfo<User> getUsersByName(String name, int pageNum, int pageSize) {
PageHelper.startPage(pageNum, pageSize);
List<User> userList = userMapper.selectUsersByName(name);
return new PageInfo<>(userList);
}
3.5.2 排序 Order By
可以使用 PageHelper
的 orderBy
方法进行排序。
java
PageHelper.startPage(pageNum, pageSize).orderBy("create_time desc");
List<User> userList = userMapper.selectAllUsers();
3.5.3 doSelectPage
方法
为了避免在 PageHelper.startPage
和查询语句之间误执行其他 SQL,可以使用 lambda 表达式风格,更加安全。
arduino
java
public PageInfo<User> getUserListSafe(int pageNum, int pageSize) {
// 使用 PageHelper 的 lambda 方法,将查询逻辑封装在里面
PageInfo<User> pageInfo = PageHelper.startPage(pageNum, pageSize)
.doSelectPageInfo(() -> userMapper.selectAllUsers()); // 自动返回 PageInfo
return pageInfo;
}
4. 原理解析
4.1 核心入口:PageHelper
类
我们常用的 PageHelper.startPage(int pageNum, int pageSize)
方法,其核心作用是将分页参数存入当前线程的 ThreadLocal
变量中。
java
// com.github.pagehelper.PageHelper
public abstract class PageHelper {
// 关键!一个ThreadLocal变量,用于存储分页参数,保证线程安全
protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<>();
public static <E> Page<E> startPage(int pageNum, int pageSize) {
// 创建一个Page对象,包含页码和大小
Page<E> page = new Page<>(pageNum, pageSize);
// 将page对象设置到当前线程的ThreadLocal中
setLocalPage(page);
return page;
}
protected static void setLocalPage(Page page) {
LOCAL_PAGE.set(page);
}
// ... 其他方法,如 getLocalPage()
}
关键点 :ThreadLocal
确保了每个线程都有自己的分页参数副本,多线程环境下不会相互干扰。
4.2 核心引擎:PageInterceptor
拦截器
这是 PageHelper 执行的关键,它实现了 MyBatis 的 Interceptor
接口,会在 Executor 执行 SQL 时进行拦截。
java
// com.github.pagehelper.PageInterceptor
@Intercepts(@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}))
public class PageInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
try {
Object[] args = invocation.getArgs();
MappedStatement ms = (MappedStatement) args[0]; // 获取MappedStatement
Object parameter = args[1];
RowBounds rowBounds = (RowBounds) args[2];
// 1. 判断是否需要分页:从ThreadLocal中获取Page对象
Page page = PageHelper.getLocalPage();
if (page != null) {
// 2. 关键!清除ThreadLocal,防止重复分页
PageHelper.clearPage();
// 3. 判断是否是支持分页的SQL(例如,不是存储过程)
if (isSupportMethod(ms, parameter)) {
// 4. 执行分页逻辑
return doProcessPage(ms, parameter, rowBounds, invocation, page);
}
}
// 如果不分页,直接执行原方法
return invocation.proceed();
} finally {
// 确保在任何情况下都清空ThreadLocal,防止内存泄漏
PageHelper.clearPage();
}
}
// ... 其他方法
}
4.3 分页处理核心:doProcessPage
方法
这个方法完成了最核心的工作:
java
private Object doProcessPage(...) throws Throwable {
// 1. 获取原始的SQL和参数
String originalSql = getOriginalSql(ms, parameter, rowBounds);
Object parameterObject = getParameterObject(ms, parameter, rowBounds);
// 2. 创建 Count MS 并执行 COUNT 查询
MappedStatement countMs = createCountMappedStatement(ms);
Long total = (Long) executeCountQuery(countMs, parameterObject, originalSql);
// 3. 计算总页数
int totalPage = (int) ((total + page.getPageSize() - 1) / page.getPageSize());
page.setTotal(total);
page.setPages(totalPage);
// 4. 生成分页SQL(例如,将 SELECT * FROM table 变成 SELECT * FROM table LIMIT 0, 10)
String pageSql = generatePageSql(originalSql, page, dialect);
// 5. 将生成的分页SQL设置为新的BoundSql,并替换到参数中
BoundSql pageBoundSql = createNewBoundSql(ms, parameterObject, pageSql);
MappedStatement pageMs = createNewMappedStatement(ms, new BoundSqlSqlSource(pageBoundSql));
// 6. 用新的MappedStatement继续执行查询(此时SQL已经是带LIMIT的了)
args[0] = pageMs; // 替换MappedStatement
args[2] = RowBounds.DEFAULT; // 将RowBounds设为默认,因为分页信息已经在SQL里了
// 7. 执行查询,得到当前页的数据列表
List resultList = (List) invocation.proceed();
// 8. 返回一个Page对象,它既包含了数据列表,也包含了分页信息
return new PageImpl(page, resultList);
}
5. 总结
- 设置参数 :
PageHelper.startPage()
将分页参数存入ThreadLocal
。 - 拦截SQL :MyBatis 执行查询时,
PageInterceptor
被触发。 - 判断分页 :拦截器从
ThreadLocal
中取出分页参数。如果存在,则进行分页处理。 - 执行Count :自动生成并执行
COUNT(*)
查询,获取总数。 - 改写SQL :根据数据库方言,将原 SQL 改写成带分页(如
LIMIT
)的 SQL。 - 执行分页查询:执行改写后的 SQL,得到当前页数据。
- 封装结果 :将数据列表和分页信息封装到
Page
对象中返回。 - 清理现场 :清除
ThreadLocal
,防止污染后续操作。