《Mybatis》第5章 插件机制与扩展
第5章 插件机制与扩展
MyBatis 的插件机制是其最具扩展性的特性之一,允许你在 SQL 执行的四大核心组件上进行拦截,实现分页、性能监控、数据脱敏等功能。本章将从源码层面剖析插件的实现原理,并通过实战案例教你如何开发高效、稳定的自定义插件。
5.1 插件机制概述
MyBatis 插件本质上是一个 拦截器(Interceptor),它可以拦截四大核心对象的方法调用:
-
Executor:执行器,负责 SQL 执行、缓存维护。
-
StatementHandler:语句处理器,负责 JDBC Statement 的创建和参数设置。
-
ParameterHandler:参数处理器,负责将 Java 参数设置到 PreparedStatement 中。
-
ResultSetHandler:结果集处理器,负责将 ResultSet 映射为 Java 对象。
通过在这些对象的方法执行前后插入自定义逻辑,可以实现强大的扩展功能,而无需修改 MyBatis 源码。
5.2 插件实现原理
5.2.1 Interceptor 接口
所有插件都必须实现 org.apache.ibatis.plugin.Interceptor 接口:
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.proceed()可以调用原方法。 -
plugin:生成代理对象。默认实现调用
Plugin.wrap,这是一个静态方法,用于为目标对象创建 JDK 动态代理。 -
setProperties:从配置文件中读取插件属性。
5.2.2 代理链的生成:InterceptorChain
MyBatis 在初始化时,会将所有配置的插件添加到 InterceptorChain 中。InterceptorChain 是一个简单的列表,并提供 pluginAll 方法为目标对象层层包装代理:
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;
}
public void addInterceptor(Interceptor interceptor) {
interceptors.add(interceptor);
}
}
当创建 Executor、StatementHandler、ParameterHandler、ResultSetHandler 时,MyBatis 会调用 pluginAll 对原始对象进行包装,生成代理链。例如创建 Executor 的代码:
// Configuration.java
public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
executorType = executorType == null ? defaultExecutorType : executorType;
executorType = executorType == null ? ExecutorType.SIMPLE : 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;
}
其他三个对象的创建也类似,都在对应的方法中调用了 pluginAll。
5.2.3 Plugin 类的核心:动态代理
Plugin.wrap 方法的实现如下:
public static Object wrap(Object target, Interceptor interceptor) {
// 获取该拦截器注解中定义的要拦截的方法签名
Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
Class<?> type = target.getClass();
// 找出目标对象实现的接口中,哪些接口被拦截器标记了
Class<?>[] interfaces = getAllInterfaces(type, signatureMap);
if (interfaces.length > 0) {
// 创建 JDK 动态代理
return Proxy.newProxyInstance(
type.getClassLoader(),
interfaces,
new Plugin(target, interceptor, signatureMap));
}
return target;
}
getSignatureMap 会解析拦截器上的 @Intercepts 和 @Signature 注解,构建一个映射:从接口类型到该接口中需要拦截的方法集合。
Plugin 类实现了 InvocationHandler,其 invoke 方法如下:
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
try {
// 获取被代理对象实现的接口中,当前方法对应的拦截器集合(可能有多个?实际上每个拦截器单独包装)
Set<Method> methods = signatureMap.get(method.getDeclaringClass());
// 如果当前方法需要被拦截,则调用拦截器的 intercept 方法
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);
}
}
关键点:
-
每个拦截器单独生成一个代理对象,多个拦截器依次包装,形成代理链。
-
调用链的执行顺序取决于拦截器在
InterceptorChain中的添加顺序:最外层的拦截器最先执行其intercept方法,通过invocation.proceed()调用内层拦截器或真实目标。
5.2.4 多层代理的调用过程

5.3 可拦截方法与签名
要确定拦截哪个对象的哪个方法,需要使用 @Intercepts 和 @Signature 注解声明。每个 @Signature 定义了一个要拦截的方法,包括:
-
type:目标类型(Executor、StatementHandler、ParameterHandler、ResultSetHandler 之一) -
method:方法名 -
args:参数类型数组(用于区分重载方法)
四大对象可拦截的方法列表(常用):
| 对象 | 方法 | 参数 | 说明 |
|---|---|---|---|
| Executor | update | MappedStatement, Object | 执行更新(增删改) |
| Executor | query | MappedStatement, Object, RowBounds, ResultHandler | 执行查询(无缓存) |
| Executor | query | MappedStatement, Object, RowBounds, ResultHandler, CacheKey, BoundSql | 执行查询(内部使用) |
| Executor | commit | boolean | 提交事务 |
| Executor | rollback | boolean | 回滚事务 |
| StatementHandler | prepare | Connection, Integer | 创建 Statement |
| StatementHandler | parameterize | Statement | 设置参数 |
| StatementHandler | batch | Statement | 批量操作 |
| StatementHandler | update | Statement | 执行更新 |
| StatementHandler | query | Statement, ResultHandler | 执行查询 |
| ParameterHandler | getParameterObject | 无 | 获取参数对象 |
| ParameterHandler | setParameters | PreparedStatement | 设置参数 |
| ResultSetHandler | handleResultSets | Statement | 处理结果集 |
| ResultSetHandler | handleOutputParameters | CallableStatement | 处理存储过程输出参数 |
示例:声明拦截 Executor 的 query 方法
@Intercepts({
@Signature(
type = Executor.class,
method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
)
})
public class MyPlugin implements Interceptor {
// ...
}
5.4 自定义插件实战
5.4.1 分页插件
分页是 Web 应用最常用的功能之一。MyBatis 本身提供了 RowBounds 进行内存分页(性能差),因此通常需要物理分页,即改写 SQL 添加 LIMIT 子句。
实现思路:
-
拦截
Executor.query方法,判断是否需要分页(例如参数中包含分页对象)。 -
从参数中提取分页信息(页码、每页大小)。
-
改写
BoundSql中的 SQL,添加数据库特定的分页语法。 -
设置新的参数(如
offset、limit)到参数列表中。 -
继续执行原方法,返回分页结果。
代码示例(MySQL 方言):
@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();
MappedStatement ms = (MappedStatement) args[0];
Object parameter = args[1];
RowBounds rowBounds = (RowBounds) args[2];
// 检查是否需要分页(例如参数实现了某个分页接口)
if (parameter instanceof Pageable) {
Pageable page = (Pageable) parameter;
// 获取原始 BoundSql
BoundSql boundSql = ms.getBoundSql(parameter);
String originalSql = boundSql.getSql();
// 生成分页 SQL
String pageSql = originalSql + " LIMIT " + page.getOffset() + "," + page.getPageSize();
// 创建新的 BoundSql
BoundSql newBoundSql = new BoundSql(ms.getConfiguration(), pageSql,
boundSql.getParameterMappings(), boundSql.getParameterObject());
// 复制附加参数(如果原始 BoundSql 有额外参数)
for (ParameterMapping mapping : boundSql.getParameterMappings()) {
String prop = mapping.getProperty();
if (boundSql.hasAdditionalParameter(prop)) {
newBoundSql.setAdditionalParameter(prop, boundSql.getAdditionalParameter(prop));
}
}
// 通过反射修改 MappedStatement 中的 BoundSql(比较复杂,推荐使用工具类)
// 这里简化:重新创建 MappedStatement?不,我们需要修改现有的 BoundSql,但 MappedStatement 不可变。
// 更好的方式:使用 MetaObject 操作 BoundSql。
MetaObject metaObject = SystemMetaObject.forObject(boundSql);
metaObject.setValue("sql", pageSql); // 直接修改原 BoundSql 的 SQL(但需要确保原 BoundSql 可变,实际上 BoundSql 的 sql 字段没有 setter,需用反射)
// 或者构建一个新的 MappedStatement 并替换?但会影响后续查询,不建议。
// 通常做法:创建新的 BoundSql 并替换参数列表中的值,但需要处理后续的 ParameterHandler。
// 这里采用最简单的方式:通过反射修改 BoundSql 的 sql 字段。
Field sqlField = BoundSql.class.getDeclaredField("sql");
sqlField.setAccessible(true);
sqlField.set(boundSql, pageSql);
}
return invocation.proceed();
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
// 可以读取配置,如数据库方言
}
}
注意事项:
-
直接修改
BoundSql的sql字段可能不安全,因为BoundSql被多个地方引用。更规范的做法是:拦截Executor后,生成新的BoundSql并替换参数,同时需要保证ParameterHandler能正确处理参数。 -
分页插件应支持多种数据库方言,可通过配置动态选择。
-
要正确处理
RowBounds,避免重复分页。 -
如果使用 PageHelper 等成熟分页插件,建议直接集成,避免重复造轮子。
5.4.2 慢 SQL 监控插件
在生产环境中,监控慢 SQL 对性能优化至关重要。通过插件拦截 StatementHandler 的执行,可以记录 SQL 执行时间。
实现思路:
-
拦截
StatementHandler.query和StatementHandler.update方法。 -
在执行前后记录时间,计算耗时。
-
如果超过阈值,打印警告日志或发送到监控系统。
代码示例:
@Intercepts({
@Signature(type = StatementHandler.class, method = "query", args = {Statement.class, ResultHandler.class}),
@Signature(type = StatementHandler.class, method = "update", args = {Statement.class}),
@Signature(type = StatementHandler.class, method = "batch", args = {Statement.class})
})
public class SlowSqlInterceptor implements Interceptor {
private long slowThreshold = 1000; // 默认 1 秒
@Override
public Object intercept(Invocation invocation) throws Throwable {
long start = System.currentTimeMillis();
try {
return invocation.proceed();
} finally {
long duration = System.currentTimeMillis() - start;
if (duration > slowThreshold) {
// 获取 SQL
StatementHandler handler = (StatementHandler) invocation.getTarget();
BoundSql boundSql = handler.getBoundSql();
String sql = boundSql.getSql();
// 获取参数(可选)
Object parameter = boundSql.getParameterObject();
// 记录慢 SQL
Logger logger = LoggerFactory.getLogger(SlowSqlInterceptor.class);
logger.warn("Slow SQL detected, cost {} ms, sql: {}, params: {}", duration, sql, parameter);
}
}
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
String threshold = properties.getProperty("slowThreshold");
if (threshold != null) {
slowThreshold = Long.parseLong(threshold);
}
}
}
配置插件:
<plugins>
<plugin interceptor="com.xyz.SlowSqlInterceptor">
<property name="slowThreshold" value="500"/>
</plugin>
</plugins>
注意事项:
-
慢 SQL 记录应使用异步方式,避免影响主流程。
-
考虑敏感信息脱敏,不要记录明文密码等。
-
如果 SQL 非常长,可以截断日志。
5.5 插件开发注意事项
5.5.1 插件执行顺序
多个插件同时存在时,它们的执行顺序由在 InterceptorChain 中的添加顺序决定。例如:
<plugins>
<plugin interceptor="com.xyz.PluginA"/>
<plugin interceptor="com.xyz.PluginB"/>
</plugins>
则代理链为:PluginA 在最外层,PluginB 在内层。调用顺序:
-
进入
PluginA.intercept -
PluginA中调用proceed()-> 进入PluginB.intercept -
PluginB中调用proceed()-> 进入真实目标 -
返回时逆序。
因此,如果插件之间有依赖(例如分页插件和慢 SQL 插件,慢 SQL 应该在外层还是内层?),需合理设计顺序。通常,与业务无关的监控类插件可放在最外层,而修改 SQL 的插件应放在内层(确保 SQL 已被最终修改)。
5.4.2 避免重复代理
如果插件拦截的方法内部又调用了其他可拦截的方法(例如 Executor.query 内部调用了 StatementHandler.query),注意不要重复拦截。通常每个插件只关注自己需要拦截的方法,不干涉其他环节。
5.4.3 性能开销
插件基于动态代理实现,每次调用都会经过反射。虽然开销很小,但在高并发场景下应尽量减少不必要的拦截。可考虑:
-
缓存方法签名,避免重复解析。
-
在
intercept方法中快速判断,尽早放行不需要拦截的调用。
5.4.4 修改参数或返回值的注意事项
如果插件需要修改参数或返回值,必须确保修改后的对象与后续组件的期望一致。例如:
-
修改
BoundSql后,要同步更新ParameterMapping,否则ParameterHandler设置参数时可能出错。 -
修改返回值时,要保持类型一致,或确保 MyBatis 能处理转换。
5.6 源码深度剖析:Plugin 如何匹配方法签名
在 Plugin.wrap 中,getSignatureMap 方法解析拦截器上的注解,构建一个 Map<Class<?>, Set<Method>>。其核心逻辑:
private static Map<Class<?>, Set<Method>> getSignatureMap(Interceptor interceptor) {
Intercepts interceptsAnnotation = interceptor.getClass().getAnnotation(Intercepts.class);
if (interceptsAnnotation == null) {
throw new PluginException("No @Intercepts annotation was found in interceptor " + interceptor.getClass().getName());
}
Signature[] sigs = interceptsAnnotation.value();
Map<Class<?>, Set<Method>> signatureMap = new HashMap<>();
for (Signature sig : sigs) {
// 根据方法名和参数类型,从目标类中反射获取 Method 对象
Method method = sig.type().getMethod(sig.method(), sig.args());
Set<Method> methods = signatureMap.computeIfAbsent(sig.type(), k -> new HashSet<>());
methods.add(method);
}
return signatureMap;
}
在 Plugin.invoke 中,通过 method.getDeclaringClass() 获取方法所属的接口,然后从 signatureMap 中查找对应的 Set<Method>,判断是否包含当前方法。
重要:由于动态代理只能拦截接口方法,因此被拦截的对象必须实现接口(MyBatis 的核心对象都是接口实现)。例如 Executor 是一个接口,SimpleExecutor 是其实现类。
5.7 本章小结
-
MyBatis 插件基于 JDK 动态代理,可以拦截四大核心对象的方法。
-
插件通过
Interceptor接口实现,利用@Intercepts和@Signature声明要拦截的目标方法。 -
多个插件形成代理链,执行顺序由配置顺序决定。
-
自定义插件可实现分页、慢 SQL 监控、数据脱敏等功能,但需注意性能开销和修改对象时的兼容性。
-
源码中
InterceptorChain和Plugin类共同完成了代理的生成和调用分发。
⭐面试题
1. MyBatis 插件支持拦截哪些对象的方法?如果多个插件同时拦截同一个方法,执行顺序如何确定?
考察点:对 MyBatis 插件机制的理解,包括可拦截的对象和代理链顺序。
完整答复:
MyBatis 插件可以拦截四大核心对象的方法:
-
Executor(执行器):负责 SQL 执行、缓存维护。可拦截方法包括
update、query、commit、rollback等。 -
StatementHandler(语句处理器):负责创建 Statement、设置参数、执行 SQL。可拦截方法包括
prepare、parameterize、batch、update、query等。 -
ParameterHandler(参数处理器):负责将 Java 参数设置到 PreparedStatement 中。可拦截方法为
setParameters。 -
ResultSetHandler(结果集处理器):负责将 ResultSet 映射为 Java 对象。可拦截方法包括
handleResultSets、handleOutputParameters。
当多个插件同时拦截同一个对象的同一个方法时,它们的执行顺序由 插件在 MyBatis 配置文件中的声明顺序 决定。MyBatis 会将所有插件依次添加到 InterceptorChain 中,然后通过 pluginAll 方法为目标对象创建代理链。最先声明的插件位于代理链的最外层,因此其 intercept 方法最先执行;最内层的插件最后执行 intercept,但最接近真实目标。调用过程是层层进入,然后逆序返回。
例如配置顺序为 PluginA、PluginB,则调用顺序为:
-
PluginA.intercept()
-
PluginA 中调用 invocation.proceed() -> 进入 PluginB.intercept()
-
PluginB 中调用 invocation.proceed() -> 进入真实目标方法
-
真实目标返回 -> PluginB.intercept() 后置逻辑 -> PluginA.intercept() 后置逻辑
2. 请设计一个分页插件,说明拦截哪个对象的哪个方法,以及如何改写 SQL。
考察点:对插件实际应用的理解,能否设计出合理的技术方案。
完整答复:
目标:实现物理分页,避免 MyBatis 默认的 RowBounds 内存分页。
拦截对象和方法:
-
拦截
Executor的query方法,具体签名为:query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler)
因为 Executor 是所有查询的入口,在此处改写 SQL 可以影响最终的 SQL 执行。
实现步骤:
-
判断是否需要分页:通常通过参数对象是否实现某个分页接口(如
Pageable)来判断。也可以约定参数中包含pageNum和pageSize属性。 -
获取原始 SQL:通过
ms.getBoundSql(parameter)获取BoundSql对象,从中得到原始 SQL 字符串。 -
改写 SQL:根据数据库方言,在原始 SQL 后拼接分页语句。例如 MySQL 使用
LIMIT offset, limit,Oracle 使用三层嵌套查询。可以支持多种数据库,通过配置或自动识别。 -
处理分页参数:分页需要的 offset 和 limit 需要添加到参数列表中。通常有两种方式:
-
将 offset 和 limit 作为附加参数设置到
BoundSql的 additionalParameters 中。 -
或者修改参数对象,添加这两个字段(需要确保 ParameterHandler 能正确处理)。
-
-
更新 BoundSql:由于
BoundSql的sql字段没有 public setter,需要通过反射修改;或者创建新的BoundSql并替换,同时需要更新MappedStatement中的BoundSql(但 MappedStatement 不可变,通常的做法是直接修改原 BoundSql 的私有字段)。 -
继续执行:调用
invocation.proceed()执行被拦截的方法,此时 Executor 会使用修改后的 SQL 和参数执行查询。
注意事项:
-
要避免重复分页,如果已经分页过,不应再次处理。
-
需要兼容 MyBatis 的缓存机制,修改 SQL 后 CacheKey 可能发生变化,分页插件的 CacheKey 应包含分页参数。
-
推荐直接使用成熟的分页插件如 PageHelper,它已经解决了上述复杂问题。
3. 插件的 @Signature 注解中 type、method、args 分别代表什么?如果填写错误会导致什么问题?
考察点:对插件声明细节的掌握,以及错误排查能力。
完整答复:
@Signature 注解用于声明插件要拦截的目标方法,包含三个属性:
-
type:指定要拦截的目标对象类型,必须是
Executor、StatementHandler、ParameterHandler、ResultSetHandler之一。 -
method:指定要拦截的方法名,字符串形式。
-
args:指定被拦截方法的参数类型数组,用于区分重载方法。因为 MyBatis 核心对象存在多个重载方法(例如
Executor有两个query方法),必须通过参数类型精确定位。
如果填写错误,可能导致以下问题:
-
type 错误:如果 type 不是上述四种类型,MyBatis 在启动解析注解时会抛出异常,因为无法从该类型中获取方法。
-
method 名称错误:如果方法名不存在于该类型中,启动时反射调用
getMethod会抛出NoSuchMethodException,导致应用启动失败。 -
args 错误:如果参数类型数组与实际方法不匹配(例如顺序、个数或类型不对),同样会抛出
NoSuchMethodException。这可能导致插件无法正确拦截目标方法,甚至影响其他插件或正常流程。更隐蔽的是,如果 args 匹配到了另一个重载方法,则插件会错误地拦截了非预期的方法,导致逻辑混乱(例如拦截了内部使用的query方法而非对外暴露的query方法)。
更多推荐

所有评论(0)