9.5 分页
MyBatis 具有分页功能,它里面有一个类 - RowBounds,我们可以使用 RowBounds 分页。但是使用它分页有一个很严重的问题,那就是它会在一条 SQL 中查询所有的结果出来,然后根据从第几条到第几条取出数据返回。如果这条 SQL 返回很多数据,毫无疑问,系统就很容易抛出内存溢出的异常。因此我们需要用其他方法去处理它。这里将分别讨论用 RowBounds 传递参数的分页方法和使用插件的 SQL 分页方法。
9.5.1 RowBounds 分页
RowBounds 分页是 MyBatis 的内置功能,在任何的 select 语句中都可以使用它。我们来掌握一下 RowBounds 的源码,如代码清单 9-24 所示。
代码清单 9-24:RowBounds 源码
public class RowBounds { public static final int NO_ROW_OFFSET = 0; public static final int NO_ROW_LIMIT = Integer.MAX_VALUE; public static final RowBounds DEFAULT = new RowBounds(); private int offset; private int limit; public RowBounds() { this.offset = NO_ROW_OFFSET; this.limit = NO_ROW_LIMIT; } public RowBounds(int offset, int limit) { this.offset = offset; this.limit = limit; } public int getOffset() { return offset; } public int getLimit() { return limit; } }
RowBounds 主要定义了两个参数,offset 和 limit。其中,offset 代表从第几行开始读取数据,而 limit 则是限制返回的记录数。在默认的情况下,offset 的默认值为 0,而 limit 则是 Java 所允许的最大整数(2147483647)。不过在一些大数据的场合,一次性取出大量的数据,比方说从一张表中一次性取出上百万条记录,这对内存的消耗是很大的,性能差不说,这么多的数据还会引起内存溢出的问题,所以在大数据的查询场景下要慎重使用它。
我们看一个简单的查询,通过角色名称模糊查询角色信息,如代码清单 9-25 所示。
代码清单 9-25:通过角色名称模糊查询
<select id="findRolesByName" parameterType="string" resultMap= "roleResultMap"> select role_no, role_name, note from t_role where role_name like concat('%', #{roleName}, '%') </select>
接口定义需要修改为下面的形式,如代码清单 9-26 所示。
代码清单 9-26:定义 RowBounds 接口
public List<Role> findRolesByName(String roleName, RowBounds rowbounds);
这样便可以使用这个参数了,现在让我们测试一下代码清单 9-27。
代码清单 9-27:测试 RowBounds
RoleMapper roleMapper = session.getMapper(RoleMapper.class); List<Role> roleList = roleMapper.findRolesByName("role", new RowBounds(0, 5)); for(Role role : roleList) { System.err.println("role_no=>"+role.getRoleNo() + "\t role_ name=>"+role.getRoleName() ); }
测试结果如下。
....... role_no=>role1 role_name=>role_name1 role_no=>role2 role_name=>role_name2 role_no=>role3 role_name=>role_name3 role_no=>role4 role_name=>role_name4 role_no=>role5 role_name=>role_name5 ......
显然系统限制了 5 条记录,在一些不需要考虑大数据量的场景下我们可以使用它,比较方便和简易。
注意,虽然 RowBounds 分页在任何的 select 语句中都可以使用,但是它是在 SQL 查询出所有结果的基础上截取数据的,所以在大数据量返回的 SQL 中并不适用。RowBounds 分页更适合在一些返回数据结果较少的查询中使用。
9.5.2 插件分页
9.5.1 节我们谈到了 RowBounds 分页的不足,大数据量下会常常发生内存溢出,为了避免这个问题,我们需要修改 SQL。因此,我们往往需要提供一个插件重写 SQL 来进行分页,以避免大数据量的问题。
在编写插件之前,我们需要回顾第 6 章和第 7 章的内容,只有在掌握了 SqlSession 下四大对象的运作过程和插件开发的过程,才能写出安全高效的插件。
分页插件是 MyBatis 中最为经典和常用的插件,所以首先要确定拦截方法,通过第 6 章的学习我们知道,SQL 的预编译是在 StatementHandler 对象的 prepare 方法中进行的,因此我们需要在此方法运行之前去创建计算总数 SQL,并且通过它得到查询总条数,然后将当前要运行的 SQL 改造为分页的 SQL,这样就能保证 SQL 分页。
为了方便分页插件的使用,这里先定义一个 POJO 对象,如代码清单 9-28 所示。
代码清单 9-28:定义分页插件 POJO
public class PageParams { private Integer page;//当前页码 private Integer pageSize;//每页条数 private Boolean useFlag;//是否启用插件 private Boolean checkFlag;//是否检测当前页码的有效性 private Integer total;//当前 SQL 返回总数,插件回填 private Integer totalPage;//SQL 以当前分页的总页数,插件回填 // ......setters and getters...... }
这样就可以通过这个 POJO 去定义当前的页码,每页的条数,是否启用插件,是否检测当前页码的有效性,通过这些属性可以控制插件的行为。而 total 和 totalPage 则是等待插件回填的两个数据,通过回填的数据,调用者就可以轻易得到这条 SQL 运行的总数和总页数。
有了思路,就要去确定方法签名,MyBatis 插件要求提供 3 个注解信息:拦截对象类型(type,只能是四大对象中的一个),方法名称(method)和方法参数(args)。由于我们拦截的是 StatementHandler 对象的 prepare 方法,它的参数是 Connnection 对象,所以就可以得到如代码清单 9-29 所示的分页插件签名。
代码清单 9-29:分页插件签名
@Intercepts({ @Signature( type =StatementHandler.class, method = "prepare", args ={Connection.class} )}) publicclass PagingPlugin implements Interceptor { ...... }
通过第 7 章的学习,大家都知道插件需要实现 Interceptor 接口,它定义了 3 个方法:
intercept。
plugin。
setProperties。
定义分页 POJO 属性的一些默认值,有了默认值可以更加方便地使用分页插件。我们可以通过插件接口所提供的 setProperties(Propterties porps)方法进行设置,因此只要在分页插件中配置这些默认值就可以了。而 plugin() 方法用于生成代理对象,可以使用 MyBatis 的方法 Plugin.wrap(),至于其原理请查看第 7 章的内容。我们很快就可以完成 plugin 方法和 setProperties 方法,如代码清单 9-30 所示。
代码清单 9-30:分页插件的 setProperties 和 plugin 方法
@Intercepts({ @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class})}) public class PagingPlugin implements Interceptor { private Integer defaultPage;//默认页码 private Integer defaultPageSize;//默认每页条数 private Boolean defaultUseFlag;//默认是否启动插件 private Boolean defaultCheckFlag;//默认是否检测当前页码的正确性 @Override public Object plugin(Object statementHandler) { return Plugin.wrap(statementHandler, this); } @Override public void setProperties(Properties props) { String strDefaultPage = props.getProperty("default.page", "1"); String strDefaultPageSize = props.getProperty("default.pageSize", "50"); String strDefaultUseFlag = props.getProperty("default.useFlag", "false"); String strDefaultCheckFlag = props.getProperty("default.checkFlag", "false"); this.defaultPage = Integer.parseInt(strDefaultPage); this.defaultPageSize = Integer.parseInt(strDefaultPageSize); this.defaultUseFlag = Boolean.parseBoolean(strDefaultUseFlag); this.defaultCheckFlag = Boolean.parseBoolean(strDefaultCheckFlag); } @Override public Object intercept(Invocation invocation) throws Throwable { ..... } ...... }
这里使用了 setProperties() 方法去设置配置的参数得到默认值,然后通过 Plugin.wrap() 方法去生产动态代理对象,一般而言我们都是那么使用的。
现在我们讨论 intercept 方法,这是我们的重点。这里支持 3 种传递分页参数的方法:继承 PageParams 的 POJO 作为参数;使用注解 @Param 传递 PageParams 对象;使用 Map 传递参数。使用其中任意一种都是支持的,稍后会给出分离分页参数的方法。
这里需要先统计当前 SQL 运行可以返回的总条数。因此,我们先要构造统计总条数的 SQL,然后运行它得到总条数,再通过每页多少条的 pageSize 进而算出最大页数,回填之前定义的 POJO。而拿到当前运行的 SQL 去构建统计总条数的 SQL 还是比较容易的,但是这里的难点是给构造的计算总条数 SQL 设置参数。这是头疼的问题,不过应该注意到它和查询语句的参数是一致的,因此可以利用 MyBatis 自身提供的类来设置参数,在第 6 章讲述过它是通过 ParameterHandler 对象完成的,因此需要构建一个新的 ParameterHandler 对象,在 MyBatis 中默认是使用 DefaultParameterHandler 来实现 ParameterHandler 的,使用它就可以给总条数 SQL 设置参数,所以先看看它的构造方法,如代码清单 9-31 所示。
代码清单 9-31:DefaultParameterHandler 的构造方法
public class DefaultParameterHandler implements ParameterHandler { ...... public DefaultParameterHandler(MappedStatement mappedStatement, Object parameterObject, BoundSql boundSql) { ...... } ...... }
其中,用当前查询语句的上下文便可以得到 mappedStatement 和 parameterObject,而 BoundSql 则要使用统计总数的 SQL。因此,在构建新的 ParameterHander 之前,需要构建一个新的 BoundSql,它的构造方法如代码清单 9-32 所示。
代码清单 9-32:BoundSql 构造方法
public class BoundSql { ...... public BoundSql(Configuration configuration, String sql, List <ParameterMapping> parameterMappings, Object parameterObject) { ...... } ...... }
configuration,parameterMappings 和 parameterObject 都可以在当前执行查询 SQL 的 BoundSql 中获得,而我们仅仅需要修改统计的 SQL 而已。我们来看看 intercept 的实现,如代码清单 9-33 所示。
代码清单 9-33:插件分页 intercept 方法
@Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler stmtHandler = getUnProxyObject(invocation); MetaObject metaStatementHandler = SystemMetaObject.forObject (stmtHandler); String sql = (String) metaStatementHandler.getValue("delegate. boundSql.sql"); //不是 select 语句 if (!checkSelect(sql)) { return invocation.proceed(); } BoundSql boundSql = (BoundSql) metaStatementHandler.getValue ("delegate.boundSql"); Object parameterObject = boundSql.getParameterObject(); PageParams pageParams = getPageParams(parameterObject); if (pageParams == null) {//没有分页参数,不启用插件 return invocation.proceed(); } //获取分页参数,获取不到时候使用默认值 Integer pageNum = pageParams.getPage() == null? this.defaultPage : pageParams.getPage(); Integer pageSize = pageParams.getPageSize() == null? this.defaultPageSize : pageParams.getPageSize(); Boolean useFlag = pageParams.getUseFlag() == null? this.defaultUseFlag : pageParams.getUseFlag(); Boolean checkFlag = pageParams.getCheckFlag() == null? this.defaultCheckFlag : pageParams.getCheckFlag(); if (!useFlag) { //不使用分页插件 return invocation.proceed(); } int total = getTotal(invocation, metaStatementHandler, boundSql); //回填总数到分页参数里 setTotalToPageParams(pageParams, total, pageSize); //检查当前页码的有效性 checkPage(checkFlag, pageNum, pageParams.getTotalPage()); //修改 SQL return changeSQL(invocation, metaStatementHandler, boundSql, pageNum, pageSize); }
其中加粗的代码是需要后续讨论的方法。首先需要从代理对象中分离出真实对象,通过 MetaObject 绑定这个非代理对象来获取各种参数值,这是插件中常常用到的方法。让我们看看获取真实对象的方法,如代码清单 9-34 所示。
代码清单 9-34:获取真实对象
/** * 从代理对象中分离出真实对象 * @param ivt --Invocation * @return 非代理 StatementHandler 对象 */ private StatementHandler getUnProxyObject(Invocation ivt) { StatementHandler statementHandler = (StatementHandler) ivt.getTarget(); MetaObject metaStatementHandler = SystemMetaObject.forObject (statementHandler); // 分离代理对象链(由于目标类可能被多个拦截器拦截,从而形成多次代理,通过循环可以分离出最原始的目标类) Object object = null; while (metaStatementHandler.hasGetter("h")) { object = metaStatementHandler.getValue("h"); } if (object == null) { return statementHandler; } return (StatementHandler) object; }
这里从 BoundSql 中获取我们当前要执行的 SQL,如果是 select 语句我们才进行分页处理,否则直接通过反射执行原有的 prepare 方法,所以这里有一个判断的方法,如代码清单 9-35 所示。
代码清单 9-35:判断是否 select 语句
/** * 判断是否 select 语句 * @param sql * @return */ private boolean checkSelect(String sql) { String trimSql = sql.trim(); int idx = trimSql.toLowerCase().indexOf("select"); return idx == 0; }
这个时候需要获取分页参数。参数可以是 Map 对象,也可以是 POJO,或者通过 @Param 注解。这里支持继承 PageParams 或者 Map。这里支持继承 PageParams 或者 Map,从映射器的内部组成的参数规则可以知道 @Param 方式在 MyBatis 也是一种 Map 传参。获取分页参数的方法,如代码清单 9-36 所示。
代码清单 9-36:获取分页参数
/** * 分解分页参数,这里支持使用 Map 和 @Param 注解传递参数,或者 POJO 继承 PageParams,这三种方式都是允许的 * @param parameterObject --sql 允许参数 * @return 分页参数 */ private PageParams getPageParams(Object parameterObject) { if(parameterObject == null) { return null; } PageParams pageParams = null; //支持 Map 参数和 MyBatis 的 @Param 注解参数 if (parameterObject instanceof Map) { @SuppressWarnings("unchecked") Map<String, Object> paramMap = (Map<String, Object>) parameterObject; Set<String> keySet = paramMap.keySet(); Iterator<String> iterator = keySet.iterator(); while(iterator.hasNext()) { String key = iterator.next(); Object value = paramMap.get(key); if (value instanceof PageParams) { return (PageParams)value; } } } else if (parameterObject instanceof PageParams) {//继承方式 pageParams = (PageParams) parameterObject; } return pageParams ; }
判断参数是否是一个 Map。如果是 Map,则遍历 Map 找到分页参数;如果不是 Map,就判断它是不是继承了 PageParams 类,如果是就直接返回。一旦得到的这个分页参数为 null 或者分页参数指示不启用插件,那么就直接执行原来拦截的方法返回。
得到分页参数后,要获取总数。获取总数是分页插件最难的部分,但是根据之前的分析我们也有了应对的方法,这个获取总数的方法,如代码清单 9-37 所示。
代码清单 9-37:分页插件获取总数的方法
/** * 获取总数 * * @param ivt Invocation * @param metaStatementHandler statementHandler * @param boundSql sql * @return sql 查询总数 * @throws Throwable 异常 */ private int getTotal(Invocation ivt, MetaObject metaStatementHandler, BoundSql boundSql) throws Throwable { //获取当前的 mappedStatement MappedStatement mappedStatement = (MappedStatement) metaStatementHandler.getValue("delegate.mappedStatement"); //配置对象 Configuration cfg = mappedStatement.getConfiguration(); //当前需要执行的 SQL String sql = (String) metaStatementHandler.getValue("delegate. boundSql.sql"); // 改写为统计总数的 SQL,这里是 MySQL 数据库,如果是其他的数据库,需要按数据库的 SQL 规范改写 String countSql = "select count(*) as total from (" + sql + ") $_paging"; //获取拦截方法参数,我们知道是 Connection 对象 Connection connection = (Connection) ivt.getArgs()[0]; PreparedStatement ps = null; int total = 0; try { //预编译统计总数 SQL ps = connection.prepareStatement(countSql); //构建统计总数 BoundSql BoundSql countBoundSql = new BoundSql(cfg, countSql, boundSql. getParameterMappings(), boundSql.getParameterObject()); //构建 MyBatis 的 ParameterHandler 用来设置总数 SQL 的参数 ParameterHandler handler = new DefaultParameterHandler (mappedStatement, boundSql.getParameterObject(), countBoundSql); //设置总数 SQL 参数 handler.setParameters(ps); //执行查询 ResultSet rs = ps.executeQuery(); while (rs.next()) { total = rs.getInt("total"); } } finally { //这里不能关闭 Connection,否则后续的 SQL 就没法继续了 if (ps != null ) { ps.close(); } } System.err.println("总条数:" + total); return total; }
我们从 BoundSql 中获取了当前需要执行的 SQL,对它进行改写就可以得到我们统计的 SQL,然后使用 Connection 预编译。设置参数是难点,因为参数规则总数和当前要执行的查询是一致的,所以使用 MyBatis 提供的 ParameteHandler 进行参数设置即可。在此之前我们分析过,需要构建一个 BoundSql 对象,而除了计算总数的 SQL,所有的参数都可以从原来的 BoundSql 对象中获得。然后进一步利用 MyBatis 提供的 DefaultParameterHandler 构建 ParameterHandler 对象,并使用 setParameters 设置参数,采用 JDBC 的方式计算出总数并将其返回,但是这里不能够关闭 Connection 对象,因为后面的查询还需要用到它。
得到这个总数后将它回填到分页参数中,这样调用者就可以得到这两个在分页中很重要的参数,如代码清单 9-38 所示。
代码清单 9-38:回填总条数和总页数到分页参数
private void setTotalToPageParams(PageParams pageParams, int total, int pageSize) { pageParams.setTotal(total); //计算总页数 int totalPage = total % pageSize == 0 ? total / pageSize : total / pageSize + 1; pageParams.setTotalPage(totalPage); }
然后,根据分页参数的设置判断是否启用检测页码正确性的处理,当当前页码大于最大页码的时候抛出异常,提示错误,如代码清单 9-39 所示。
代码清单 9-39:判断当前页码是否大于最大页码
/** * 检查当前页码的有效性. * @param checkFlag * @param pageNum * @param pageTotal * @throws Throwable */ private void checkPage(Boolean checkFlag, Integer pageNum, Integer pageTotal) throws Throwable { if (checkFlag) { //检查页码 page 是否合法. if (pageNum > pageTotal) { throw new Exception("查询失败,查询页码【" + pageNum + "】大于总页数【" + pageTotal + "】!!"); } } }
这里根据设置的参数,判断是需要检测当前页码的有效性,当无效的时候抛出异常,这样 MyBatis 就会停止以后的工作,如果正常就继续。最后,我们修改当前 SQL 为分页的 SQL,如代码清单 9-40 所示。
代码清单 9-40:改写 SQL 以满足 SQL 分页的需求
/** * 修改当前查询的 SQL * @param invocation * @param metaStatementHandler * @param boundSql * @param page * @param pageSize * @throws Exception */ private Object changeSQL(Invocation invocation, MetaObject metaStatementHandler, BoundSql boundSql, int page, int pageSize) throws Exception { //获取当前需要执行的 SQL String sql = (String) metaStatementHandler.getValue("delegate. boundSql.sql"); //修改 SQL,这里使用的是 MySQL,如果是其他数据库则需要修改 String newSql = "select * from (" + sql +")+ ") $_paging_table limit ?, ? "; //修改当前需要执行的 SQL metaStatementHandler.setValue("delegate.boundSql.sql", newSql); //相当于条用 StatementHandler 的 prepare 方法,预编译了当前 SQL 并设置原有的参数,但是少了两个分页参数,它返回的是一个 PreparedStatement 对象 PreparedStatement ps = (PreparedStatement)invocation.proceed(); //计算 SQL 总参数个数 int count = ps.getParameterMetaData().getParameterCount(); //设置两个分页参数 ps.setInt(count -1, (page - 1) * pageSize); ps.setInt(count, pageSize); return ps; }
首先,从对象中获取当前需要执行的 SQL,将其改写为分页的 SQL。然后,回填到对象中,但是改写后我们多加了两个分页参数,因此调度原有的方法(invocation.proceed())后还差这两个参数没有设置。所以我们在后面再设置它,这样就可以调用原来的 prepare 方法对 SQL 进行预编译,完成了使用插件的任务,以后我们的查询都可以得到分页。
从上面的分析看,我们只有对 MyBatis 的四大对象十分了解,才能编写出想要的插件,所以第 6 章和第 7 章是这个分页插件的学习基础。注意,在 MyBatis 中使用插件要慎重,因为插件将覆盖原有对象的方法,所以必须慎用插件,能够不用尽量不要用它。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论