TL;DR
- 场景:在 MyBatis 持久层中需要对 SQL 执行流程做无侵入增强,例如分页、SQL 日志、性能监控、SQL 改写、二级缓存等,又不想改 MyBatis 核心源码。
- 结论:MyBatis 插件机制基于 JDK 动态代理,对
Executor、StatementHandler、ParameterHandler、ResultSetHandler四大核心对象的方法调用进行拦截;通过实现Interceptor接口并以@Intercepts/@Signature注解声明拦截点,配合<plugins>配置即可生效。 - 产出:插件接口三大方法(
intercept/plugin/setProperties)的职责拆解、InterceptorChain.pluginAll串联多个拦截器的源码示例、Plugin.invoke与Invocation类的字段与调用约定、典型自定义插件模板与 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:
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 方法,那么可以定义:
@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 上:
<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 方法会对所有拦截的方法进行检测,以决定是否执行插件逻辑,该方法的逻辑如下:
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 主要用于存储目标类,方法以及方法参数列表,下面简单看一下该类的定义:
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 正常执行,但自定义逻辑从未触发 | @Signature 中 method 拼写错误或 args 与目标方法参数列表不匹配,导致 signatureMap 命中失败,invoke 走 method.invoke(target, args) 原路径 | 在 Plugin.invoke 第一行打断点,观察 signatureMap.get(method.getDeclaringClass()) 是否为 null 或 methods.contains(method) 为 false | 严格按目标接口方法签名补全 type / method / args,注意重载方法必须用 args 区分 |
启动报 Method not found 或 NoSuchMethodException | @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.pluginAll 按 interceptors 列表顺序逐层包装,先注册的在最外层 | 在 Configuration 解析阶段打印 interceptorChain.getInterceptors() 顺序 | 调整 sqlMapConfig.xml 中 <plugin> 的声明顺序,把最外层(最后包装目标)的拦截器放最后 |
| 拦截后 SQL 执行变慢明显 | 拦截 StatementHandler.prepare 或 Executor.query 时做了同步 IO/重活,未做缓存 | 用 Arthas / async-profiler 看 intercept 方法耗时 | 拦截点尽量靠近外层(Executor),并把耗时操作异步化或加本地缓存 |
拦截 ParameterHandler.setParameters 后参数绑定丢失 | 误以为 proceed() 会自动恢复参数,在 proceed() 前后对 args[1] 做了 in-place 修改但未还原 | 在 setParameters 前后打印 PreparedStatement 的 ? 数量与值 | 不要在拦截器里改 boundSql 之外的入参对象;如需改写 SQL,拦截 StatementHandler 的 prepare / parameterize |
自定义 Invocation 子类后 proceed 死循环 | 覆写 proceed() 时忘了调用 super.proceed(),导致递归调用到自身 | 单元测试拦截器,对递归路径加超时保护 | 始终基于 Invocation 原始 proceed() 实现,包装而非覆写 |
| 插件里抛出的异常被吞掉 | Plugin.invoke 中 catch (Exception e) { throw ExceptionUtil.unwrapThrowable(e); } 会做拆包,但业务异常如果是 Error 或被 try-catch 吞掉则看不到 | 在 intercept 内显式打日志,确认 proceed() 抛出的原异常 | 让异常直接抛出,由 Plugin.invoke 统一拆包;不要在 intercept 中 try-catch(Exception) 静默 |
@Intercepts 注解未生效,类被当作普通 Bean | 注解写在 implement Interceptor 之外的类上,或未实现 Interceptor 接口 | 启动日志确认 Configuration 解析到的 interceptors 数量 | 确保类 implements Interceptor,且 @Intercepts 标注在类上 |
同一接口的多个 @Signature 顺序导致命中错乱 | 多个 @Signature 命中同一方法时,先声明的先生效;混淆了 type 与 method | 用最小复现的 @Signature 列表逐个验证 | 拆分到不同 @Intercepts 注解块中,或合并到一个拦截器里手动分发 |