第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);
    }
}

当创建 ExecutorStatementHandlerParameterHandlerResultSetHandler 时,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 子句。

实现思路

  1. 拦截 Executor.query 方法,判断是否需要分页(例如参数中包含分页对象)。

  2. 从参数中提取分页信息(页码、每页大小)。

  3. 改写 BoundSql 中的 SQL,添加数据库特定的分页语法。

  4. 设置新的参数(如 offsetlimit)到参数列表中。

  5. 继续执行原方法,返回分页结果。

代码示例(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 执行时间。

实现思路

  1. 拦截 StatementHandler.query 和 StatementHandler.update 方法。

  2. 在执行前后记录时间,计算耗时。

  3. 如果超过阈值,打印警告日志或发送到监控系统。

代码示例

@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 执行、缓存维护。可拦截方法包括 updatequerycommitrollback 等。

  • StatementHandler(语句处理器):负责创建 Statement、设置参数、执行 SQL。可拦截方法包括 prepareparameterizebatchupdatequery 等。

  • ParameterHandler(参数处理器):负责将 Java 参数设置到 PreparedStatement 中。可拦截方法为 setParameters

  • ResultSetHandler(结果集处理器):负责将 ResultSet 映射为 Java 对象。可拦截方法包括 handleResultSetshandleOutputParameters

当多个插件同时拦截同一个对象的同一个方法时,它们的执行顺序由 插件在 MyBatis 配置文件中的声明顺序 决定。MyBatis 会将所有插件依次添加到 InterceptorChain 中,然后通过 pluginAll 方法为目标对象创建代理链。最先声明的插件位于代理链的最外层,因此其 intercept 方法最先执行;最内层的插件最后执行 intercept,但最接近真实目标。调用过程是层层进入,然后逆序返回。

例如配置顺序为 PluginA、PluginB,则调用顺序为:

  1. PluginA.intercept()

  2. PluginA 中调用 invocation.proceed() -> 进入 PluginB.intercept()

  3. PluginB 中调用 invocation.proceed() -> 进入真实目标方法

  4. 真实目标返回 -> PluginB.intercept() 后置逻辑 -> PluginA.intercept() 后置逻辑

2. 请设计一个分页插件,说明拦截哪个对象的哪个方法,以及如何改写 SQL。

考察点:对插件实际应用的理解,能否设计出合理的技术方案。

完整答复

目标:实现物理分页,避免 MyBatis 默认的 RowBounds 内存分页。

拦截对象和方法

  • 拦截 Executor 的 query 方法,具体签名为:
    query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler)
    因为 Executor 是所有查询的入口,在此处改写 SQL 可以影响最终的 SQL 执行。

实现步骤

  1. 判断是否需要分页:通常通过参数对象是否实现某个分页接口(如 Pageable)来判断。也可以约定参数中包含 pageNum 和 pageSize 属性。

  2. 获取原始 SQL:通过 ms.getBoundSql(parameter) 获取 BoundSql 对象,从中得到原始 SQL 字符串。

  3. 改写 SQL:根据数据库方言,在原始 SQL 后拼接分页语句。例如 MySQL 使用 LIMIT offset, limit,Oracle 使用三层嵌套查询。可以支持多种数据库,通过配置或自动识别。

  4. 处理分页参数:分页需要的 offset 和 limit 需要添加到参数列表中。通常有两种方式:

    • 将 offset 和 limit 作为附加参数设置到 BoundSql 的 additionalParameters 中。

    • 或者修改参数对象,添加这两个字段(需要确保 ParameterHandler 能正确处理)。

  5. 更新 BoundSql:由于 BoundSql 的 sql 字段没有 public setter,需要通过反射修改;或者创建新的 BoundSql 并替换,同时需要更新 MappedStatement 中的 BoundSql(但 MappedStatement 不可变,通常的做法是直接修改原 BoundSql 的私有字段)。

  6. 继续执行:调用 invocation.proceed() 执行被拦截的方法,此时 Executor 会使用修改后的 SQL 和参数执行查询。

注意事项

  • 要避免重复分页,如果已经分页过,不应再次处理。

  • 需要兼容 MyBatis 的缓存机制,修改 SQL 后 CacheKey 可能发生变化,分页插件的 CacheKey 应包含分页参数。

  • 推荐直接使用成熟的分页插件如 PageHelper,它已经解决了上述复杂问题。

3. 插件的 @Signature 注解中 type、method、args 分别代表什么?如果填写错误会导致什么问题?

考察点:对插件声明细节的掌握,以及错误排查能力。

完整答复

@Signature 注解用于声明插件要拦截的目标方法,包含三个属性:

  • type:指定要拦截的目标对象类型,必须是 ExecutorStatementHandlerParameterHandlerResultSetHandler 之一。

  • method:指定要拦截的方法名,字符串形式。

  • args:指定被拦截方法的参数类型数组,用于区分重载方法。因为 MyBatis 核心对象存在多个重载方法(例如 Executor 有两个 query 方法),必须通过参数类型精确定位。

如果填写错误,可能导致以下问题

  1. type 错误:如果 type 不是上述四种类型,MyBatis 在启动解析注解时会抛出异常,因为无法从该类型中获取方法。

  2. method 名称错误:如果方法名不存在于该类型中,启动时反射调用 getMethod 会抛出 NoSuchMethodException,导致应用启动失败。

  3. args 错误:如果参数类型数组与实际方法不匹配(例如顺序、个数或类型不对),同样会抛出 NoSuchMethodException。这可能导致插件无法正确拦截目标方法,甚至影响其他插件或正常流程。更隐蔽的是,如果 args 匹配到了另一个重载方法,则插件会错误地拦截了非预期的方法,导致逻辑混乱(例如拦截了内部使用的 query 方法而非对外暴露的 query 方法)。

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐