返回介绍

9.5 分页

发布于 2025-04-26 13:08:35 字数 16152 浏览 0 评论 0 收藏

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 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。