欢迎光临
我们一直在努力

MyBatis 插件机制详解

MyBatis 插件机制详解

定位:MyBatis 系列第 5 篇——拦截器(Interceptor)原理、代理装配、典型插件实现与使用边界 适用版本:MyBatis 3.5.x(JDK 8+)


目录

  • 设计定位
  • 可拦截的四大对象
  • 实现三要素
  • 代理装配原理
  • 典型插件实现模式
  • 注册与配置
  • 使用边界与风险
  • 总结
  • 常见高频面试题

  • 一、设计定位

    1.1 本质

    MyBatis 插件机制是责任链式拦截器 + JDK 动态代理的组合:对执行链路上四个核心对象的指定方法做透明增强,不修改框架源码,全部基于官方扩展点。它与 GoF 责任链、Servlet Filter、Spring Interceptor 是同一类设计。

    1.2 与 Spring AOP 的分工

    维度Spring AOPMyBatis 插件
    拦截位置 业务 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 / 请求上下文
    }
    }

    要点与陷阱:

  • 拿到的 target 通常是 RoutingStatementHandler 门面,需经 MetaObject 取 delegate 才能访问 mappedStatement;
  • BoundSql.sql 为 final,替换靠反射(这是分页/租户插件的通用手法);
  • 改写 SQL 若新增占位符,还需同步处理 parameterMappings 与参数值,否则参数错位——成熟插件(PageHelper、MP 租户插件)用 JSqlParser 解析语法树完成改写,生产代码切勿用字符串拼接处理 SQL 结构。
  • 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 日志标注改写来源

    纪律:

  • 优先使用成熟插件(PageHelper、MP 内置插件),自研仅限业务强定制(租户、加解密)。
  • 自研插件必须:拦截点最小化、单元测试覆盖、在团队文档中登记。
  • 调试技巧:打印 Executor/StatementHandler 的实际类型与代理层数,确认插件链符合预期:
  • log.info("handler={}", handler.getClass()); // 多层代理时会看到 $ProxyN 嵌套


    八、总结

  • 本质:责任链式拦截器 + JDK 动态代理,对 Executor、StatementHandler、ParameterHandler、ResultSetHandler 的指定方法做透明增强;与 Spring AOP 的分界是「是否需要接触 SQL/参数/结果集本体」。
  • 三要素:@Intercepts 声明拦截点、@Signature 精确匹配(type + method + args)、Interceptor.intercept 实现逻辑并 proceed 放行。
  • 装配原理:Plugin.wrap 按 signatureMap 判断目标类型,未声明的类型原样返回零开销;命中的用 JDK 代理包裹,invoke 时按方法分发。
  • 链式顺序:后注册的插件在外层、先执行;任一环节可短路;多插件改写同一语句必须有顺序约定。
  • 改写 SQL 的通用手法:MetaObject 取内部对象 → 拿 BoundSql → 反射替换 sql 字段;生产级改写必须用 SQL 解析器(JSqlParser),禁止字符串拼接处理 SQL 结构。
  • 典型应用:分页(COUNT + 方言 LIMIT)、慢 SQL 审计、多租户、字段加解密。
  • 使用纪律:成熟插件优先、自研最小化拦截点、顺序文档化、升级回归。

  • 九、常见高频面试题

    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。

    在这里插入图片描述

    赞(0)
    未经允许不得转载:171主机测评 » MyBatis 插件机制详解
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址