MyBatis3源码深度解析(二十三)MyBatis拦截器的原理及应用(二)自定义分页插件、慢SQL插件

前言

分页查询在日常开发中非常常见,实现的方式一般有两种:

第一种是从数据库中查询出所有满足条件的数据,然后通过应用程序进行分页处理,这种方式在数据量过大时效率比较低,而且可能会造成内存溢出,所以不太常用。

第二种是通过数据库提供的分页语句进行物理分页,这种该方式效率较高且查询数据量较少,所以是一种比较查用的分页方式。

本节基于数据库物理分页的方式编写一个MyBatis分页插件。

9.2 自定义一个MyBatis分页插件

9.2.1 自定义分页插件的编写

由于面向对象设计原则中提倡面向接口编程,因此首先可以编写一个接口,定义有关分页的一些基本方法。例如编写一个Paginable接口:

public interface Paginable<T> {

    /** 总记录数 */
    int getTotalCount();
    /** 总页数 */
    int getTotalPage();
    /** 每页记录数 */
    int getPageSize();
    /** 当前页号 */
    int getPageNo();
    
}

然后编写一个Page类实现Paginable接口,来具体描述分页信息:

public class Page<T> implements Paginable<T> {

    /** 当前页面,默认第1页 */
    private int pageNo = 1;
    /** 每页记录数,默认10条 */
    private int pageSize = 10;
    /** 总记录数 */
    private int totalCount = 0;
    /** 总页数 */
    private int totalPage = 0;
    /** 查询时间戳 */
    private long timestamp = 0;
    /** 是否全量更新,若true,则会更新totalCount */
    private boolean full = false;

    @Override
    public int getTotalCount() {
        return totalCount;
    }

    public void setTotalCount(int totalCount) {
        this.totalCount = totalCount;
        // 设置总记录数totalCount时,根据一定的规则计算出总页数totalPage
        int totalPage = totalCount % pageSize == 0 ? totalCount / pageSize : totalCount / pageSize + 1;
        this.setTotalPage(totalPage);
    }
    
    // 其他getter、setter方法 ......
}

分页插件的基本思路是:在SQL语句执行前,从目标对象中将SQL取出来,替换成能查询总记录数或进行分页的SQL语句,再放回目标对象中。

既然要提取和重新设置目标对象的属性,那么可以先定义一个工具类,通过反射机制提取和设置Java对象的属性。

public abstract class ReflectionUtils {

    /**
     * 利用反射获取指定对象的指定属性
     *
     * @param target 目标对象
     * @param fieldName 目标属性
     * @return 目标属性的值
     */
    public static Object getFieldValue(Object target, String fieldName) {
        Object result = null;
        Field field = ReflectionUtils.getField(target, fieldName);
        if (field != null) {
            field.setAccessible(true);
            try {
                result = field.get(target);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return result;
    }

    /**
     * 利用反射获取指定对象里面的指定属性
     *
     * @param target
     *            目标对象
     * @param fieldName
     *            目标属性
     * @return 目标字段
     */
    private static Field getField(Object target, String fieldName) {
        Field field = null;
        for (Class<?> clazz = target.getClass(); clazz != Object.class; clazz = clazz.getSuperclass()) {
            try {
                field = clazz.getDeclaredField(fieldName);
                break;
            } catch (NoSuchFieldException e) {
                // ignore
            }
        }
        return field;
    }

    /**
     * 利用反射设置指定对象的指定属性为指定的值
     *
     * @param target 目标对象
     * @param fieldName 目标属性
     * @param fieldValue 目标值
     */
    public static void setFieldValue(Object target, String fieldName, String fieldValue) {
        Field field = ReflectionUtils.getField(target, fieldName);
        if (field != null) {
            try {
                field.setAccessible(true);
                field.set(target, fieldValue);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}

接下来正式编写分页插件:

// 拦截StatementHandler接口的prepare()方法
@Intercepts({
        @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class PageInterceptor implements Interceptor {

    // 数据库类型
    private String databaseType;

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    @Override
    public void setProperties(Properties properties) {
        this.databaseType = properties.getProperty("databaseType");
    }

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // 获取拦截的目标对象
        RoutingStatementHandler handler = (RoutingStatementHandler) invocation.getTarget();
        // 获取目标对象组合的StatementHandler对象(delegate属性)
        StatementHandler delegate = (StatementHandler) ReflectionUtils.getFieldValue(handler, "delegate");
        // 获取StatementHandler对象中封装的BoundSql对象
        BoundSql boundSql = delegate.getBoundSql();
        // 获取BoundSql对象中的参数对象
        Object parameterObject = boundSql.getParameterObject();
        // 如果参数对象是Page类,才进行增强逻辑
        if(parameterObject instanceof Page<?>) {
            Page<?> page = (Page<?>) parameterObject;
            // 获取StatementHandler对象中封装的MappedStatement对象
            MappedStatement mappedStatement = (MappedStatement) ReflectionUtils.getFieldValue(delegate, "mappedStatement");
            // 获取目标方法的参数之一:Connection对象
            Connection connection = (Connection) invocation.getArgs()[0];
            // 获取BoundSql对象中的SQL语句
            String sql = boundSql.getSql();
            System.out.println("原SQL语句:" + sql);
            if(page.isFull()) {
                // 获取记录总数
                this.setTotalCount(page, mappedStatement, connection);
            }
            page.setTimestamp(System.currentTimeMillis());
            // 根据原SQL语句和page对象的信息,获取分页SQL
            String pageSql = this.getPageSql(page, sql);
            System.out.println("分页SQL语句:" + pageSql);
            // 替换BoundSql对象中的SQL语句
            ReflectionUtils.setFieldValue(boundSql, "sql", pageSql);
        }
        // 继续指定原目标方法
        return invocation.proceed();
    }

    private String getPageSql(Page<?> page, String sql) {
        // todo
        return null;
    }

    private void setTotalCount(Page<?> page, MappedStatement mappedStatement, Connection connection) {
        // todo
    }

}

MyBatis自定义插件类都必须实现Interceptor接口,还需要通过@Intercepts注解配置对哪些组件的哪些方法进行拦截。

在本案例中,指定对StatementHandler对象的prepare()方法进行拦截,因此在调用StatementHandler对象的prepare()方法之前,会调用PageInterceptor对象的intercept()方法。

在PageInterceptor对象的intercept()方法中,拦截逻辑的大致流程是:

(1)如果参数对象是Page类型(或是Page类的子类),则进入分页逻辑,通过反射机制获取BoundSql对象,从该对象中提取出要执行的SQL语句和参数对象;
(2)如果全量更新配置为true,则调用setTotalCount()方法,将原SQL语句转换为查总记录数的SQL语句,查询出记录总数;
(3)接着调用getPageSql()方法将原SQL语句转换为对应数据库类型格式的分页SQL语句;
(4)将分页SQL语句放回目标方法中,执行目标方法,底层则会执行分页SQL语句。

需要注意的是,进入分页逻辑的条件是:参数对象是Page类型。因此,Mapper方法的参数对象必须继承Page类。

PageInterceptor类中还有getPageSql()方法、setTotalCount()方法未完成,下面继续:

/**
 * 给当前的参数对象page设置总记录数
 *
 * @param page            Mapper映射语句对应的参数对象
 * @param mappedStatement Mapper映射语句
 * @param connection      当前的数据库连接
 */
private void setTotalCount(Page<?> page, MappedStatement mappedStatement, Connection connection) {
    // 获取原SQL语句
    BoundSql boundSql = mappedStatement.getBoundSql(page);
    String sql = boundSql.getSql();
    // 根据原SQL语句获取对应的查询记录总数的SQL语句
    String countSql = "select count(1) " + sql.substring(sql.toLowerCase().indexOf("from"));
    System.out.println("查询记录总数的SQL语句:" + countSql);
    List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
    // 重新封装一个BoundSql对象和ParameterHandler对象
    BoundSql countBoundSql = new BoundSql(mappedStatement.getConfiguration(), countSql, parameterMappings, page);
    ParameterHandler parameterHandler = new DefaultParameterHandler(mappedStatement, page, countBoundSql);
    PreparedStatement pstmt = null;
    ResultSet rs = null;
    try {
        // 执行查询记录总数的语句
        pstmt = connection.prepareStatement(countSql);
        parameterHandler.setParameters(pstmt);
        rs = pstmt.executeQuery();
        if(rs.next()) {
            int totalCount = rs.getInt(1);
            System.out.println("获得记录总数 = " + totalCount);
            // 设置记录总数的同时已设置了总页数
            page.setTotalCount(totalCount);
        }
    } catch (SQLException e) {
        e.printStackTrace();
    } finally {
        if(rs != null) {
            try {
                rs.close();
            } catch (SQLException e) {
                e.printStackTrace();
            }
        }
        if(pstmt != null) {
            try {
                pstmt.close();
            } catch (SQLException e) {
                e.printStackTrace();
            }
        }
    }
}

setTotalCount()方法的实现可知,查询记录总数是一次额外的查询,直接使用JDBC API操作数据库。 这也很好理解,因为用户并没有编写查询记录总数的SQL语句,但这个查询又是有需要的。

/**
 * 根据page对象获取对应的分页查询Sql语句,
 * 这里只做了三种数据库类型,Mysql、Oracle、HSQLDB
 * 其它的数据库都没有进行分页
 *
 * @param page 分页对象
 * @param sql  原始sql语句
 * @return
 */
private String getPageSql(Page<?> page, String sql) {
    StringBuffer sqlBuffer = new StringBuffer(sql);
    if ("mysql".equalsIgnoreCase(databaseType)) {
        return getMysqlPageSql(page, sqlBuffer);
    } else if ("oracle".equalsIgnoreCase(databaseType)) {
        return getOraclePageSql(page, sqlBuffer);
    } else if ("hsqldb".equalsIgnoreCase(databaseType)) {
        return getHSQLDBPageSql(page, sqlBuffer);
    }
    return sqlBuffer.toString();
}

/**
 * 获取Mysql数据库的分页查询语句
 *
 * @param page      分页对象
 * @param sqlBuffer 包含原sql语句的StringBuffer对象
 * @return Mysql数据库分页语句
 */
private String getMysqlPageSql(Page<?> page, StringBuffer sqlBuffer) {
    int offset = (page.getPageNo() - 1) * page.getPageSize();
    sqlBuffer.append(" limit ").append(offset).append(",").append(page.getPageSize());
    return sqlBuffer.toString();
}

/**
 * 获取Oracle数据库的分页查询语句
 *
 * @param page      分页对象
 * @param sqlBuffer 包含原sql语句的StringBuffer对象
 * @return Oracle数据库的分页查询语句
 */
private String getOraclePageSql(Page<?> page, StringBuffer sqlBuffer) {
    int offset = (page.getPageNo() - 1) * page.getPageSize() + 1;
    sqlBuffer.insert(0, "select u.*, rownum r from (").append(") u where rownum < ")
            .append(offset + page.getPageSize());
    sqlBuffer.insert(0, "select * from (").append(") where r >= ").append(offset);
    return sqlBuffer.toString();
}

/**
 * 获取HSQLDB数据库的分页查询语句
 *
 * @param page      分页对象
 * @param sqlBuffer 包含原sql语句的StringBuffer对象
 * @return Oracle数据库的分页查询语句
 */
private String getHSQLDBPageSql(Page<?> page, StringBuffer sqlBuffer) {
    int offset = (page.getPageNo() - 1) * page.getPageSize() + 1;
    return "select limit " + offset + " " + page.getPageSize() + " * from (" + sqlBuffer.toString() + " )";
}

自定义的分页插件支持3种数据库厂商,分别是MySQL、Oracle、HSQLDB,它们的分页查询语句各不相同。

到此为止,分页插件已经编写好了。接下来编写测试代码,看看在实际开发中如何使用该插件。

9.2.2 自定义分页插件的使用

自定义插件后,需要在MyBatis主配置文件中对插件进行注册:

<!--mybatis-config.xml-->
<plugins>
    <plugin interceptor="com.star.mybatis.page.PageInterceptor">
        <property name="databaseType" value="mysql"/>
    </plugin>
</plugins>

上面的配置中,通过databaseType属性指定数据库类型为MySQL。

由于仅当参数对象是Page的子类时才会执行分页逻辑,因此需要编写一个UserQuery类继承Page类:

public class UserQuery extends Page<User> {
}

接下来是Mapper方法:

public interface UserMapper {
    @Select("select * from user")
    List<User> selectUserPage(UserQuery userQuery);
}

Mapper方法selectUserPage()中编写的SQL语句,只是一个简单的SELECT语句,并没有涉及到分页查询。具体的分页操作都是由自定义的插件来完成的。

最后,编写单元测试调用Mapper方法:

@Test
public void testPage() throws IOException {
    Reader reader = Resources.getResourceAsReader("mybatis-config.xml");
    SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(reader);
    SqlSession sqlSession = sqlSessionFactory.openSession();
    UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
    UserQuery userQuery = new UserQuery();
    userQuery.setPageNo(1);
    userQuery.setPageSize(2);
    userQuery.setFull(true);
    List<User> userList = userMapper.selectUserPage(userQuery);
    userList.forEach(System.out::println);
}

运行单元测试,控制台打印相关信息:

原SQL语句:select * from user
查询记录总数的SQL语句:select count(1) from user
获得记录总数 = 3
分页SQL语句:select * from user limit 0,2
User{id=1, name='孙悟空', age=1500, phone='18705464523', birthday=Thu Jan 01 00:00:00 CST 1}
User{id=2, name='猪八戒', age=1000, phone='15235468789', birthday=Fri Mar 10 00:00:00 CST 500}

由结果可知,自定义的分页插件会根据原SQL语句,构建出对应的查询记录总数SQL语句,以及分页查询SQL语句,使得最终查询的结果是分页后的数据。

9.3 自定义慢SQL统计插件

在实际项目中,有时会因为各种原因导致SQL执行耗时过长,从而影响服务性能。为了对耗时过长的SQL语句进行优化,就需要先把SQL语句找出来。

利用MyBatis插件功能,可以把执行时间超过某个设定的值的SQL语句输出到日志中,从而更有针对性地对SQL语句进行优化。

下面编写一个SlowSqlInterceptor类实现Interceptor接口,通过@Intercepts注解配置拦截StatementHandler对象的query()update()batch()方法:

@Intercepts({
        @Signature(type = StatementHandler.class, method = "query", args = {Statement.class, ResultHandler.class}),
        @Signature(type = StatementHandler.class, method = "update", args = {Statement.class}),
        @Signature(type = StatementHandler.class, method = "batch", args = {Statement.class})
})
public class SlowSqlInterceptor implements Interceptor {

    // 超时时长(秒)
    private Integer limitSecond;

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    @Override
    public void setProperties(Properties properties) {
        String limitSecond = (String) properties.get("limitSecond");
        this.limitSecond = Integer.parseInt(limitSecond);
    }

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // 记录开始执行时间
        long beginTimeMillis = System.currentTimeMillis();
        StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
        try {
            // 执行SQL语句
            return invocation.proceed();
        } finally {
            // 记录结束执行时间
            long endTimeMillis = System.currentTimeMillis();
            // 计算执行时间
            long costTimeMillis = endTimeMillis - beginTimeMillis;
            if (costTimeMillis > limitSecond * 1000) {
                BoundSql boundSql = statementHandler.getBoundSql();
                System.out.println("SQL语句【" + boundSql.getSql() + "】,执行耗时:" + costTimeMillis + "ms");
            }
        }
    }

}

intercept()方法中,分别记录SQL语句执行前后的时间,如果两者的差超过了配置好的超时时间(limitSecond属性),则打印出这条SQL语句及其耗时。(为演示方便,这里只是将SQL语句打印在控制台,实际项目中可以输出到日志文件中)

最后,在MyBatis主配置文件中对插件进行注册:

<!--mybatis-config.xml-->
<plugins>
    <plugin interceptor="com.star.mybatis.page.PageInterceptor">
        <property name="databaseType" value="mysql"/>
    </plugin>
    <plugin interceptor="com.star.mybatis.page.SlowSqlInterceptor">
        <property name="limitSecond" value="1"/>
    </plugin>
</plugins>

再次执行【9.2 分页插件】的单元测试代码。为了模拟出慢SQL的效果,可以借助IDE在SlowSqlInterceptor类的intercept()方法中的return invocation.proceed();这一行代码上打一个断点,稍停几秒再放通。

控制台打印结果:

SQL语句【select * from user limit 0,2】,执行耗时:22120ms

这样,一个检验慢SQL的插件也生效了。

9.4 小结

第九章到此就梳理完毕了,本章的主题是:MyBatis拦截器原理及应用。回顾一下本章的梳理的内容:

(二十二)拦截器的实现原理与执行过程
(二十三)自定义分页插件、慢SQL插件

更多内容请查阅分类专栏:MyBatis3源码深度解析

第十章主要学习:MyBatis级联映射与懒加载。主要内容包括:

  • MyBatis级联映射原理;
  • MyBatis懒加载机制。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

维先生d

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值