MyBatis 插件机制详解
定位:MyBatis 系列第 5 篇——拦截器(Interceptor)原理、代理装配、典型插件实现与使用边界 适用版本:MyBatis 3.5.x(JDK 8+)
目录
一、设计定位
1.1 本质
MyBatis 插件机制是责任链式拦截器 + JDK 动态代理的组合:对执行链路上四个核心对象的指定方法做透明增强,不修改框架源码,全部基于官方扩展点。它与 GoF 责任链、Servlet Filter、Spring Interceptor 是同一类设计。
1.2 与 Spring AOP 的分工
| 拦截位置 | 业务 Bean 方法(含 Mapper 接口方法) | MyBatis 执行链内部(Executor/StatementHandler 等) |
| 能拿到什么 | 方法参数与返回值 | BoundSql、参数映射、ResultSet 本体 |
| 典型用途 | 事务、日志、权限 | 分页改写 SQL、参数加密、结果脱敏 |
需要接触/改写 SQL 本体时只能用 MyBatis 插件——这是两者分界的判据。
1.3 典型横切能力
分页(PageHelper、MP PaginationInnerInterceptor)、慢 SQL 审计、多租户条件拼接、字段加解密、数据脱敏、数据权限 SQL 改写。
二、可拦截的四大对象
| Executor | update、query(两个重载)、flushStatements、commit、rollback、getTransaction、close、isClosed | 执行耗时统计、自定义缓存 |
| StatementHandler | prepare、parameterize、batch、update、query | 改写 SQL(分页、多租户) |
| ParameterHandler | getParameterObject、setParameters | 参数加密、默认值填充 |
| ResultSetHandler | handleResultSets、handleOutputParameters | 结果脱敏、字段解密 |
拦截点选择的经验法则:
- 要改 SQL 文本:StatementHandler.prepare(此时 Statement 尚未创建,改写最安全);
- 要统计整体耗时/影响执行入口:Executor.query/update;
- 要改参数值:ParameterHandler.setParameters;
- 要改返回结果:ResultSetHandler.handleResultSets。
注意:Executor.query 有两个重载,@Signature 的 args 必须与目标重载精确匹配,否则拦截不生效。
三、实现三要素
3.1 @Intercepts + @Signature:声明拦截点
@Intercepts({
@Signature(type = Executor.class,
method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class SlowQueryInterceptor implements Interceptor { ... }
- @Intercepts:类级注解,可声明多个 @Signature(一个插件拦截多个点)。
- @Signature:type 四选一;method 为方法名字符串;args 是参数类型的 Class 数组——重载方法必须靠 args 精确区分。
3.2 Interceptor 接口的三个方法
public interface Interceptor {
// 核心:拦截逻辑。invocation.proceed() 放行到下一环节
Object intercept(Invocation invocation) throws Throwable;
// 装配:默认实现即 Plugin.wrap(target, this),一般不覆写
default Object plugin(Object target) {
return Plugin.wrap(target, this);
}
// 接收 <plugin> 标签的 <property> 配置
default void setProperties(Properties properties) {}
}
3.3 完整示例:慢查询审计插件
@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 SlowQueryInterceptor implements Interceptor {
private static final Logger log = LoggerFactory.getLogger(SlowQueryInterceptor.class);
private long thresholdMs = 500;
@Override
public Object intercept(Invocation invocation) throws Throwable {
long start = System.currentTimeMillis();
try {
return invocation.proceed(); // 放行,继续责任链
} finally {
long cost = System.currentTimeMillis() – start;
if (cost > thresholdMs) {
MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
log.warn("[SLOW-SQL] id={}, cost={}ms", ms.getId(), cost);
}
}
}
@Override
public void setProperties(Properties properties) {
String threshold = properties.getProperty("thresholdMs", "500");
this.thresholdMs = Long.parseLong(threshold);
}
}
Invocation 封装三要素:target(被代理对象)、method、args——可读取、可改写后再 proceed。
四、代理装配原理
4.1 Plugin.wrap:按需生成代理
// Plugin.wrap 核心逻辑(简化)
public static Object wrap(Object target, Interceptor interceptor) {
// 1. 解析拦截器声明:Map<Class<?>, Set<Method>> signatureMap
Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
// 2. target 的类型不在声明内 → 原样返回(零代理开销)
Class<?> type = target.getClass();
Class<?>[] interfaces = getAllInterfaces(type, signatureMap);
if (interfaces.length > 0) {
// 3. 命中 → JDK 动态代理
return Proxy.newProxyInstance(type.getClassLoader(), interfaces,
new Plugin(target, interceptor, signatureMap));
}
return target;
}
Plugin.invoke 的分发逻辑:
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
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); // 透传
}
4.2 装配时机
拦截发生在 Configuration 的工厂方法中:newExecutor(包 Executor)、newStatementHandler、newParameterHandler、newResultSetHandler 依次调用 interceptorChain.pluginAll(target)——按注册顺序逐个 wrap。
4.3 多插件链的顺序
注册顺序:P1 → P2 → P3
包裹结果:P3( P2( P1( 真实对象 ) ) )
执行顺序:P3 先拦截 → P2 → P1 → 真实对象(后注册的先执行)
短路规则:任一环节不调用 proceed() 则链路终止(直接返回自定义结果)
多个插件改写同一语句时,执行顺序决定最终 SQL 形态——分页插件与租户插件共存时顺序错误会产生互相覆盖的 Bug,装配顺序必须有明确约定与文档。
五、典型插件实现模式
5.1 SQL 改写:多租户示例
拦截 StatementHandler.prepare,在 Statement 创建前改写 BoundSql:
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare",
args = {Connection.class, Integer.class})
})
public class TenantInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler handler = (StatementHandler) invocation.getTarget();
// RoutingStatementHandler 是门面,需经 MetaObject 取到内部真实 handler
MetaObject metaObject = MetaObject.forObject(handler,
new DefaultObjectFactory(), new DefaultObjectWrapperFactory(),
new DefaultReflectorFactory());
// 仅处理 SELECT,简化示例;实际需解析语句类型并防重复改写
MappedStatement ms = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
if (ms.getSqlCommandType() == SqlCommandType.SELECT) {
BoundSql boundSql = handler.getBoundSql();
String sql = boundSql.getSql();
String newSql = addTenantCondition(sql, currentTenantId());
// BoundSql.sql 是 final 字段,通过反射替换
Field sqlField = BoundSql.class.getDeclaredField("sql");
sqlField.setAccessible(true);
sqlField.set(boundSql, newSql);
}
return invocation.proceed();
}
private String addTenantCondition(String sql, String tenantId) {
// 教学示例:朴素字符串处理,生产应使用 JSqlParser 等 SQL 解析器
return sql + " AND tenant_id = '" + tenantId + "'";
}
private String currentTenantId() {
return "T001"; // 实际来自 ThreadLocal / 请求上下文
}
}
要点与陷阱:
5.2 参数处理:写前加密
@Intercepts({
@Signature(type = ParameterHandler.class, method = "setParameters",
args = {PreparedStatement.class})
})
public class EncryptInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
ParameterHandler ph = (ParameterHandler) invocation.getTarget();
MetaObject mo = SystemMetaObject.forObject(ph);
Object paramObj = mo.getValue("parameterObject");
if (paramObj instanceof SensitiveEntity entity) {
entity.setIdCard(EncryptUtil.encrypt(entity.getIdCard())); // 透明加密
}
return invocation.proceed();
}
}
配套读路径:ResultSetHandler 拦截器在结果组装后解密,业务代码无感知。
5.3 分页插件的原理(PageHelper 工作方式)
1. ThreadLocal 接收分页参数(PageHelper.startPage(pageNum, pageSize))
2. 拦截 Executor.query:
a. 将原 SQL 改写为 COUNT 语句执行,得到总数
b. 将原 SQL 追加方言分页子句(MySQL: LIMIT ?, ?)
c. 用反射替换 BoundSql 并补充分页参数
d. 执行改写后的查询
3. 结果包装为 Page(继承 ArrayList,附 total/pageNum)
MyBatis-Plus 的 PaginationInnerInterceptor 原理相同,只是挂在 MP 自己的拦截器容器内(见 07 篇)。
六、注册与配置
6.1 原生 XML
<plugins>
<plugin interceptor="com.example.plugin.SlowQueryInterceptor">
<property name="thresholdMs" value="500"/>
</plugin>
<plugin interceptor="com.example.plugin.TenantInterceptor"/>
</plugins>
<plugin> 的书写顺序即包裹顺序(后写的先执行)。
6.2 Spring / Spring Boot
把 Interceptor 声明为 Bean,starter 自动收集并注册到 SqlSessionFactory:
@Configuration
public class MyBatisPluginConfig {
@Bean
public SlowQueryInterceptor slowQueryInterceptor() {
SlowQueryInterceptor interceptor = new SlowQueryInterceptor();
Properties props = new Properties();
props.setProperty("thresholdMs", "500");
interceptor.setProperties(props);
return interceptor;
}
}
多个 Bean 的执行顺序由 @Order / Bean 定义顺序影响,需显式约定。
七、使用边界与风险
| 兼容风险 | 依赖内部结构(RoutingStatementHandler 的 delegate、BoundSql 的 sql 字段),MyBatis 升级可能破坏 | 升级时回归测试插件;尽量依赖官方 API |
| 顺序风险 | 多插件改写同一语句互相覆盖 | 明确装配顺序约定并文档化 |
| 性能风险 | 每个拦截点增加代理调用与反射开销 | 拦截点最小化,热点路径避免重逻辑 |
| 可维护风险 | SQL 被隐形改写,线上问题排查困难 | 插件清单文档化;慢 SQL 日志标注改写来源 |
纪律:
log.info("handler={}", handler.getClass()); // 多层代理时会看到 $ProxyN 嵌套
八、总结
九、常见高频面试题
1. MyBatis 插件(拦截器)的原理?可以拦截哪些对象?
要点:四大对象 Executor、StatementHandler、ParameterHandler、ResultSetHandler 的指定方法。通过 @Intercepts/@Signature 声明拦截点,Plugin.wrap 用 JDK 动态代理逐层包裹形成责任链;invoke 时判断方法是否在声明集合内,命中则调用 interceptor.intercept,invocation.proceed() 放行。
2. 多个 MyBatis 插件的执行顺序是怎样的?
要点:按注册顺序逐层包裹,后注册的在外层,执行时外层先拦截(后配置的先执行);任一插件不调用 proceed() 即短路。多插件改写 SQL 时顺序决定最终形态,必须有明确约定。
3. 分页插件的原理?
要点:拦截 Executor.query(或 StatementHandler),ThreadLocal 接收分页参数;先把原 SQL 改写为 COUNT 查询取总数,再给原 SQL 追加方言分页子句(MySQL LIMIT),反射替换 BoundSql 并补充分页参数,执行后把结果包装成含 total 的分页对象。PageHelper 与 MP PaginationInnerInterceptor 均属此原理。
4. 为什么分页插件能拿到并修改 SQL?
要点:执行链中 SQL 文本保存在 BoundSql 对象里;插件通过 MetaObject 从 StatementHandler(RoutingStatementHandler 的 delegate)取到 BoundSql,再用反射替换其 final 的 sql 字段。这是 MyBatis 插件改写 SQL 的通用手法。
5. MyBatis 插件和 Spring AOP 有什么区别?
要点:Spring AOP 拦截业务 Bean 方法(含 Mapper 接口方法),能拿到方法参数与返回值,但拿不到 BoundSql/参数映射/ResultSet;MyBatis 插件拦截框架执行链内部,能接触并改写 SQL 本体。需要 SQL 级横切(分页、租户、加解密)时用 MyBatis 插件。
6. @Signature 的 args 为什么必须写?写错会怎样?
要点:Executor.query 等方法存在重载,args(参数类型数组)用于精确匹配目标方法。写错或缺失会导致 signatureMap 匹配不到实际调用,插件静默不生效(不报错),是常见的插件失效原因。
7. 如何实现一个字段脱敏/加密插件?拦截点如何选择?
要点:写路径拦截 ParameterHandler.setParameters,加密 parameterObject 中的敏感字段后再放行;读路径拦截 ResultSetHandler.handleResultSets,组装结果后遍历解密/脱敏。选择依据:要改参数选 ParameterHandler,要改结果选 ResultSetHandler。




