MyBatis插件机制与拦截器链源码分析
引言
做过几年业务系统的同学,大概率都遇到过这样的需求:想给所有 SQL 统一加上租户条件、想统计每条 SQL 的耗时、想对敏感字段做脱敏、想在分页时自动改写 SQL……这些需求有个共同点——它们都发生在 SQL 执行的前后,但你不想改业务代码,也不想改 MyBatis 源码。
这时候,MyBatis 的插件机制(Plugin)就派上用场了。分页插件 PageHelper、SQL 监控插件 p6spy 的 MyBatis 集成、数据权限插件,几乎都是基于这套机制实现的。
但真正读过源码的人会发现一个问题:你明明传了一个 Interceptor 进去,为什么它返回的 Executor 却变成了一个 Plugin 对象? 更迷惑的是,Plugin 类实现了 InvocationHandler——它居然是个动态代理!也就是说,MyBatis 的插件本质是 JDK 动态代理的一层"马甲"。
这篇文章我们从一次生产事故讲起,然后一路挖到 Plugin.wrap() 和 InvocationChain 的源码,把整条拦截器链彻底讲透。读完之后,你不仅能写出正确的插件,还能避开那些"插件一多就乱序""拦截不到嵌套查询"的坑。
核心概念:餐厅后厨与"中间人"
先打个比方。
想象一家餐厅的后厨,主厨(Executor)负责真正炒菜,但餐厅里还有几类角色:采购员(StatementHandler,负责拿食材)、配菜员(ParameterHandler,负责处理调料)、摆盘师(ResultSetHandler,负责装盘)。
现在老板(业务方)提了个需求:每道菜出锅前都要过一遍质检。你不想让主厨改流程,也不想让采购员分心。于是老板安排了一个"质检员"站在主厨和点单台之间:所有点单先经过质检员,质检员可以看一眼、记个时间、甚至偷偷改一下订单内容,然后再转给主厨。
这个"质检员"就是 Interceptor(拦截器)。而站岗的岗位——主厨、采购员、配菜员、摆盘师——就是 MyBatis 允许你拦截的四个核心接口。
技术上讲,MyBatis 插件机制由三部分组成:
Interceptor接口:定义intercept(Invocation)、plugin(Object)、setProperties(Properties)三个方法。@Intercepts+@Signature注解:声明你要拦截哪个类的哪个方法,参数类型是什么。Plugin.wrap():把目标对象包成 JDK 动态代理,代理的InvocationHandler就是Plugin本身。
关键点在于:拦截器不是"链式调用"的,而是"套娃式"的。多个插件会一层层包裹,最外层的插件先执行,最后才是真实目标对象。
看到这张图,你就能理解为什么插件的注册顺序和执行顺序是相反的——先注册的插件在 wrap 时先被包上,后来者又包在它外面。这个"套娃"特性是很多坑的根源,后面会重点讲。
源码深度分析:从 wrap 到 invoke
1. 插件是如何被"装配"的
MyBatis 启动时,XMLConfigBuilder 解析 标签,每个 生成一个 Interceptor 实例,塞进 Configuration.interceptorChain:
// Configuration.java(节选)
public void addInterceptor(Interceptor interceptor) {
interceptorChain.addInterceptor(interceptor);
}然后到了创建四大核心对象的环节。以 Executor 为例,newExecutor 方法最后一步:
// Configuration.java(节选)
public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
executorType = executorType == null ? defaultExecutorType : executorType;
Executor executor;
if (ExecutorType.BATCH == executorType) {
executor = new BatchExecutor(this, transaction);
} else if (ExecutorType.REUSE == executorType) {
executor = new ReuseExecutor(this, transaction);
} else {
executor = new SimpleExecutor(this, transaction);
}
if (cacheEnabled) {
executor = new CachingExecutor(executor);
}
// 关键:套上所有插件
executor = (Executor) interceptorChain.pluginAll(executor);
return executor;
}InterceptorChain.pluginAll 的源码非常朴素,但正是"套娃"的源头:
// InterceptorChain.java
public Object pluginAll(Object target) {
for (Interceptor interceptor : interceptors) {
target = interceptor.plugin(target);
}
return target;
}注意这里:target 被反复重新赋值。第一个插件包装后,第二个插件包的是"第一个插件包过的对象",以此类推。所以 interceptors 列表中越靠前的插件,越先被套上,也就越靠内层——执行时越晚。
2. Interceptor.plugin 默认实现:Plugin.wrap
Interceptor 接口本身有个 default 方法:
default Object plugin(Object target) {
return Plugin.wrap(target, this);
}Plugin.wrap 就是动态代理的入口:
// Plugin.java
public static Object wrap(Object target, Interceptor interceptor) {
// 1. 解析 @Intercepts 注解,得到 "接口 -> 方法签名集合" 的映射
Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
Class<?> type = target.getClass();
// 2. 找出目标对象实现了哪些被拦截的接口
Class<?>[] interfaces = getAllInterfaces(type, signatureMap);
// 3. 如果一个都没实现,直接返回原对象(注意这个细节!)
if (interfaces.length > 0) {
return Proxy.newProxyInstance(
type.getClassLoader(),
interfaces,
new Plugin(target, interceptor, signatureMap));
}
return target;
}这里有三个细节值得深挖:
细节一:getSignatureMap 解析注解。 它把 @Signature 的 type、method、args 组合成 Method 对象,放进以接口类为 key 的 Map。注意 Method 的构造可能抛 NoSuchMethodException,如果注解写错了方法名或参数类型,启动时就会直接抛异常——这其实是好事,早失败早暴露。
细节二:getAllInterfaces 只返回"被拦截的接口"。 比如你只拦截 Executor,那么 CachingExecutor 实现的 Executor 接口会被返回,但 CachingExecutor 自己的类不会被代理。这意味着你无法拦截目标类的非接口方法——这是 JDK 动态代理的固有局限,不是 MyBatis 的锅。
细节三:没有匹配接口时返回原对象。 所以一个插件如果签名写错,不会报错,而是"静默失效"。这是最常见的坑之一。
3. Plugin.invoke:拦截器链的真正执行者
Plugin 实现了 InvocationHandler,所有代理方法调用都会进入 invoke:
// Plugin.java
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
try {
// 1. 拿到当前方法所属接口被拦截的方法集合
Set<Method> methods = signatureMap.get(method.getDeclaringClass());
// 2. 如果当前方法在拦截范围内,交给拦截器处理
if (methods != null && methods.contains(method)) {
return interceptor.intercept(new Invocation(target, method, args));
}
// 3. 否则直接反射调用真实对象
return method.invoke(target, args);
} catch (Exception e) {
throw ExceptionUtil.unwrapThrowable(e);
}
}注意 method.getDeclaringClass() 这一行。假设你拦截 Executor.query,而 CachingExecutor 重写了 query,那么 method.getDeclaringClass() 是 CachingExecutor 还是 Executor?答案是取决于方法在哪个类中声明。如果 CachingExecutor 自己声明了 query,getDeclaringClass() 就是 CachingExecutor,而你的 signatureMap 里 key 是 Executor——匹配不上,拦截失效!
这是 MyBatis 插件最阴险的坑之一。实际上 CachingExecutor 确实重写了 query,但 signatureMap 的 key 是注解里写的 Executor.class。那为什么 PageHelper 能拦到?因为 getSignatureMap 里对 @Signature.type 用的是接口类,而 CachingExecutor.query 的 declaringClass 是 CachingExecutor……等等,这里需要澄清:Method.getDeclaringClass() 返回的是方法"首次声明"的类。CachingExecutor 的 query 是它自己声明的(重写),所以 declaringClass 是 CachingExecutor。那匹配岂不是必然失败?
实际源码里 getSignatureMap 用的是 type.getMethod(...),得到的 Method 的 declaringClass 就是 type 本身(接口)。而代理对象调用时传入的 method 是接口方法(因为 Proxy.newProxyInstance 传入的 interfaces 是接口数组),所以 method.getDeclaringClass() 返回的是接口。这就是为什么 getAllInterfaces 只返回接口、Proxy 也只代理接口方法——保证 declaringClass 与 signatureMap 的 key 一致。理解这一点,你就明白 JDK 代理在这里为什么"必须"用接口了。
4. Invocation:拦截器手里的"接力棒"
// Invocation.java
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);
}
}proceed() 就是"把活交给下一棒"。但注意:它调用的是 target 的方法,而 target 是"下一个插件代理对象"或"真实对象"。所以当拦截器 A 的 intercept 里调用 invocation.proceed(),实际触发的是拦截器 B 的 invoke,B 再 proceed 到 C……最终到真实对象。这就是"责任链"的实现方式——用动态代理的嵌套调用模拟出链式效果。
一个标准的拦截器写法:
@Intercepts({
@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class TimingInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
long start = System.nanoTime();
try {
return invocation.proceed(); // 交给下一棒
} finally {
long cost = System.nanoTime() - start;
System.out.println("SQL 耗时: " + cost / 1_000_000 + "ms");
}
}
}实战代码:三个可运行的完整示例
下面三个示例都可以直接放进 Spring Boot + MyBatis 项目运行。为了聚焦插件本身,省略了部分样板代码。
示例一:SQL 耗时统计与慢 SQL 告警
这个插件拦截 Executor 的 query 和 update,统计耗时并打印慢 SQL。注意它必须同时拦截 query 和 update,否则更新操作统计不到。
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import java.util.Properties;
/**
* 慢 SQL 监控插件
* 拦截 Executor 的 query / update 方法
*/
@Intercepts({
@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
@Signature(type = Executor.class, method = "update",
args = {MappedStatement.class, Object.class})
})
public class SlowSqlInterceptor implements Interceptor {
/** 慢 SQL 阈值,单位毫秒,可通过 properties 配置 */
private long thresholdMs = 500L;
@Override
public Object intercept(Invocation invocation) throws Throwable {
long start = System.nanoTime();
try {
return invocation.proceed(); // 放行到下一棒
} finally {
long costMs = (System.nanoTime() - start) / 1_000_000;
if (costMs >= thresholdMs) {
// 第一个参数是 MappedStatement,可拿到 SQL 与 statementId
MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
Object param = invocation.getArgs()[1];
BoundSql boundSql = ms.getBoundSql(param);
System.err.printf("[慢SQL] %dms | %s | SQL=%s%n",
costMs, ms.getId(), boundSql.getSql().replaceAll("\\s+", " "));
}
}
}
@Override
public Object plugin(Object target) {
// 只对 Executor 类型做代理,避免无谓的代理开销
if (target instanceof Executor) {
return Plugin.wrap(target, this);
}
return target;
}
@Override
public void setProperties(Properties properties) {
// 支持 <plugin><property name="thresholdMs" value="1000"/></plugin>
String v = properties.getProperty("thresholdMs");
if (v != null) {
this.thresholdMs = Long.parseLong(v);
}
}
}要点:plugin 方法里加 instanceof 判断是个好习惯。因为 pluginAll 会对四大对象都调用 plugin,不加判断的话,Plugin.wrap 内部虽然也会检查签名,但多一次 getSignatureMap 解析(虽然有缓存,但仍有开销)。
示例二:多租户自动拼接条件(基于 SQL 改写)
这个示例演示如何改写 SQL。注意:直接改 BoundSql.sql 是危险操作,因为 BoundSql 被缓存复用。正确做法是通过反射替换 BoundSql 的 sql 字段,或者更稳妥地——修改 MappedStatement 的 SqlSource。这里用反射演示原理,生产环境建议用 MyBatis-Plus 的租户插件或自定义 SqlInjector。
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.plugin.*;
import java.lang.reflect.Field;
import java.sql.Connection;
import java.util.Properties;
/**
* 多租户插件(演示 SQL 改写原理)
* 拦截 StatementHandler.prepare,在 SQL 执行前注入租户条件
*
* 警告:生产环境请使用官方推荐方式,直接改 BoundSql 有并发风险
*/
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare",
args = {Connection.class, Integer.class})
})
public class TenantInterceptor implements Interceptor {
private static final ThreadLocal<String> TENANT = new ThreadLocal<>();
public static void setTenant(String tenantId) { TENANT.set(tenantId); }
public static void clear() { TENANT.remove(); }
@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler handler = (StatementHandler) invocation.getTarget();
// 用 MetaObject 优雅地读取私有属性,比纯反射友好
MetaObject meta = SystemMetaObject.forObject(handler);
BoundSql boundSql = (BoundSql) meta.getValue("delegate.boundSql");
String sql = boundSql.getSql();
String tenantId = TENANT.get();
if (tenantId != null && sql.toLowerCase().contains("where")) {
// 简化演示:在 WHERE 后插入租户条件
String newSql = sql.replaceFirst("(?i)where",
"where tenant_id = '" + tenantId + "' and ");
// 通过反射修改 BoundSql.sql(生产环境需加锁或改用 SqlSource 重写)
Field field = BoundSql.class.getDeclaredField("sql");
field.setAccessible(true);
field.set(boundSql, newSql);
}
return invocation.proceed();
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) { }
}坑点提醒:BoundSql.sql 是 final 吗?在 MyBatis 3.5.x 中不是 final,但直接反射修改在高并发下可能因为 BoundSql 被 MappedStatement 缓存而串数据。更稳妥的方案是拦截 Executor.query,用 MappedStatement 构造新的 BoundSql,再通过 Runnable 包装调用 query 的重载版本。这也是 PageHelper 的做法——它没有改原 SQL,而是生成了一条 count SQL 和一条分页 SQL,用 MappedStatement 替换后重新调用 Executor.query。
示例三:拦截器执行顺序验证
这个示例用两个插件验证"套娃"顺序。理解这个,你就理解了 90% 的插件顺序问题。
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 通过两个插件验证执行顺序
* 注册顺序:A 先注册,B 后注册
* 实际执行顺序:B 进入 -> A 进入 -> 真实方法 -> A 退出 -> B 退出
*/
public class OrderDemo {
@Intercepts(@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}))
static class PluginA implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
System.out.println(">>> PluginA 进入");
try { return invocation.proceed(); }
finally { System.out.println("<<< PluginA 退出"); }
}
}
@Intercepts(@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}))
static class PluginB implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
System.out.println(">>> PluginB 进入");
try { return invocation.proceed(); }
finally { System.out.println("<<< PluginB 退出"); }
}
}
@Configuration
static class MyBatisConfig {
@Bean
public PluginA pluginA() { return new PluginA(); }
@Bean
public PluginB pluginB() { return new PluginB(); }
}
// 输出(注册顺序 A -> B):
// >>> PluginB 进入
// >>> PluginA 进入
// <<< PluginA 退出
// <<< PluginB 退出
}结论:后注册的插件先执行(进入),后退出。这就像穿衣服——最后穿的在外面,最先脱。所以如果你有"分页插件"和"数据权限插件",数据权限插件应该后注册,因为分页 SQL 需要在数据权限条件之后拼接,才能保证分页数量正确。这个顺序搞反,分页总数会算错,是经典线上事故。
方案对比:MyBatis 插件 vs 其他扩展方式
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| MyBatis 插件(Interceptor) | JDK 动态代理 + 责任链 | 无侵入、可拦截四大对象、生态成熟 | 只能拦接口方法、顺序依赖注册、调试困难 | 分页、监控、多租户、SQL 改写 |
| 自定义 SqlSessionFactoryBean 包装 | 替换核心对象 | 完全可控 | 侵入性大、需理解初始化流程 | 深度定制 |
| MyBatis-Plus 内置拦截器 | 官方实现 | 开箱即用、顺序管理 | 绑定 MP 生态 | 用 MP 的项目 |
| AOP 切 Mapper 接口 | Spring AOP | 简单、可切业务方法 | 拿不到 SQL 上下文、无法改 SQL | 缓存、日志 |
| JDBC 层代理(p6spy) | 驱动代理 | 与 ORM 无关 | 无法感知 MyBatis 语义 | 通用 SQL 监控 |
对比可以看出,MyBatis 插件的核心优势是能拿到 MappedStatement、BoundSql 这类 ORM 语义信息,这是 AOP 和 JDBC 代理做不到的。但代价就是前面说的"套娃顺序"和"只能拦接口"这两大限制。
最佳实践与避坑指南
1. 签名写错会静默失效,务必写单测验证。
@Signature 的 method 名或 args 类型写错,getSignatureMap 会抛 NoSuchMethodException(这是好事);但如果 type 写的是一个目标对象没实现的接口,getAllInterfaces 返回空数组,wrap 直接返回原对象,插件静默失效。建议每个插件都写一个断言"代理对象是 Plugin 实例"的测试。
2. 拦截 query 要拦全参数版本。
Executor 有 query(MappedStatement, Object, RowBounds, ResultHandler) 和 query(MappedStatement, Object, RowBounds, ResultHandler, CacheKey, BoundSql) 两个重载。只拦前者,某些内部调用(如嵌套查询)可能走后者,导致漏拦。PageHelper 两个都拦。
3. 顺序管理用 @Order 或 order 属性。
Spring Boot 下多个 Interceptor Bean,注册顺序受 Bean 加载顺序影响,不可靠。推荐在 的 property 里显式声明顺序,或自定义 ConfigurationCustomizer 按 @Order 排序后手动 addInterceptor。
4. 不要直接在 intercept 里改 BoundSql.sql。
BoundSql 可能被缓存复用,高并发下会串 SQL。要改写 SQL,走 MappedStatement 替换或 SqlSource 重写路线,参考 PageHelper 的 ExecutorUtil。
5. proceed() 必须放在 try-finally 里。
否则异常路径下"退出"逻辑不执行,监控数据会失真。示例一的写法就是标准姿势。
6. 注意 plugin 方法的 instanceof 判断。
pluginAll 会对四大对象都调 plugin,虽然 wrap 内部会检查,但提前判断能省掉 getSignatureMap 的解析开销(虽然 InterceptorChain 对 signatureMap 没有缓存,每次 wrap 都重新解析注解,插件多时这是实打实的性能损耗)。
7. 嵌套查询(Nested Query)的拦截盲区。
ResultSetHandler 处理嵌套结果时,可能通过 Executor 内部再次查询,此时新 Executor 是否被代理取决于 MappedStatement 的 executorType。如果发现"插件对某些 SQL 不生效",先检查是不是嵌套查询走了不同的 Executor 实例。
总结
把整条链路串起来:
- 装配:
InterceptorChain.pluginAll对四大对象依次调用plugin,先注册的在内层。 - 代理:
Plugin.wrap用@Intercepts生成signatureMap,对匹配接口做 JDK 动态代理。 - 执行:
Plugin.invoke命中签名则调interceptor.intercept,拦截器内invocation.proceed()触发下一层代理,形成套娃式责任链。
核心洞察是:MyBatis 插件 = JDK 动态代理 + 责任链模式的嵌套实现。理解了"套娃"和"接口代理"这两点,顺序问题、失效问题、嵌套问题就都有了统一解释。
延伸思考:如果你要设计一个类似的扩展框架,MyBatis 这套方案给你什么启发?其实它牺牲了"非接口方法拦截能力"和"显式顺序控制",换来了"零侵入"和"与 Spring 解耦"。现代框架(如 Dubbo 的 Filter、Spring 的 HandlerInterceptor)更倾向于显式链式结构 + 顺序声明,可调试性更好。MyBatis 受限于历史设计(JDK 代理),只能靠约定和文档来弥补。这也提醒我们:扩展点的设计,顺序可观测性和失败可见性,比"优雅"更重要。
下次再写插件,先问自己三个问题:拦的是接口方法吗?顺序想清楚了吗?proceed() 在 finally 里吗?想清楚这三点,坑就少了一大半。