Java / MyBatis 插件

MyBatis 插件

1. 前言

MyBatis 允许我们以插件的形式对已映射语句执行过程中的某一点进行拦截调用,通俗一点来说,MyBatis 的插件其实更应该被称作为拦截器。

MyBatis 插件的使用十分广泛,分页、性能分析、乐观锁、逻辑删除等等常用的功能都可以通过插件来实现。既然插件如此好用,本小节我们就一起来探索插件并且实现一个简单的 SQL 执行时间计时插件。

2. 介绍

2.1 可拦截对象

MyBatis 允许插件拦截如下 4 个对象的方法。

  • Executor的 update, query, flushStatements, commit, rollback, getTransaction, close, isClosed 方法
  • ParameterHandler的 getParameterObject, setParameters 方法
  • ResultSetHandler的 handleResultSets, handleOutputParameters 方法
  • StatementHandler的 prepare, parameterize, batch, update, query 方法

注意,这四个对象都是接口,插件会拦截实现了该接口的对象。

2.2 插件接口

插件必须实现 Interceptor 接口。Interceptor 接口共有 3 个方法,如下:

public interface Interceptor {
    Object intercept(Invocation invocation) throws Throwable;

    default Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    default void setProperties(Properties properties) {
    }
}

其中 plugin 和 setProperties 方法都是默认实现的方法,我们可以选择不覆盖实现,而 intercept 方法则必须实现。如下:

  • intercept : 核心方法,通过 Invocation 我们可以拿到被拦截的对象,从而实现自己的逻辑。
  • plugin: 给 target 拦截对象生成一个代理对象,已有默认实现。
  • setProperties: 插件的配置方法,在插件初始化的时候调用。

2.3 拦截器签名

插件可对多种对象进行拦截,因此我们需要通过拦截器签名来告诉 MyBatis 插件应该拦截何种对象的何种方法。举例如下:

@Intercepts({@Signature(
  type = StatementHandler.class,
  method = "prepare",
  args = {Connection.class, Integer.class}
)})
public class XXXPlugin implements Interceptor {}

类 XXXPlugin 上有两个注解:

  • Intercepts注解: 拦截声明,只有 Intercepts 注解修饰的插件才具有拦截功能。
  • Signature注解: 签名注解,共 3 个参数,type 参数表示拦截的对象,如 StatementHandler,另外还有Executor、ParameterHandler和ResultSetHandler;method 参数表示拦截对象的方法名,即对拦截对象的某个方法进行拦截,如 prepare,代表拦截 StatementHandler 的 prepare 方法;args 参数表示拦截方法的参数,因为方法可能会存在重载,因此方法名加上参数才能唯一标识一个方法。

推断可知 XXXPlugin 插件会拦截 StatementHandler对象的 prepare(Connection connection, Integer var2) 方法。

一个插件可以拦截多个对象的多个方法,因此在 Intercepts 注解中可以添加上多个 Signature注解。

3. 实践

接下来,我们一起来实现一个简单的 SQL 执行时间计时插件。插件的功能是日志输出每一条 SQL 的执行用时。

在 com.imooc.mybatis 包下,我们新建 plugin 包,并在包中添加 SqlStaticsPlugin 类。SqlStaticsPlugin 会拦截 StatementHandler的prepare方法,如下:

package com.imooc.mybatis.plugin;

import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.plugin.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.sql.Connection;

@Intercepts({@Signature(
  type = StatementHandler.class,
  method = "prepare",
  args = {Connection.class, Integer.class}
)})
public class SqlStaticsPlugin implements Interceptor {
  private Logger logger = LoggerFactory.getLogger(SqlStaticsPlugin.class);

  @Override
  public Object intercept(Invocation invocation) throws Throwable {
    return invocation.proceed();
  }
}

我们一起来完善这个插件。

  1. 首先需要得到 invocation 的拦截对象 StatementHandler,并从 StatementHandler 中拿到 SQL 语句。
  2. 得到当前的时间戳 startTime。
  3. 执行 SQL。
  4. 得到执行后的时间戳 endTime。
  5. 计算时间差,并打印 SQL 耗时。

对应的 intercept 方法代码如下:

public Object intercept(Invocation invocation) throws Throwable {
  // 得到拦截对象
  StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
  MetaObject metaObj = SystemMetaObject.forObject(statementHandler);
  String sql = (String) metaObj.getValue("delegate.boundSql.sql");
  // 开始时间
  long startTime = System.currentTimeMillis();
  // 执行SQL
  Object res = invocation.proceed();
  // 结束时间
  long endTime = System.currentTimeMillis();
  long sqlCost = endTime - startTime;
  // 去掉无用的换行符,打印美观
  logger.info("sql: {} - cost: {}ms", sql.replace("\n", ""), sqlCost);
  // 返回执行的结果
  return res;
}

注意,通过反射调用后的结果 res,我们一定要记得返回。MyBatis 提供了 MetaObject 这个类来方便我们进行拦截对象属性的修改,这里我们简单的使用了getValue方法来得到 SQL 语句。

我们在全局配置文件注册这个插件:

<plugins>
  <plugin interceptor="com.imooc.mybatis.plugin.SqlStaticsPlugin" />
</plugins>

到这,这个插件已经可以工作了,但是我们希望它能更加灵活一点,通过配置来拦截某些类型的 SQL,如只计算 select 类型SQL的耗时。

插件会在初始化的时候通过 setProperties 方法来加载配置,利用它我们可以得到哪些方法需要被计时。如下:

public class SqlStaticsPlugin implements Interceptor {
  private List<String> methods = Arrays.asList("SELECT", "INSERT", "UPDATE", "DELETE");

  @Override
  public void setProperties(Properties properties) {
    String methodsStr = properties.getProperty("methods");
    if (methodsStr == null || methodsStr.isBlank())
      return;
    String[] parts = methodsStr.split(",");
    methods = Arrays.stream(parts).map(String::toUpperCase).collect(Collectors.toList());
  }
}

methods 参数默认可通过 select、insert、update 和 delete 类型的SQL语句,如果插件存在配置项 methods,那么则根据插件配置来覆盖默认配置。

在全局配置文件中,我们来添加上 methods 这个配置:

<plugins>
  <plugin interceptor="com.imooc.mybatis.plugin.SqlStaticsPlugin">
    <property name="methods" value="select,update"/>
  </plugin>
</plugins>

类型之间以 , 隔开,MyBatis 会在插件初始化时,自动将 methods 对应的值通过 setProperties 方法来传递给SqlStaticsPlugin插件。插件拿到 Properties 后解析并替换默认的 methods 配置。

再次完善一下 intercept 方法,使其支持配置拦截:

public Object intercept(Invocation invocation) throws Throwable {
  StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
  MetaObject metaObj = SystemMetaObject.forObject(statementHandler);
  // 得到SQL类型
  String sqlCommandType = metaObj.getValue("delegate.mappedStatement.sqlCommandType").toString();
  // 如果方法配置中没有SQL类型,则无需计时,直接返回调用
  if (!methods.contains(sqlCommandType)) {
    return invocation.proceed();
  }
  String sql = (String) metaObj.getValue("delegate.boundSql.sql");
  long startTime = System.currentTimeMillis();
  Object res = invocation.proceed();
  long endTime = System.currentTimeMillis();
  long sqlCost = endTime - startTime;
  logger.info("sql: {} - cost: {}ms", sql.replace("\n", ""), sqlCost);
  return res;
}

当插件注册后,应用程序会打印出如下的日志语句:

17:48:14.110 [main] INFO com.imooc.mybatis.plugin.SqlStaticsPlugin - sql: INSERT INTO blog(info,tags)    VALUES(?,    ?) - cost: 87ms

至此,一个简单的 SQL 计时插件就开发完毕了。

4. 小结

  • MyBatis 插件强大且易用,是深入掌握 MyBatis 的必备知识点。
  • 不少 MyBatis 三方库都提供了很多好用的插件,如 Pagehelper 分页插件,我们可以拿来即用。