• 文档
  • 教程
  • 后端

【免费下载链接】CodeGuide

:books: 本代码库是作者小傅哥多年从事一线互联网 Java 开发的学习历程技术汇总,旨在为大家提供一个清晰详细的学习教程,侧重点更倾向编写Java核心内容。如果本仓库能为您提供帮助,请给予支持(关注、点赞、分享)!

项目地址: https://gitcode.com/gh_mirrors/code/CodeGuide
点击查看 免费下载

MyBatis 的插件(Interceptor)机制是它最开放的扩展点:PageHelper 分页、慢 SQL 监控、数据权限过滤、字段自动填充,几乎全部建立在它之上。本文以四大核心对象(Executor、StatementHandler、ParameterHandler、ResultSetHandler)为线索,从插件创建时机、JDK 动态代理源码,到自定义插件的完整写法与多插件执行顺序,把 MyBatis 插件机制彻底讲透;同时结合本仓库《手写 MyBatis》系列第 17 章 Plugin 插件功能实现 的工程实现,给出源码级的佐证。


一、前言

MyBatis 的强大之处,不仅在于 SQL 映射和缓存机制,更在于它开放的插件扩展点。

PageHelper 分页插件、SQL 执行时间监控、数据权限过滤、自动填充字段……这些常见的扩展能力,全部基于 MyBatis 的 Interceptor 插件机制实现。

但大多数候选人只知道"插件就是拦截器",面试官一追问底层原理就露馅。追到 Plugin.wrap()、追到 signatureMap、追到洋葱模型的执行顺序,能答上来的寥寥无几。

本仓库的《手写 MyBatis》系列将"插件"作为独立章节实现:第 17 章 Plugin 插件功能实现 完整落地了 Interceptor 接口、InterceptorChain 拦截器链与 Plugin 动态代理三个核心类,读者完全可以对照原生 MyBatis 源码与手写工程,双向印证插件机制的本质。

二、面试题

谢飞机,小记!,面试继续。

面试官:MyBatis 插件的原理是什么?

谢飞机:通过拦截器拦截 SQL 执行。

面试官:能拦截哪些对象?

谢飞机:Executor?

面试官:四大核心对象是什么?

谢飞机:Executor、StatementHandler……还有?

面试官:ParameterHandler 和 ResultSetHandler。插件是怎么拦截的?底层用的什么技术?

谢飞机:动态代理?

面试官:对,JDK 动态代理。那 Plugin.wrap() 做了什么?

谢飞机:……

面试官:多个插件的执行顺序是怎样的?

谢飞机:按配置顺序?

面试官:你再想想。下一个!

可以看到,面试官的追问路径非常固定:能拦截哪些对象 → 底层什么技术 → Plugin.wrap() 做了什么 → 多插件顺序如何。这篇文章就沿着这条路径展开。

三、四大核心对象

1. 概览

MyBatis SQL 执行过程中,有四大核心对象,每个对象都有可拦截的方法:

核心对象职责可拦截方法
ExecutorSQL 执行器,总调度update/query/commit/rollback
StatementHandlerSQL 语句处理器prepare/parameterize/batch/update/query
ParameterHandler参数处理器setParameters/getParameterObject
ResultSetHandler结果集处理器handleResultSets/handleOutputParameters

这四类对象对应 SQL 执行的四个阶段:执行调度 → 语句准备 → 参数装配 → 结果映射。插件之所以能"拦截 SQL",本质就是在这四个阶段之间插入自定义逻辑。

2. 创建时机

四大核心对象全部由 Configuration 统一创建,并且每个对象创建后,都会经过 interceptorChain.pluginAll() 进行插件包装:

// org.apache.ibatis.session.Configuration
public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
    Executor executor = new SimpleExecutor(this, transaction);
    if (cacheEnabled) executor = new CachingExecutor(executor);
    executor = (Executor) interceptorChain.pluginAll(executor);  // ← 插件拦截
    return executor;
}

public StatementHandler newStatementHandler(Executor executor, MappedStatement ms, ...) {
    StatementHandler handler = new RoutingStatementHandler(executor, ms, parameter, ...);
    handler = (StatementHandler) interceptorChain.pluginAll(handler);  // ← 插件拦截
    return handler;
}

public ParameterHandler newParameterHandler(MappedStatement ms, Object parameter, ...) {
    ParameterHandler handler = new DefaultParameterHandler(ms, parameter, boundSql);
    handler = (ParameterHandler) interceptorChain.pluginAll(handler);  // ← 插件拦截
    return handler;
}

public ResultSetHandler newResultSetHandler(Executor executor, MappedStatement ms, ...) {
    ResultSetHandler handler = new DefaultResultSetHandler(executor, ms, ...);
    handler = (ResultSetHandler) interceptorChain.pluginAll(handler);  // ← 插件拦截
    return handler;
}

关于 interceptorChain 本身的定义,面经手册 · 第 34 篇 MyBatis 工作原理是什么?从 SqlSessionFactory 到 SqlSession 全链路解析 给出了 Configuration 的核心字段:

// org.apache.ibatis.session.Configuration
public class Configuration {
    // 拦截器链
    protected final InterceptorChain interceptorChain = new InterceptorChain();
    // ...
}

值得注意的是 Executor 的创建顺序:SimpleExecutor → CachingExecutor → 插件代理。也就是说插件代理永远包裹在最外层,装饰器模式(二级缓存)与代理模式(插件)叠加形成了完整调用链。这一点在第 34 篇总结的"六大设计模式"中也有印证:装饰链:SimpleExecutor → CachingExecutor → 插件代理。

3. 执行链路

SqlSession
  ↓
Executor(被插件代理)
  ↓
StatementHandler(被插件代理)
  ↓  prepare() → parameterize()
ParameterHandler(被插件代理)
  ↓  setParameters()
Statement.execute()
  ↓
ResultSetHandler(被插件代理)
  ↓  handleResultSets()
结果返回

四大核心对象在链路中的位置决定了插件的"视野":

  • 拦截 Executor.query():可以拿到 MappedStatement、参数、RowBounds、ResultHandler,是改写 SQL 与分页的最佳位置(PageHelper 就拦这里);
  • 拦截 StatementHandler.prepare():可以拿到 Connection,是获取 BoundSql、监控 SQL 耗时的最佳位置;
  • 拦截 ParameterHandler.setParameters():可以拿到参数对象,适合参数改写、字段填充;
  • 拦截 ResultSetHandler.handleResultSets():可以拿到结果集,适合结果加工、脱敏、权限过滤。

四、插件原理 — JDK 动态代理

1. 核心接口

MyBatis 插件机制围绕一个接口展开:

public interface Interceptor {
    // 拦截逻辑
    Object intercept(Invocation invocation) throws Throwable;

    // 创建代理对象
    default Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    // 设置属性
    default void setProperties(Properties properties) {}
}

三个方法的职责非常清晰:

  • intercept(Invocation):插件真正的业务逻辑,Invocation 封装了目标对象、方法、参数;
  • plugin(Object):默认实现直接委托给 Plugin.wrap(target, this),子类可覆写以定制代理策略;
  • setProperties(Properties):接收 <plugin> 标签下 <property> 配置的参数。

2. 拦截器链

Configuration 持有全局唯一的 InterceptorChain,插件的注册与逐层包装都在这里完成:

// org.apache.ibatis.plugin.InterceptorChain
public class InterceptorChain {
    private final List<Interceptor> interceptors = new ArrayList<>();

    public Object pluginAll(Object target) {
        for (Interceptor interceptor : interceptors) {
            target = interceptor.plugin(target);  // 逐层代理
        }
        return target;
    }
}

pluginAll() 是整条链路的关键:按配置顺序遍历所有拦截器,把上一个拦截器产生的代理对象作为下一个拦截器的 target。这正是"多插件洋葱模型"的源头(下文第六节展开)。

3. Plugin.wrap() 源码

Plugin 类既是工厂(生成代理),又是 InvocationHandler(处理调用),一个类承担了两个角色:

// org.apache.ibatis.plugin.Plugin
public class Plugin implements InvocationHandler {
    private final Object target;
    private final Interceptor interceptor;
    private final Map<Class<?>, Set<Method>> signatureMap;

    public static Object wrap(Object target, Interceptor interceptor) {
        // 1. 获取插件要拦截的方法签名
        Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
        Class<?> type = target.getClass();

        // 2. 检查目标对象是否在拦截范围内
        Map<Class<?>, Set<Method>> targetMap = new HashMap<>();
        for (Class<?> superClass : type.getInterfaces()) {
            if (signatureMap.containsKey(superClass)) {
                targetMap.put(superClass, signatureMap.get(superClass));
            }
        }

        // 3. 如果有需要拦截的方法,创建代理
        if (!targetMap.isEmpty()) {
            return Proxy.newProxyInstance(
                type.getClassLoader(),
                type.getInterfaces(),
                new Plugin(target, interceptor, targetMap)  // ← JDK 动态代理
            );
        }

        // 4. 不需要拦截,返回原对象
        return target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        // 检查当前方法是否需要拦截
        Set<Method> methods = signatureMap.get(method.getDeclaringClass());
        if (methods != null && methods.contains(method)) {
            // 拦截!调用插件的 intercept 方法
            return interceptor.intercept(new Invocation(target, method, args));
        }
        // 不拦截,调用原始方法
        return method.invoke(target, args);
    }
}

wrap() 的四个步骤可以提炼为:

  1. 解析签名:通过反射读取 @Intercepts 和 @Signature 注解,得到 signatureMap(接口类型 → 方法集合);
  2. 匹配目标:遍历目标对象实现的所有接口,找出与 signatureMap 交集;
  3. 创建代理:交集非空才用 Proxy.newProxyInstance() 创建 JDK 动态代理;
  4. 原样返回:插件与目标对象无关(比如自定义插件只拦 Executor,但传入的是 ResultSetHandler)时,直接返回原对象,不产生任何代理开销。

invoke() 的分流逻辑同样关键:方法命中 signatureMap 才走 interceptor.intercept(),否则直接 method.invoke(target, args) 放行。所以一个插件拦截范围外的调用,几乎没有性能损耗。

4. 原理图解

目标对象:StatementHandler
    ↓ Plugin.wrap()
代理对象:$Proxy0 (implements StatementHandler)
    ↓ 调用 prepare()
Plugin.invoke()
    ↓ 检查是否在 signatureMap 中
    ↓ 是 → interceptor.intercept(new Invocation(target, method, args))
    ↓ 否 → method.invoke(target, args)

Invocation 包含:
    - target:原始对象
    - method:被拦截的方法
    - args:方法参数

Invocation 是整个拦截调用的"信使",它的 proceed() 方法用于继续执行原始方法,这是插件编写中最常调用的 API:

// org.apache.ibatis.plugin.Invocation
public class Invocation {
    private final Object target;
    private final Method method;
    private final Object[] args;

    public Object proceed() throws InvocationTargetException, IllegalAccessException {
        return method.invoke(target, args);  // 调用原始方法
    }
    // getTarget() / getMethod() / getArgs() 省略
}

5. 仓库源码佐证:手写 MyBatis 的 Plugin 实现

本仓库《手写 MyBatis》系列在第 17 章 Plugin 插件功能实现 中,完全按照原生 MyBatis 的套路手写了一套插件框架,并在前言中明确了插件的设计动机:

没有代理也就不会有 AOP、不会有 ORM、不会有 RPC。结合着我们本章节继续扩展的 Mybatis Plugin 插件功能同样也是需要代理的存在,才能让我们监听原有对象的行为,扩展自定义的逻辑。

第 17 章同时给出了插件的典型应用方向——分页、数据库表路由、监控日志,并指出这些核心功能的扩展"都来自于 Mybatis Plugin 提供对类的代理扩展,并在代理中调用我们自定义插件的逻辑行为"。读者若想从零理解插件机制的每个类为什么长这样,对照手写工程逐行阅读是非常好的路径。

而在第 22 章 Mybatis 设计模式 的"代理模式"小节,作者把 Plugin 与 MapperProxy、DriverProxy、Invoker 归为同类场景,一句话点透了本质:没有代理模式,就不会有各类框架存在。MyBatis 内部至少有两条代理线——Mapper 接口代理(MapperProxy)与核心对象插件代理(Plugin),前者让"接口无实现类也能执行 SQL"成为可能,后者让"不改源码也能改 SQL"成为可能。

五、自定义插件编写

理解了原理,下面用两个完整示例演示插件的编写与注册。

1. SQL 执行时间监控插件

拦截 StatementHandler.prepare(),在 SQL 真正预编译前记录耗时:

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

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // 前置:记录开始时间
        long startTime = System.currentTimeMillis();

        try {
            // 执行原始方法
            return invocation.proceed();
        } finally {
            // 后置:计算耗时
            long cost = System.currentTimeMillis() - startTime;
            StatementHandler handler = (StatementHandler) invocation.getTarget();
            BoundSql boundSql = handler.getBoundSql();
            String sql = boundSql.getSql();

            if (cost > 1000) {
                log.warn("慢SQL [{}ms]: {}", cost, sql);
            } else {
                log.info("SQL [{}ms]: {}", cost, sql);
            }
        }
    }

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

几个值得细品的写法:

  • @Intercepts + @Signature 是插件声明拦截目标的方式,type 必须是四大核心对象之一,method 和 args 必须与真实方法签名完全一致,否则 signatureMap 匹配不上,插件静默失效;
  • 用 try/finally 而非 try/catch:即使 SQL 执行抛异常,也能在 finally 中记录耗时;
  • 从 invocation.getTarget() 取出原始 StatementHandler,通过 getBoundSql() 拿到完整 SQL——注意此时拿到的还是未预编译的原始 SQL,因此可以安全地打印或改写;
  • prepare(Connection, Integer) 的 Integer 是 Statement 的超时时间参数,args 必须写成包装类型 Integer.class,这是新手最常见的踩坑点。

2. 注册插件

方式一:mybatis-config.xml

<plugins>
    <plugin interceptor="com.example.plugin.SqlTimeInterceptor">
        <property name="slowThreshold" value="1000"/>
    </plugin>
</plugins>

<property> 中的参数会在插件实例化后通过 setProperties(Properties) 注入,可在 intercept() 中读取,例如把写死的 1000 换成 slowThreshold 属性,实现"配置驱动"。

方式二:Spring Boot 配置

@Configuration
public class MyBatisConfig {

    @Bean
    public SqlTimeInterceptor sqlTimeInterceptor() {
        return new SqlTimeInterceptor();
    }
}

Spring Boot 场景下,只要把 Interceptor 实现类注册为 Spring Bean,mybatis-spring-boot-starter 就会在构建 SqlSessionFactory 时自动收集所有 Interceptor 类型的 Bean 并注入拦截器链。若同时使用 XML 与 Bean 两种方式,注意插件会叠加生效。

3. 分页插件示例

分页插件是插件机制最经典的"实战形态"——拦截 Executor.query(),改写 SQL 追加 LIMIT:

@Intercepts({
    @Signature(
        type = Executor.class,
        method = "query",
        args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
    )
})
public class PageInterceptor implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object[] args = invocation.getArgs();
        RowBounds rowBounds = (RowBounds) args[2];

        if (rowBounds != RowBounds.DEFAULT) {
            // 有分页参数,改写 SQL
            MappedStatement ms = (MappedStatement) args[0];
            BoundSql boundSql = ms.getBoundSql(args[1]);
            String originalSql = boundSql.getSql();

            // 拼接分页 SQL
            String pageSql = originalSql + " LIMIT "
                + rowBounds.getOffset() + ", " + rowBounds.getLimit();

            // 反射修改 SQL
            Field field = boundSql.getClass().getDeclaredField("sql");
            field.setAccessible(true);
            field.set(boundSql, pageSql);

            // 清除 RowBounds(避免 MyBatis 内存分页)
            args[2] = RowBounds.DEFAULT;
        }

        return invocation.proceed();
    }
}

这个示例踩透了三个关键细节:

  1. 拿 SQL:MappedStatement.getBoundSql(parameterObject) 把参数绑定进 SQL 模板,得到最终可执行的 BoundSql;
  2. 改 SQL:BoundSql.sql 字段是私有的,必须通过反射 setAccessible(true) 才能改写——这是所有分页插件(包括 PageHelper)改 SQL 的统一手法;
  3. 防双分页:把 args[2] 重置为 RowBounds.DEFAULT,避免改写 SQL 后 MyBatis 又按 RowBounds 做一次内存截取。

关于分页的完整原理,可对照面经手册 · 第 40 篇 MyBatis 分页怎么做?RowBounds 和 PageHelper 原理分析:RowBounds 是逻辑分页(SQL 查全量,内存 skipRows 跳行),而 PageInterceptor 是物理分页(SQL 层面加 LIMIT)。该篇给出了 PageHelper 的完整拦截流程:

1. PageHelper.startPage(pageNum, pageSize)
   → 创建 Page 对象 → 存入 ThreadLocal
2. MyBatis 执行 SQL → Executor.query() 被 PageInterceptor 拦截
3. PageInterceptor.intercept()
   → 从 ThreadLocal 取出 Page 对象 → 判断是否需要分页
4. 改写 SQL
   → 通过 Dialect 生成 COUNT SQL 与分页 SQL(LIMIT ...)
5. 先执行 COUNT SQL 获取总条数,再执行分页 SQL 获取当页数据
6. 封装 Page 结果 → 清除 ThreadLocal

其中 @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}) 与上面自定义分页插件完全一致——理解了插件机制,就理解了 PageHelper 的底层原理。第 40 篇还给出了自定义物理分页插件的完整代码(含 reflectSetSql 反射改写 SQL 的工具方法),可以直接复用。

六、多插件执行顺序

1. 代理嵌套结构

先看结论,再解释为什么:

假设配置了3个插件:A、B、C

创建代理过程(按配置顺序):
  target → A.plugin(target) → proxyA
  proxyA → B.plugin(proxyA) → proxyB
  proxyB → C.plugin(proxyB) → proxyC

最终执行时(洋葱模型):
  C.intercept()
    → B.intercept()
      → A.intercept()
        → target.method()
      ← A
    ← B
  ← C

结论:配置在前面的插件,代理在最内层,拦截时最晚执行。配置在后面的插件,代理在最外层,拦截时最先执行。

原因就在 InterceptorChain.pluginAll() 的逐层包装:target 先被 A 包装,A 的代理对象再被 B 包装,B 的代理对象最后被 C 包装。最外层代理的 invoke() 最先被触发,所以后配置的 C 先执行;C 调用 proceed() 进入 B 的代理,B 再 proceed() 进入 A 的代理,最后才到达原始 target。

2. 示例验证

配置顺序:PluginA → PluginB

代理结构:target → A代理 → B代理(最外层)

调用流程:
  1. B.intercept() 先执行
  2. B.proceed() → A.intercept()
  3. A.proceed() → target.method()

执行顺序:B → A → target → A返回 → B返回

3. 注意事项

  • 插件执行顺序与配置顺序相反(最外层先执行)
  • 修改 SQL 的插件应放在最内层(最先配置)——保证后续插件与最终执行拿到的都是改写后的 SQL,同时避免外层插件反复改写同一 SQL
  • 监控类插件应放在最外层(最后配置)——它的 finally 耗时统计能够覆盖内层所有插件 + 原始方法的完整执行时间
  • 多个插件修改同一方法时,注意互相影响;拦截同一方法时,后配置插件的 args 修改会被先配置插件(内层)读取到

七、常见面试追问

Q1:MyBatis 插件能拦截哪些方法?

四大核心对象的所有公开方法都可以拦截,常用的有:

  • Executor:update、query、commit、rollback
  • StatementHandler:prepare、parameterize、batch、update、query
  • ParameterHandler:setParameters、getParameterObject
  • ResultSetHandler:handleResultSets、handleOutputParameters

注意:四大核心对象的拦截需要 JDK 动态代理支持接口方法。四大核心对象恰好都是接口,所以 Plugin.wrap() 中遍历 type.getInterfaces() 才能工作。

Q2:插件和 Spring AOP 有什么区别?

对比项MyBatis 插件Spring AOP
实现方式JDK 动态代理JDK/CGLIB 代理
作用范围仅四大核心对象Spring 容器中所有 Bean
配置方式@Intercepts + @Signature@Aspect + 切点表达式
拦截粒度精确到方法签名切点表达式匹配
适用场景SQL 拦截改写通用切面逻辑

补充一点:MyBatis 插件的 @Signature 是编译期注解 + 运行期反射解析的精确签名匹配,粒度细到"哪个接口的哪个方法、参数类型列表完全一致";Spring AOP 则是切点表达式匹配,适合横切关注点。二者一个是框架内部的扩展机制,一个是容器级的 AOP 方案,定位完全不同。

Q3:如何控制插件的执行顺序?

MyBatis 原生不提供顺序控制,按配置顺序层层代理。可以通过自定义 InterceptorChain 或在 Spring 中使用 @Order 注解控制 Bean 注册顺序。

补充说明执行顺序与 @Order 的关系:在 Spring Boot 整合场景下,插件最终都会被收集进 InterceptorChain,其顺序取决于 Bean 的注册顺序;@Order 注解可以影响 Bean 的装配顺序,进而影响 interceptorChain.interceptors 列表中的先后关系。但无论顺序如何变化,洋葱模型的核心规律不变:列表靠后的插件代理在外层、先执行。

八、总结

记住三个核心要点:

1. 四大核心对象
   Executor(执行器)、StatementHandler(语句处理器)
   ParameterHandler(参数处理器)、ResultSetHandler(结果集处理器)
   每个对象创建后都经过 interceptorChain.pluginAll() 代理

2. 插件原理
   JDK 动态代理 → Plugin.wrap() → Proxy.newProxyInstance()
   invoke() 检查 signatureMap → 命中则调用 interceptor.intercept()
   Invocation 封装 target + method + args

3. 多插件执行顺序
   配置顺序 A→B→C,代理结构 C→B→A(洋葱模型)
   最外层先执行,最内层最后执行
   修改SQL的插件先配置,监控类插件后配置

面试回答模板:

MyBatis 插件基于 JDK 动态代理实现,拦截四大核心对象:Executor、StatementHandler、ParameterHandler、ResultSetHandler。每个对象创建后经过 InterceptorChain.pluginAll() 逐层代理包装。

核心原理是 Plugin.wrap() 通过 @Intercepts 和 @Signature 注解获取要拦截的方法签名,用 Proxy.newProxyInstance() 创建代理对象。调用被拦截方法时,Plugin.invoke() 检查 signatureMap,命中则调用 Interceptor.intercept(),传入 Invocation 对象(包含目标对象、方法、参数),通过 invocation.proceed() 执行原始逻辑。

多个插件按配置顺序层层代理,形成洋葱模型:后配置的插件在最外层,先拦截;先配置的插件在最内层,后拦截。修改 SQL 的插件应先配置,监控类插件应后配置。

进一步阅读:本仓库的 MyBatis 面经手册系列围绕插件机制形成了一条完整的学习链——第 34 篇 工作原理全链路(理解四大核心对象在架构分层中的位置)、第 40 篇 分页与 PageHelper 原理(插件机制最成功的实战案例)、第 43 篇 Spring 整合(插件在 Spring 容器中的注册时机);若想从零手写一遍插件框架,则推荐直接阅读 《手写 MyBatis》第 17 章 Plugin 插件功能实现。

  • 文档
  • 教程
  • 后端

【免费下载链接】CodeGuide

:books: 本代码库是作者小傅哥多年从事一线互联网 Java 开发的学习历程技术汇总,旨在为大家提供一个清晰详细的学习教程,侧重点更倾向编写Java核心内容。如果本仓库能为您提供帮助,请给予支持(关注、点赞、分享)!

项目地址: https://gitcode.com/gh_mirrors/code/CodeGuide
点击查看 免费下载

更多推荐