Java-14 深入浅出 MyBatis 插件机制深度解析:四大对象拦截与动态代理原理

TL;DR

  • 场景:在 MyBatis 持久层中需要对 SQL 执行流程做无侵入增强,例如分页、SQL 日志、性能监控、SQL 改写、二级缓存等,又不想改 MyBatis 核心源码。
  • 结论 :MyBatis 插件机制基于 JDK 动态代理,对 ExecutorStatementHandlerParameterHandlerResultSetHandler 四大核心对象的方法调用进行拦截;通过实现 Interceptor 接口并以 @Intercepts / @Signature 注解声明拦截点,配合 <plugins> 配置即可生效。
  • 产出 :插件接口三大方法(intercept / plugin / setProperties)的职责拆解、InterceptorChain.pluginAll 串联多个拦截器的源码示例、Plugin.invokeInvocation 类的字段与调用约定、典型自定义插件模板与 XML 注册方式,以及覆盖注解签名错误、plugin() 漏写、Invocation.proceed() 漏调用、多拦截器顺序的速查卡。

插件简介

一般情况下,开源框架都会提供插件或者扩展点,供开发者自行拓展。这样的好处显而易见,一是增加了框架的灵活性,二是开发者结合实际需求,对框架进行扩展,使它能够更好的工作。 以 MyBatis 为例子,我们可以基于 MyBatis 插件机制实现分页、分表、监控功能。由于插件和业务无关,业务也无法感知插件的存在,因此可以无感植入插件,在无形中实现增强。 MyBatis 插件机制允许用户通过实现插件接口来扩展 MyBatis 的功能,拦截 SQL 语句的执行过程,进行自定义的处理。这是 MyBatis 提供的一种灵活的机制,可以在不修改核心代码的情况下对其行为进行定制。插件可以用于日志记录、性能监控、SQL 统计等多个场景。

基本概念

MyBatis 插件机制基于 Java 的 拦截器模式,通过插件类实现对 MyBatis 核心行为的拦截和修改。插件可以在执行特定的操作时(如查询、更新、删除等)插入自定义代码。常见的插件场景包括:

  • 拦截执行 SQL 的过程:可以修改、增强或记录 SQL 执行的细节。
  • 增加额外的处理逻辑:比如日志、缓存、性能监控等。

作用范围

插件的作用对象是 MyBatis 内部的核心组件,如 Executor、StatementHandler、ResultSetHandler 和 ParameterHandler。你可以选择拦截这些组件的具体操作,例如:

  • Executor:执行 SQL 的操作。
  • StatementHandler:处理 SQL 语句的生成、参数设置等。
  • ResultSetHandler:处理结果集的映射。
  • ParameterHandler:处理 SQL 语句的参数设置。

插件通过 intercept 方法拦截目标对象,决定是否执行后续操作或修改返回值。

常见场景

  • SQL 日志记录: 记录执行的 SQL 语句和执行时间,有助于进行性能监控和问题排查。
  • SQL 性能监控: 插件可以记录 SQL 执行的时间,帮助开发者识别性能瓶颈,优化 SQL 执行。
  • 事务控制: 插件可以用来在执行 SQL 前后添加自定义的事务控制逻辑,如重试机制等。
  • 缓存: 插件可以用于实现二级缓存或其他缓存机制,避免重复查询相同数据。
  • SQL 改写: 插件可以修改 SQL 语句,例如添加某些条件、修改排序等,扩展 MyBatis 的查询功能。

优点

  • 灵活性:插件机制允许开发者在不修改 MyBatis 核心代码的情况下,灵活地增加或修改功能。
  • 扩展性:插件可以轻松地扩展 MyBatis,满足各种业务需求。
  • 可定制性:可以根据业务需求定制插件,实现特定的功能,如日志记录、性能监控等。

缺点

  • 调试复杂:由于插件是通过代理模式实现的,调试时可能会增加复杂性。
  • 性能开销:每次 SQL 执行时都需要经过插件的拦截和处理,可能会引入一定的性能开销。

插件介绍

MyBatis 作为一个广泛的优秀的 ORM 开源框架,这个框架具有强大的灵活性,在四大组件:Executor、StatementHandler、ParameterHandler、ResultSetHandler,提供了简易易用的插件扩展机制。 MyBatis 对持久层的操作就是依赖这四个核心对象,MyBatis 支持用插件对四大核心对象进行拦截,对 MyBatis 来说就是拦截器,增强核心对象的功能,增加功能本质上借助底层的动态代理来实现的,换句话说,MyBatis 中的四大对象都是代理对象。

MyBatis 允许拦截的方法如下:

  • 执行器 Executor:update query commit rollback 等等
  • SQL 语法构建器 StatementHandler:prepare、parameterize、batch、updates query 等方法
  • 参数处理器 ParameterHandler:getParameterObject setParameters 方法
  • 结果集处理器 ResultSetHandler:handlerResultSets handleOutputParameters 等方法

插件原理

在四大核心对象创建的时候:

  • 每个创建出来都不是直接返回的,而是 interceptorChain.pluginAll(parameterHandler)
  • 获取到所有 interceptor,调用 interceptor.plugin(target)返回 target 包装后的对象
  • 插件机制,可以使用插件为目标对象创建一个代理对象,AOP(面向切面)我们的插件可以为四大对象创建出代理对象,代理对象可以拦截到四大对象的每一个执行。

插件具体是如何拦截并附加额外的功能呢?比如 ParameterHandler:

java 复制代码
public ParameterHandler createParameterHandler(
        MappedStatement mappedStatement, 
        Object parameter, 
        BoundSql boundSql, 
        InterceptorChain interceptorChain) {

    // 创建 ParameterHandler
    ParameterHandler parameterHandler = 
            mappedStatement.getLang().createParameterHandler(mappedStatement, parameter, boundSql);

    // 通过拦截链对 ParameterHandler 进行增强
    parameterHandler = (ParameterHandler) interceptorChain.applyInterceptors(parameterHandler);

    return parameterHandler;
}

public Object applyInterceptors(Object target) {
    // 对目标对象依次应用所有拦截器
    for (Interceptor interceptor : interceptors) {
        target = interceptor.plugin(target);
    }
    return target;
}

interceptorChain 保存了所有的拦截器(interceptors),是 MyBatis 初始化的时候创建的,调用拦截器链中的拦截器依次对目标进行拦截或增强。interceptor.plugin(target)中的 target 就可以理解为 MyBatis 中的四大对象,返回的 target 就是被多重代理后的对象。

如果我们想要拦截 Executor 的 query 方法,那么可以定义:

java 复制代码
@Intercepts({
    @Signature(
        type = Executor.class,
        method = "query",
        args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
    )
})
public class ExamplePlugin implements Interceptor {
    
    // 这里是插件的主要逻辑部分
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // 插件逻辑
        return invocation.proceed(); // 执行原方法
    }

    // 这个方法用于插件的封装和配置
    @Override
    public Object plugin(Object target) {
        if (target instanceof Executor) {
            return Plugin.wrap(target, this); // 创建插件代理对象
        }
        return target;
    }

    // 这个方法用于插件的参数配置
    @Override
    public void setProperties(Properties properties) {
        // 设置插件的配置参数
    }
}

除此之外,我们还需要将插件配置到 sqlMapConfig.xml 上:

xml 复制代码
<plugins>
    <plugin interceptor="icu.wzk.interceptor.ExamplePlugin"></plugin>
</plugins>

这样 MyBatis 在启动时可以加载插件,并保存插件实例相关对象(InterceptorChain,拦截器)。待准备工作结束后,MyBatis 对于就绪状态,我们在执行 SQL 时,需要先通过 DefaultSqlSessionFactory 创建 SQL Session。Executor 实例会在创建 SqlSession 的过程中被创建,Executor 实例创建完毕后,MyBatis 会通过 JDK 动态代理为实例生成代理类,这样,插件逻辑可以在 Executor 相关方法开始调用前执行。

自定义插件

MyBatis 插件接口 interceptor:

  • interceptor 方法,插件的核心方法
  • plugin 方法,生成 target 的代理对象
  • setProperties 方法,传递插件所需参数

源码分析

插件执行逻辑,Plugin 实现了 InvocationHandler 接口,因此它的 Invoke 方法会拦截所有的方法调用,invoke 方法会对所有拦截的方法进行检测,以决定是否执行插件逻辑,该方法的逻辑如下:

java 复制代码
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    try {
      Set<Method> methods = signatureMap.get(method.getDeclaringClass());
      if (methods != null && methods.contains(method)) {
        return interceptor.intercept(new Invocation(target, method, args));
      }
      return method.invoke(target, args);
    } catch (Exception e) {
      throw ExceptionUtil.unwrapThrowable(e);
    }
  }

对应的截图如下所示:

可以看到,invoke 方法的代码比较少,逻辑不难理解,首先,invoke方法会检测被拦截方法,插件逻辑封装在 interceptor 中,该方法的参数类型为 Invocation 主要用于存储目标类,方法以及方法参数列表,下面简单看一下该类的定义:

java 复制代码
public class Invocation {

  private final Object target;
  private final Method method;
  private final Object[] args;

  public Invocation(Object target, Method method, Object[] args) {
    this.target = target;
    this.method = method;
    this.args = args;
  }

  public Object getTarget() {
    return target;
  }

  public Method getMethod() {
    return method;
  }

  public Object[] getArgs() {
    return args;
  }

  public Object proceed() throws InvocationTargetException, IllegalAccessException {
    return method.invoke(target, args);
  }

}

对应的截图如下所示:

错误速查卡

症状 根因 定位 修复
插件注册后 SQL 正常执行,但自定义逻辑从未触发 @Signaturemethod 拼写错误或 args 与目标方法参数列表不匹配,导致 signatureMap 命中失败,invokemethod.invoke(target, args) 原路径 Plugin.invoke 第一行打断点,观察 signatureMap.get(method.getDeclaringClass()) 是否为 null 或 methods.contains(method) 为 false 严格按目标接口方法签名补全 type / method / args,注意重载方法必须用 args 区分
启动报 Method not foundNoSuchMethodException @Signature 注解中的 method 名在 type 接口中不存在,或 args 数组长度与目标方法不一致 查看 MyBatis 启动日志中抛出的反射异常堆栈 改为接口真实方法名,并通过 args 精确锁定重载
拦截 Executor 后部分 SQL 返回 ClassCastException plugin() 方法忘记调用 Plugin.wrap,返回了原始 target 而非代理对象;或返回类型强转错误 Interceptor.plugin 处打断点,确认返回值是否为 Proxy 实例 始终 return Plugin.wrap(target, this);,并保持返回类型为 Object
业务方法体被跳过,调用方收到 null intercept 中忘记调用 invocation.proceed() 就直接 return null 搜索 intercept 实现中是否缺少 proceed() 调用 在自定义逻辑执行前后调用 invocation.proceed(),并将其返回值作为 intercept 的返回值
多个插件嵌套时代理顺序与预期相反 InterceptorChain.pluginAllinterceptors 列表顺序逐层包装,先注册的在最外层 Configuration 解析阶段打印 interceptorChain.getInterceptors() 顺序 调整 sqlMapConfig.xml<plugin> 的声明顺序,把最外层(最后包装目标)的拦截器放最后
拦截后 SQL 执行变慢明显 拦截 StatementHandler.prepareExecutor.query 时做了同步 IO/重活,未做缓存 用 Arthas / async-profiler 看 intercept 方法耗时 拦截点尽量靠近外层(Executor),并把耗时操作异步化或加本地缓存
拦截 ParameterHandler.setParameters 后参数绑定丢失 误以为 proceed() 会自动恢复参数,在 proceed() 前后对 args[1] 做了 in-place 修改但未还原 setParameters 前后打印 PreparedStatement? 数量与值 不要在拦截器里改 boundSql 之外的入参对象;如需改写 SQL,拦截 StatementHandlerprepare / parameterize
自定义 Invocation 子类后 proceed 死循环 覆写 proceed() 时忘了调用 super.proceed(),导致递归调用到自身 单元测试拦截器,对递归路径加超时保护 始终基于 Invocation 原始 proceed() 实现,包装而非覆写
插件里抛出的异常被吞掉 Plugin.invokecatch (Exception e) { throw ExceptionUtil.unwrapThrowable(e); } 会做拆包,但业务异常如果是 Error 或被 try-catch 吞掉则看不到 intercept 内显式打日志,确认 proceed() 抛出的原异常 让异常直接抛出,由 Plugin.invoke 统一拆包;不要在 intercepttry-catch(Exception) 静默
@Intercepts 注解未生效,类被当作普通 Bean 注解写在 implement Interceptor 之外的类上,或未实现 Interceptor 接口 启动日志确认 Configuration 解析到的 interceptors 数量 确保类 implements Interceptor,且 @Intercepts 标注在类上
同一接口的多个 @Signature 顺序导致命中错乱 多个 @Signature 命中同一方法时,先声明的先生效;混淆了 typemethod 用最小复现的 @Signature 列表逐个验证 拆分到不同 @Intercepts 注解块中,或合并到一个拦截器里手动分发
相关推荐
小楼v1 小时前
Kafka消息队列安装步骤及从0入门到基础核心掌握
java·kafka·消息队列·教程·安装
Javatutouhouduan1 小时前
普通Java程序员如何高效学习JVM?
java·jvm·java虚拟机·java面试·后端开发·java编程·java八股文
用户298698530141 小时前
Java 实战:精准操控 Word 文档中的内容控件
java·后端
李白的天不白1 小时前
spring boot + vue3项目部署须知
java·spring boot·后端
明夜之约1 小时前
Spring Transaction 传播机制
java·spring
闪电悠米1 小时前
黑马点评-分布式锁-03_lua_atomic_unlock
java·数据库·分布式·缓存·oracle·wpf·lua
多工坊1 小时前
The content of elements must consist of well-formed character data or markup.
java
linmoo19861 小时前
Java踩坑系列之二:ThreadLocal内存泄漏
java·内存泄漏·threadlocal·踩坑
传说之后1 小时前
Go语言入门:从零到Hello World
后端·编程语言