• 如何自定义一个MyBatis插件


    如何自定义一个MyBatis插件

    面试官您好,我来回答一下如何自定义 MyBatis 插件这个问题。

    先讲核心原理 🧠

    MyBatis 插件本质上是基于JDK 动态代理 + 责任链模式实现的,它允许我们在 MyBatis 执行 SQL 的关键节点进行拦截和增强。

    MyBatis 定义了四大核心对象可以被拦截:

    拦截对象 主要作用 常用拦截方法
    Executor 执行增删改查的调度器 update、query、commit、rollback
    StatementHandler 处理 SQL 语句构建 prepare、parameterize、batch、update、query
    ParameterHandler 处理 SQL 参数映射 setParameters
    ResultSetHandler 处理结果集映射 handleResultSets、handleOutputParameters

    自定义插件的完整步骤 📝

    我用一个流程图来展示完整的开发流程:

    image


    关于这个问题的底层原理和更多实战细节,我整理了一份《大厂面试手册》,包含大厂高频面试题、源码解析和性能调优案例。
    关注公众号【Rain的Java大神之路】,回复“Java”即可免费领取,持续更新中。


    1. 基础骨架代码

    @Intercepts({
        @Signature(
            type = StatementHandler.class,
            method = "prepare",
            args = {Connection.class, Integer.class}
        )
    })
    public class SqlLogPlugin implements Interceptor {
        
        @Override
        public Object intercept(Invocation invocation) throws Throwable {
            // 增强逻辑
            return invocation.proceed();
        }
        
        @Override
        public Object plugin(Object target) {
            return Plugin.wrap(target, this);
        }
        
        @Override
        public void setProperties(Properties properties) {
        }
    }
    

    2. 注册插件

    方式一:XML 配置

    <configuration>
        <plugins>
            <plugin interceptor="com.example.SqlLogPlugin"/>
        plugins>
    configuration>
    

    方式二:SpringBoot 自动配置

    @Configuration
    public class MyBatisConfig {
        @Bean
        public SqlLogPlugin sqlLogPlugin() {
            return new SqlLogPlugin();
        }
    }
    

    关键技术点 ⚡

    1. @Intercepts 和 @Signature 注解:精确指定要拦截哪个对象的哪个方法
    2. Invocation 对象:包含了被代理对象、方法和参数,调用proceed()执行原方法
    3. MetaObject 工具类:MyBatis 提供的反射工具,方便获取对象的私有属性
    4. Plugin.wrap():自动判断是否需要生成代理对象,只有匹配的对象才会被代理

    常见应用场景 🎯

    • SQL 执行日志打印(带参数)
    • 分页插件(如 PageHelper)
    • 数据权限过滤
    • 慢 SQL 监控
    • 读写分离
    • 公共字段自动填充

    注意事项和坑点 ⚠️

    1. 拦截顺序问题:多个插件会形成责任链,先注册的先拦截,后注册的先执行
    2. 不要随意修改原对象:除非你非常清楚后果,否则可能导致不可预期的问题
    3. 性能影响:拦截器会增加额外的方法调用开销,不要在拦截器中做耗时操作
    4. 异常处理:拦截器中的异常会影响 SQL 执行,需要妥善处理
    5. 版本兼容性:不同版本的 MyBatis 方法签名可能会有变化

    核心代码与技术亮点 🚀

    我给您展示一个实际项目中使用的数据权限过滤插件,这是我觉得最能体现技术深度的示例:

    /**
     * 数据权限过滤插件(技术亮点版)
     * 基于用户ID自动过滤数据,只允许查看自己创建的数据
     */
    @Intercepts({
        @Signature(
            type = StatementHandler.class,
            method = "prepare",
            args = {Connection.class, Integer.class}
        )
    })
    @Component
    @Order(1) // ✅ 技术亮点1:指定执行顺序,确保在分页插件之前执行
    public class DataPermissionPlugin implements Interceptor {
    
        // 不需要权限过滤的方法白名单
        private static final Set EXCLUDE_METHODS = Set.of(
            "getById", "listAll", "countAll", "adminQuery"
        );
    
        @Override
        public Object intercept(Invocation invocation) throws Throwable {
            // 1. 获取MyBatis内部核心对象
            StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
            // ✅ 技术亮点2:使用官方MetaObject工具安全反射,避免手写反射代码
            MetaObject metaObject = SystemMetaObject.forObject(statementHandler);
            MappedStatement ms = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
            
            // 2. 白名单过滤,不需要权限控制的方法直接放行
            String methodId = ms.getId();
            String methodName = methodId.substring(methodId.lastIndexOf(".") + 1);
            if (EXCLUDE_METHODS.contains(methodName)) {
                return invocation.proceed();
            }
    
            // 3. 获取当前登录用户(从统一安全上下文获取,与业务完全解耦)
            Long currentUserId = SecurityContextHolder.getContext().getUserId();
            if (currentUserId == null) {
                throw new AccessDeniedException("用户未登录,无法进行数据权限校验");
            }
    
            // 4. 获取原始SQL(此时动态SQL已完全解析完成)
            BoundSql boundSql = statementHandler.getBoundSql();
            String originalSql = boundSql.getSql().trim();
    
            // ✅ 技术亮点3:使用JSqlParser解析SQL抽象语法树,安全修改SQL
            // 彻底解决字符串替换导致的SQL语法错误问题(如子查询、多表联查、OR条件)
            CCJSqlParserManager parserManager = new CCJSqlParserManager();
            Select select = (Select) parserManager.parse(new StringReader(originalSql));
            PlainSelect plainSelect = (PlainSelect) select.getSelectBody();
    
            // 5. 动态添加数据权限条件
            Expression originalWhere = plainSelect.getWhere();
            EqualsTo permissionCondition = new EqualsTo();
            permissionCondition.setLeftExpression(new Column("t.create_user_id"));
            permissionCondition.setRightExpression(new LongValue(currentUserId));
    
            if (originalWhere == null) {
                plainSelect.setWhere(permissionCondition);
            } else {
                // 智能拼接AND条件,不破坏原有WHERE逻辑
                plainSelect.setWhere(new AndExpression(originalWhere, permissionCondition));
            }
    
            // 6. 将修改后的SQL写回MyBatis
            String newSql = select.toString();
            metaObject.setValue("delegate.boundSql.sql", newSql);
    
            // 7. 执行原方法
            return invocation.proceed();
        }
    
        @Override
        public Object plugin(Object target) {
            return Plugin.wrap(target, this);
        }
    
        @Override
        public void setProperties(Properties properties) {
        }
    }
    

    核心技术亮点总结 ✨

    1. 精确执行顺序控制:通过@Order注解明确指定插件执行顺序,完美解决与分页插件的冲突
    2. 安全的 SQL 修改方式:基于抽象语法树(AST)修改 SQL,比字符串替换更可靠、更健壮
    3. 优雅的反射操作:使用 MyBatis 官方MetaObject工具,一行代码获取 / 修改任何私有属性
    4. 灵活的白名单机制:支持按方法名排除不需要权限控制的接口
    5. 完全解耦的设计:从统一安全上下文获取用户信息,插件与业务代码零耦合

    常见技术难点与解决方案 🧩

    我整理了实际开发中最容易遇到的 7 个技术难点,以及对应的解决方案:

    技术难点 核心问题 解决方案 关键代码示例
    SQL 修改导致语法错误 简单字符串替换无法处理复杂 SQL(子查询、OR 条件、多表联查) 使用JSqlParser解析 SQL 抽象语法树,动态修改表达式节点 Select select=(Select) parserManager.parse(new StringReader(sql));
    多插件执行顺序混乱 分页插件在数据权限插件之前执行,导致分页后再过滤数据 实现Ordered接口或使用@Order注解规则:数值越小,优先级越高 @Order(1) // 数据权限@Order(2) // 分页
    复杂参数无法获取 无法获取嵌套对象、集合、@Param注解的参数值 使用MetaObject递归获取参数值,支持 OGNL 表达式 Object deptId=metaObject.getValue("user.dept.id");
    批量操作无法拦截 批量插入 / 更新时,拦截器只执行一次 拦截Executor的batch方法,遍历所有待执行语句 @Signature(type=Executor.class, method="batch", args={MappedStatement.class, Object.class})
    动态 SQL 修改无效 拦截时机不对,动态 SQL 还未解析完成 拦截StatementHandler的prepare方法,此时 SQL 已完全解析 @Signature(type=StatementHandler.class, method="prepare", args={Connection.class, Integer.class})
    版本兼容性问题 不同 MyBatis 版本的内部对象结构不同 使用反射获取方法和属性,避免直接调用版本相关 API Method getBoundSql=target.getClass().getMethod("getBoundSql");
    无法区分不同 Mapper 方法 所有方法都被拦截,无法精确控制 通过MappedStatement的id获取方法全名,进行白名单 / 黑名单过滤 String methodId=ms.getId();

    回答总结 ✅

    自定义 MyBatis 插件的核心就是:实现 Interceptor 接口,通过注解指定拦截点,在 intercept 方法中编写增强逻辑,最后注册插件。

    它是 MyBatis 提供的最强大的扩展机制之一,让我们可以在不修改框架源码的情况下,实现很多通用的横切功能。实际开发中,建议优先使用基于抽象语法树的 SQL 修改方式,并通过@Order注解明确控制多插件的执行顺序,这样能避免 90% 以上的坑。

    真实面试模拟

    真实面试模拟

    👨‍💻 面试官:

    你对 MyBatis 应该比较熟悉了,那如果让你自定义一个插件,实现“监控所有执行超过1秒的慢SQL”,你会怎么设计和实现?

    👦 我(候选人):

    嗯,这个问题我先从原理说起,再落到代码上,最后提一下实际开发中容易踩的坑,您看行吗?

    👨‍💻 面试官:

    可以,就这么讲。

    👦 我:

    好嘞。首先 MyBatis 插件的核心思想就是 责任链模式 + JDK 动态代理。它允许我们在四大对象的方法调用路径上嵌入自定义逻辑,这四大对象是:

    • Executor:执行器,调度SQL执行
    • StatementHandler:处理Statement的创建和参数设置
    • ParameterHandler:参数处理器
    • ResultSetHandler:结果集映射

    这些对象被创建时,都会经过 Plugin.wrap() 方法,如果发现当前插件配置的拦截点和目标对象匹配,就用 JDK 动态代理生成一个代理对象,以后调目标方法,都会先进代理的 invoke(),然后再进咱们的 intercept()。多个插件就连成一条链了,像这样:
    image

    👨‍💻 面试官:

    好,原理清楚了。那如果让你写代码实现这个慢SQL监控插件,你怎么写?

    👦 我:

    我会分三步走:实现接口、配置拦截点、注册插件。

    首先拦截点我选 Executor 的 query 和 update 方法,因为这里能直接拿到 MappedStatement,方便获取SQL和参数。代码大概这样:

    @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 SlowSqlInterceptor implements Interceptor {
    
        private long threshold = 1000; // 默认1秒
    
        @Override
        public Object intercept(Invocation invocation) throws Throwable {
            long start = System.currentTimeMillis();
            Object result = invocation.proceed(); // ⚡ 一定要调原方法
            long cost = System.currentTimeMillis() - start;
    
            if (cost > threshold) {
                MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
                Object param = invocation.getArgs()[1];
                BoundSql boundSql = ms.getBoundSql(param);
                System.err.printf("🐢 慢SQL [%dms] | %s\n", cost, boundSql.getSql());
            }
            return result;
        }
    
        @Override
        public Object plugin(Object target) {
            return Plugin.wrap(target, this); // 🔗 生成代理
        }
    
        @Override
        public void setProperties(Properties properties) {
            String th = properties.getProperty("threshold");
            if (th != null) this.threshold = Long.parseLong(th);
        }
    }
    

    👨‍💻 面试官:

    代码结构挺清晰,那怎么注册这个插件呢?

    👦 我:

    两种常见方式。

    • 传统 XML 配置:在 mybatis-config.xml 的 里加上:
    <plugin interceptor="com.xxx.SlowSqlInterceptor">
        <property name="threshold" value="2000"/>
    plugin>
    
    • Spring Boot 环境更简单,只要给拦截器类加上 @Component,MyBatis 的自动配置会自动把它加到 SqlSessionFactory 里,也可以手动通过 @Bean 注入。

    👨‍💻 面试官:

    明白了。那你在这个插件里有没有踩过什么坑,或者有什么设计上的注意点?

    👦 我:

    还真有几个点得留心 💡:

    1. 方法签名必须严格匹配:@Signature 里的方法名和参数类型一个字母都不能差,比如 Executor.update 有两个重载,我只拦带 MappedStatement 的那个,否则插件不生效。
    2. 别在拦截器里做重操作:intercept() 会包在每个SQL外面,我就做了个时间计算和条件打印,要是在里面做IO或者复杂计算,整个业务都得变慢。
    3. 不要丢掉 invocation.proceed():必须调用并返回它的结果,除非你故意要拦截返回值,不然数据库操作直接没了。
    4. 插件顺序有讲究:多个插件按配置顺序层层包裹,像剥洋葱,先进后出。
    5. 要拿“动态参数替换后”的SQL得谨慎:现在只打印原SQL,如果要替换 ?,得用 MetaObject 反射 StatementHandler 里的 ParameterHandler,但这会引入额外开销,监控场景一般不需要。

    👨‍💻 面试官:

    那除了慢SQL监控,MyBatis插件还能用在哪些场景?

    👦 我:

    原理完全一样,只是增强逻辑不同。比如:

    • 分页插件:拦截 Executor.query,判断是否需要分页,改写 SQL 并设置分页参数。
    • 读写分离:根据方法名或注解判断走主库还是从库,动态切换数据源。
    • 乐观锁:在 update 方法执行前,自动给 SQL 加上 version 判断逻辑。
    • 数据脱敏:在 ResultSetHandler 里对查询结果做统一脱敏处理。

    👨‍💻 面试官:

    刚刚你讲了监控慢SQL插件的原理和思路,现在能把核心代码亮出来吗?最好说下你认为的技术亮点。

    👦 我:

    没问题,我直接贴核心部分,边贴边讲亮点。

    // ✨ 亮点1:精准的注解驱动配置
    @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 SlowSqlMonitor implements Interceptor {
        
        // ✨ 亮点2:可外部化配置的阈值,通过setProperties动态注入
        private long threshold = 1000;
    
        @Override
        public Object intercept(Invocation invocation) throws Throwable {
            long startTime = System.currentTimeMillis();
            // 保证原逻辑一定执行
            Object result = invocation.proceed();
            long costTime = System.currentTimeMillis() - startTime;
    
            if (costTime > threshold) {
                MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
                Object parameter = invocation.getArgs()[1];
                // ✨ 亮点3:直接从MappedStatement拿到BoundSql,零侵入获取SQL
                BoundSql boundSql = mappedStatement.getBoundSql(parameter);
                String sql = boundSql.getSql();
                
                // 生产可用日志框架,这里演示用标准错误流
                System.err.printf("🐢 慢SQL [%dms] | %s | 参数: %s%n", 
                    costTime, sql, parameter);
            }
            return result;
        }
    
        @Override
        public Object plugin(Object target) {
            // ✨ 亮点4:利用官方工具生成代理,简洁且遵循规范
            return Plugin.wrap(target, this);
        }
    
        @Override
        public void setProperties(Properties properties) {
            // ✨ 亮点5:阈值可通过XML或Spring属性配置,提升灵活性
            if (properties.containsKey("threshold")) {
                this.threshold = Long.parseLong(properties.getProperty("threshold"));
            }
        }
    }
    

    技术亮点总结 📌:

    • 注解精确匹配:通过 @Intercepts 和 @Signature 声明拦截点,避免了硬编码判断,清晰且易维护。
    • 可配置阈值:利用 setProperties 接收外部参数,不改代码就能调灵敏度。
    • 轻量级监控:只做耗时统计和日志输出,不修改查询结果,不会污染业务数据。
    • 代理生成规范:Plugin.wrap(target, this) 是 MyBatis 官方推荐写法,自动生成动态代理,避免手写代理的复杂度和错误。

    👨‍💻 面试官:

    代码挺漂亮。那在实际开发这个插件的过程中,你觉得有哪些技术难点?又是怎么解决的?

    👦 我:

    确实有几个点需要小心处理,我一并整理下:

    技术难点 🤔 产生原因 解决方案 ✅
    拦截方法签名精确匹配难 Executor 等接口有多个重载方法,@Signature 中方法名或参数类型写错一个字母就不会生效 通过源码确认目标方法完整签名,拦截最基础的那个方法(如带 MappedStatement 的 query/update),避免覆盖不到或覆盖过度
    获取完整可读SQL不易 BoundSql.getSql() 拿到的是带 ? 的预编译SQL,参数值在 ParameterHandler 中,直接输出不直观 监控场景下打印预编译SQL+参数对象即可,若业务要求必须完整SQL,可通过 MetaObject 反射 ParameterHandler 取出参数替换 ?,但要注意性能开销和类型处理(如字符串加引号),建议用已有工具类(如 MyBatis 自带的 GenericTokenParser)或开启 debug 日志
    性能损耗控制 拦截器包裹了每次数据库操作,逻辑稍重就会拖慢整体业务 intercept 中只保留时间计算、条件判断和日志打印,绝不做 IO、网络调用等耗时操作;同时采用异步日志(如 logback 的 AsyncAppender)降低写日志阻塞
    多插件顺序引起的行为差异 多个插件按配置顺序层层代理,像分页插件和慢SQL监控插件如果顺序颠倒,可能导致耗时计算包含分页SQL改写过程 明确插件执行顺序,将基础监控类插件放在最外层(靠前配置),将改写SQL的业务插件放在内层;必要时在文档中约定顺序
    线程安全问题 插件实例是单例的,如果里面存储了有状态数据(如累积统计数据),多线程并发会出错 插件本身设计为无状态,若需统计,使用线程安全的 ConcurrentHashMap 或 AtomicLong,且做好清理策略,避免内存泄漏
    与Spring Boot集成的配置复杂性 早期 MyBatis-Spring 版本插件注册需要手动 Interceptor Bean,且 setProperties 不生效 目前新版 MyBatis-Spring-Boot-Starter 只要把插件类加上 @Component,并可通过 @ConfigurationProperties 或 XML 的 `` 注入参数,直接用 application.yml 自定义配置映射即可

    👦 我:

    另外补充一个小技巧,如果想在插件里拿到 Spring 容器中的 Bean(比如告警服务),可以让插件实现 ApplicationContextAware,或者用 @Component 注入,但要注意别把插件逻辑搞得太重,保持纯粹。

    👨‍💻 面试官:

    很好,难点拆解得清楚,方案也落地。这一块儿你掌握得挺深入了。下一个问题……


    如果本文对你有帮助,欢迎关注我的公众号【Rain的Java大神之路】。
    专注 Java 面试、源码、高并发实战,回复“Java”领取《大厂面试手册》,持续更新。

  • 相关阅读:
    函数的声明与作用域
    appium 2.5.1多进程自动化多台真机测试时候经常提示'exited with code 1'
    mysql新建用户
    基于Hadoop的电商用户行为分析系统设计与实现的系统架构设计
    java计算机毕业设计宠物销售网站MyBatis+系统+LW文档+源码+调试部署
    位运算的应用--->位图
    手把手教你语音识别(二)
    AGI热门方向:国内前五!AI智能体TARS-RPA-Agent落地,实在智能打造人手一个智能助理
    函数reshape(-1,)里的-1的意思
    物流查询 批量查询物流如何查看快递是否已签收
  • 原文地址:https://www.cnblogs.com/zrui-xyu/p/22976656