面经手册 · 第41篇《MyBatis 插件怎么拦截 SQL?四大核心对象与 Interceptor 原理分析》
【免费下载链接】CodeGuide
:books: 本代码库是作者小傅哥多年从事一线互联网 Java 开发的学习历程技术汇总,旨在为大家提供一个清晰详细的学习教程,侧重点更倾向编写Java核心内容。如果本仓库能为您提供帮助,请给予支持(关注、点赞、分享)!
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 执行过程中,有四大核心对象,每个对象都有可拦截的方法:
| 核心对象 | 职责 | 可拦截方法 |
|---|---|---|
| Executor | SQL 执行器,总调度 | update/query/commit/rollback |
| StatementHandler | SQL 语句处理器 | 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() 的四个步骤可以提炼为:
- 解析签名:通过反射读取
@Intercepts和@Signature注解,得到signatureMap(接口类型 → 方法集合); - 匹配目标:遍历目标对象实现的所有接口,找出与
signatureMap交集; - 创建代理:交集非空才用
Proxy.newProxyInstance()创建 JDK 动态代理; - 原样返回:插件与目标对象无关(比如自定义插件只拦 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();
}
}
这个示例踩透了三个关键细节:
- 拿 SQL:
MappedStatement.getBoundSql(parameterObject)把参数绑定进 SQL 模板,得到最终可执行的BoundSql; - 改 SQL:
BoundSql.sql字段是私有的,必须通过反射setAccessible(true)才能改写——这是所有分页插件(包括 PageHelper)改 SQL 的统一手法; - 防双分页:把
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核心内容。如果本仓库能为您提供帮助,请给予支持(关注、点赞、分享)!
更多推荐

所有评论(0)