ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MyBatisPlus自定义插件实战:从源码到数据权限拦截器

MyBatisPlus自定义插件实战:从源码到数据权限拦截器 1. 内容整体设计与思路拆解1.1 为什么既要会用MyBatisPlus还要会写插件先聊点实在的。MyBatisPlus在国内Java项目里的普及率基本已经到了“是个Spring Boot项目就可能见到它”的地步。但大多数人对它的使用停留在BaseMapper提供的CRUD方法、LambdaQueryWrapper拼条件、IPage分页这“三板斧”上。一旦遇到数据权限隔离、敏感字段自动加密、大字段懒加载、多租户自动拼接条件这类需求很多人就开始在Service层硬编码或者在XML里复制粘贴一堆重复SQL时间长了代码腐烂得没法看。我个人的看法是MyBatisPlus真正的护城河不只是它封装好的那些方法而是它基于MyBatis拦截器机制开放的插件扩展能力。说白了InnerInterceptor这个东西就是MyBatisPlus留给开发者做“越狱”的后门。理解并掌握这层机制你会发现自己能以一种极优雅的方式在SQL执行的各个阶段插入自定义逻辑——不改业务代码、不碰XML、不污染原有查询语义。这篇东西不是给你念文档的而是从源码出发手把手带你写一个能落地的自定义插件。读完你会明白MyBatisPlus的插件到底挂在哪几个核心节点上拦截器链是怎么个执行顺序为什么你自己写的拦截器有时候不生效以及最重要的——怎么用几十行代码搞定数据权限场景。1.2 插件机制在整个MyBatis生态里的定位要理解MyBatisPlus插件得先把它放回MyBatis的语境里。MyBatis执行一条SQL要经过四大核心对象Executor负责调度、StatementHandler负责处理JDBC语句、ParameterHandler负责参数绑定、ResultSetHandler负责结果集映射。这四者的关系像一条流水线Executor发出指令StatementHandler构建并执行SQLParameterHandler往SQL里填参数ResultSetHandler把查回来的数据变成对象。MyBatis的插件机制本质上就是对这四个对象做动态代理。它允许你在这些对象的方法调用前后插入自定义逻辑甚至完全替换原有的行为。MyBatisPlus的InnerInterceptor就是对这种原生拦截做的封装——我个人的理解是MyBatisPlus把MyBatis的Interceptor接口再做了一次抽象向业务开发者屏蔽了代理逻辑的复杂细节让你专注于“在SQL执行前改点什么或在结果返回前改点什么”。所以结论很明确如果你想完整掌握自定义插件的写法核心还是要吃透MyBatis的Interceptor接口看懂MyBatisPlus是怎么在这个接口上做文章的。下面这段代码是MyBatis原生拦截器的灵魂也是所有关键逻辑的起点。public interface Interceptor { // 执行拦截逻辑的地方Invocation是代理方法调用的包装 Object intercept(Invocation invocation) throws Throwable; // 生成代理对象把当前拦截器织入目标对象 default Object plugin(Object target) { return Plugin.wrap(target, this); } // 读取配置文件里的参数一般不常用 default void setProperties(Properties properties) {} }顺着这个接口往下想Intercepts注解里声明了你要拦截哪个对象的哪个方法Plugin.wrap()负责生成代理intercept()方法里通过invocation.proceed()放行或改写。MyBatisPlus的所有内置功能——分页、乐观锁、逻辑删除、数据权限、多租户、防止全表更新删除——全部建立在这薄薄一层的机制之上。2. 源码核心插件链路与拦截时机的深入解读2.1 MyBatisPlus如何包装你的InnerInterceptor先看MyBatisPlus最核心的拦截器——MybatisPlusInterceptor。这个类实现了MyBatis的Interceptor接口里面维护了一个ListInnerInterceptor链表。我刚接触这块源码时最直观的感受是它本身就是一个“空壳调度器”本身不干具体业务只是把请求按顺序转发给内部注册的各个InnerInterceptor实现。public class MybatisPlusInterceptor implements Interceptor { // 内部维护的拦截器列表按添加顺序执行 private final ListInnerInterceptor interceptors new ArrayList(); Override public Object intercept(Invocation invocation) throws Throwable { // 从目标对象上取出当前执行链的MetaObject Object target invocation.getTarget(); MetaObject metaObject SystemMetaObject.forObject(target); // 从链首开始依次执行每个InnerInterceptor for (InnerInterceptor query : interceptors) { // 这里是关键如果当前执行对象是Executor // 就调用对应类型的拦截逻辑 if (target instanceof Executor) { query.beforeQuery(...); } } return invocation.proceed(); } }你可能注意到源码里细化了很多分支——beforeQuery、beforePrepare、beforeUpdate等等。InnerInterceptor接口里定义了一组带默认实现的方法你不需要全部重写按需覆盖就能精确卡在某个执行时机。这种设计其实是典型的“模板方法模式”父类定义流程骨架子类按需插入自己的扩展点。这里我最想强调的一点是MyBatisPlus的插件对Executor和StatementHandler做了差异化处理。分页、多租户这类需要改写SQL的插件会在beforeQuery阶段拿到BoundSql和MappedStatement直接改SQL文本和参数而像数据权限这种既要改SQL又要对结果集做二次处理的就可能在beforeQuery和Executor之后分别动手。2.2 拦截器链的执行顺序与责任链模式多个InnerInterceptor一起配置时执行顺序是严格按照interceptors列表的添加顺序来的。这一点很多教程没强调但在实战里特别容易踩坑。比如你把分页插件和数据权限插件配在一起数据权限需要改写WHERE条件、分页需要改SQL拼接LIMIT两者的执行时机先后就直接影响最终生成的SQL是否成立。我个人的建议是把所有需要改写SQL的拦截器放在分页插件之前。原因很简单——分页插件执行时会把原SQL包一层变成count查询如果数据权限改写发生在分页之后那么count语句可能统计的还是未经数据权限过滤的行数很容易出现“总数对不上”或者“越权看到不该看的数据”这类诡异问题。再往底层看Plugin.wrap()生成代理时有一个站点ProxyFactory。多个拦截器会层层包裹最后一个被包裹的对象在最外层最先被调用。当一个请求进来它会从最外层代理依次穿过各个拦截器到达真实对象。这意味着添加顺序决定了谁先处理请求但代理的生成顺序又是反过来的。理解了这一点你在调试“为啥我的拦截器没触发”时就能快速定位八成是代理没缠上或者缠上的顺序不对。2.3 从MappedStatement到BoundSqlSQL是怎么被拿到的要改写SQL首先得知道SQL在MyBatis里是怎么表达的。MappedStatement封装了一条SQL语句的元信息id、参数类型、结果映射关系、SQL来源注解或XML。而真正执行时MyBatis会结合运行时参数生成一个BoundSql对象里面包含了完整的SQL文本、参数映射和参数值集合。自定义插件最常见的套路就是在拦截器里拿到MappedStatement从它身上取BoundSql然后修改BoundSql的SQL字段。BoundSql提供了getSql()和setSql()方法虽然名字听起来像个普通POJO但你要注意一点BoundSql内部的sql字段没有final修饰可以随时替换。这为SQL改写留下了口子。但改完SQL后还有一件事要做——把新增的条件占位符和参数值塞回去。这里最坑的地方在于BoundSql里有一个additionalParameters集合泛型是MapString, Object专门用来放动态追加的参数。如果你新增了占位符参数必须通过additionalParameters.put()方法把它加进去否则ParameterHandler执行参数绑定时会报“Parameter xxx not found”的经典错误。这也是初级开发者写自定义插件时最常见的翻车点。3. 实战自定义SQL改写拦截器的完整过程实现3.1 场景定义与边界确认我挑一个能覆盖绝大多数核心知识点的场景来演示数据权限拦截器。先描述业务需求系统里有多家商户普通用户只能查到自己所属商户的数据管理员可以查全部。由于历史原因查询数据不能像正规项目一样在Service层统一加where条件——很多报表SQL、统计SQL是DBA在数据库客户端复制的没法逐条改造。问在哪里拦截最优雅答案是在SQL执行前统一改写给所有查询语句追加一个tenant_id ?的条件。这样无论是通过BaseMapper接口方法、LambdaQueryWrapper构造还是自定义XML写的查询全部自动生效业务层零改动。这个方案也呼应了之前检索信息里那条“mybatisplus selectcount为0”的问题——很多数据权限过滤没做好就是只拦了selectList忘了拦selectCount。3.2 继承InnerInterceptor先搭骨架再填细节我建议直接实现InnerInterceptor接口而不是继承PaginationInnerInterceptor。PaginationInnerInterceptor内部逻辑复杂、耦合多继承它需要处理的分支太多容易把自己绕晕。直接实现接口反而能更精准地控制代码边界。public class DataScopeInnerInterceptor implements InnerInterceptor { private final String tenantColumn; private final SupplierLong tenantIdSupplier; public DataScopeInnerInterceptor(String tenantColumn, SupplierLong tenantIdSupplier) { this.tenantColumn tenantColumn; // 从Spring上下文或ThreadLocal里取当前登录用户的租户ID this.tenantIdSupplier tenantIdSupplier; } Override public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) { // 改写SQL追加数据权限条件 String originalSql boundSql.getSql(); String targetSql originalSql; // 核心操作解析SQL的where结构追加 or 拼接条件 // 这里是简化处理实际可用JSqlParser解析WHERE并拼接 boolean hasWhere originalSql.toUpperCase().matches(.*\\sWHERE\\s.*); if (hasWhere) { targetSql originalSql AND tenantColumn ?; } else { // 这里需要判断是否存在GROUP BY、ORDER BY等实际上应借助SQL解析器 targetSql originalSql WHERE tenantColumn ?; } // 通过反射修改BoundSql的sql字段 ReflectUtil.setFieldValue(boundSql, sql, targetSql); // 追加参数值 boundSql.getAdditionalParameter().put(tenantId, tenantIdSupplier.get()); } }这里需要解释几个关键点。第一直接改BoundSql.sql是有效的因为BoundSql本身就是可变对象第二往SQL里加?占位符是安全的能规避SQL注入风险第三如果不想自己拼SQL建议引入JSqlParser对SQL进行真正的结构解析——在WHERE子树末尾插入等值表达式比纯字符串拼接稳健得多。MyBatisPlus本身就是用JSqlParser做分页和多租户的。3.3 核心难点复杂SQL条件下的WHERE拼接上面那个例子只处理了最简单的情况。真实业务里SQL五花八门有GROUP BY、ORDER BY、LIMIT、UNION、子查询、JOIN……如果你用“查一下有没有WHERE关键字再拼接”的思路迟早翻车。举个惨痛例子SELECT * FROM orders GROUP BY tenant_id这条SQL你要追加的是WHERE条件但“GROUP BY”前面才是WHERE应该出现的位置直接追加在末尾就变成了GROUP BY tenant_id WHERE tenant_id ?直接语法错误。解决思路有两条。低端做法是正则或字符串截取只适合非常受控的场景推荐做法是用MyBatisPlus自己用的CCJSqlParserUtil来解析SQL然后操作AST抽象语法树。参考代码如下import net.sf.jsqlparser.parser.CCJSqlParserUtil; import net.sf.jsqlparser.statement.select.PlainSelect; import net.sf.jsqlparser.statement.select.Select; PlainSelect plainSelect (PlainSelect) CCJSqlParserUtil.parse(sql); // 构造 where 等值表达式 EqualsTo equalsTo new EqualsTo( new Column(tenantColumn), new LongValue(tenantIdSupplier.get()) ); if (plainSelect.getWhere() null) { plainSelect.setWhere(equalsTo); } else { plainSelect.setWhere(new AndExpression(plainSelect.getWhere(), equalsTo)); } // 重新生成SQL String targetSql plainSelect.toString();这个方案能正确处理大多数单表SELECT和部分JOIN场景。如果你要支持子查询、UNION等更复杂的结构需要递归遍历Select树在每一层PlainSelect上做同样的操作。我在这块踩过不少坑建议初版先限定“只改写单表或简单JOIN”跑通后再往复杂场景扩展。安全优先别一上来就搞满配。3.4 注册拦截器并验证拦截链路写好拦截器不注册等于白写。在Spring Boot里要做的是把它加进MybatisPlusInterceptor这个“调度中心”。我这里特别提醒一定要在拦截器链里把数据权限拦截器放在分页插件之前具体原因前面已经讲过了。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 先加数据权限让SQL在分页前已经被改写 interceptor.addInnerInterceptor(new DataScopeInnerInterceptor( tenant_id, () - SecurityContextHolder.getUser().getTenantId() )); // 再加分页插件 interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }注册完成之后测试流程要覆盖这些场景通过baseMapper.selectList查询、通过IService.page()分页查询、通过XML自定义查询、通过selectCount查询总数。尤其是分页查询你要特别注意count语句是否也带了数据权限条件。如果发现count语句没过滤多半是拦截器执行时机晚于分页插件调整顺序再试。4. 常见问题与排查技巧实录4.1 拦截器不生效第一反应应该查什么“自定义拦截器不生效”是大家问得最多的问题我把它排在最前面说。排查路径其实不算多按这个顺序查大概率能定位异常现象排查方向处理方式拦截器整体没被调用MybatisPlusInterceptor是否注入Spring容器确认配置类被扫到Bean确实创建拦截器被调用了但SQL没变拦截的目标方法签名是否匹配检查Intercepts注解和Signature的type、method、args是否与源码一致count查询或子查询没被过滤你只处理了PlainSelect没处理其他结构对子查询、UNION也递归处理修改了BoundSql.sql但执行报错additionalParameters没同步追加参数用additionalParameters.put()注册新增占位符参数provider生效但值不对tenantIdSupplier取到的可能是空值检查当前登录上下文的取值时机4.2 分页与自定义拦截器并存的经典冲突分页失效这个问题在热词里出现了“mybatisplus分页失效”和自定义拦截器结合时尤其常见。核心原因是只要PaginationInnerInterceptor在你的自定义拦截器之前执行它就会先把SQL改写成带LIMIT和OFFSET的形态这时你的拦截器再基于改写后的SQL做数据权限拼接就很容易出现语法错误——尤其当原始SQL尾部已经有LIMIT时你盲目在末尾追加条件会直接崩掉。还有一个隐蔽的坑分页插件会生成count语句这个count语句和普通查询走的拦截器入口是一样的。如果你的自定义拦截器没有对count语句做同样的条件追加就会出现“列表数据是过滤后的但总条数来自未过滤的计数”表现为前端分页总条数永远比实际可看的数据多。这个坑非常阴排查时建议在日志里把PaginationInnerInterceptor生成的count SQL和你的数据权限SQL分别打出来对比。我处理这类问题的方式是统一在SQL改写之后立刻打印完整SQL和绑定参数定位到具体是哪一条SQL出了问题。这一步能节省大量排查时间。4.3 BoundSql反射修改与参数追加的细节陷阱BoundSql的sql字段是私有的规范做法是通过ReflectUtil或者Field反射来改。这里有几个细节值得注意第一sql字段没有final但直接反射改私有字段在某些JDK版本下会有模块访问限制比如JDK17需要打开--add-opens或使用ReflectUtil这类封装工具第二改完sql字段后记得调用boundSql.getAdditionalParameter()把新增占位符对应的参数值放进去否则参数处理器找不到值第三如果你的SQL动态追加了多个参数参数顺序必须与SQL里占位符的先后顺序一致否则会出现错位导致的离谱结果。从经验上讲尽量把参数值直接拼进SQL字符串只在绝对受控的内部系统里可行但凡有用户输入参与构造的SQL一律用占位符方案。虽然多两步反射操作但安全性完全不是一个级别。4.4 基于JSqlParser解析时的常见坑JSqlParser本身也有一堆细节版本不同API差异挺大。MyBatisPlus 3.5.x内置的jsqlparser版本是4.x解析GROUP BY、HAVING、ORDER BY、LIMIT时返回的AST节点类型都有讲究。一个常见错误是直接把解析结果强转成PlainSelect遇到UNION或括号包裹的SELECT就会抛ClassCastException。更稳妥的方式是用SelectVisitor或Select的accept方法做分发处理。另外要特别当心CCJSqlParserUtil.parse()解析出的PlainSelect对象调用toString()时生成的SQL并不保证和原SQL一模一样——格式上可能有细微变化比如别名带不带AS、空格差异。这种差异通常不影响执行但如果项目里有用字符串匹配做SQL断言的单测很容易在重构后失败。5. 扩展场景与经验总结5.1 更多可复用的自定义插件设计模式数据权限只是自定义插件玩法的冰山一角。下面这几个场景我都实际写代码验证过可以给你作为后续扩展的参考方向字段自动加密/解密拦截器在插入和更新前把敏感字段加密在查询后解密。关键点是加密发生在ParameterHandler阶段、解密发生在ResultSetHandler阶段。和MyBatisPlus自带字段加密方案的区别在于你可以完全控制加密范围不依赖注解。多租户自动隔离MyBatisPlus自带了TenantLineInnerInterceptor但如果你需要的是“同一张表通过不同字段区分租户”就得自己写。思路和数据权限拦截器几乎一样。SQL防全表更新/删除在beforeUpdate里检查BoundSql的SQL是否带WHERE条件没有就直接抛异常。这个拦截器我在生产上用过拯救了不少“手滑误删全表”的事故。自定义主键生成策略在插入前根据业务规则生成主键并塞回实体。虽然MyBatisPlus的IdType已经支持雪花ID但某些老系统可能要求特定格式的业务主键这时自定义拦截器是最干净的解法。5.2 源码阅读路线与学习建议如果你想更系统地掌握这块我建议按下面的顺序读源码步骤源码类重点观察1Interceptor三个方法的职责边界2Pluginwrap()如何生成代理、invoke()如何匹配方法签名3MybatisPlusInterceptor内部如何调度InnerInterceptor列表4PaginationInnerInterceptorbeforeQuery怎么改写SQL、count SQL怎么生成5BoundSqlSQL字段、additionalParameters、parameterMappings的结构读源码切忌从头到尾一行不漏地读要有目的性地追问“某个功能到底是在哪个方法里被改写的”然后把断点打在那几个核心入口上。在MybatisPlusInterceptor.intercept()这里打一个条件断点每次查询都能直接看到执行链上的每个环节比死记代码高效得多。5.3 关于稳定性与性能的几点心得最后分享几个我在生产环境沉淀下来的经验。第一自定义拦截器里尽量不要做重逻辑比如正则匹配大SQL、在线解析超长SQL都是性能隐患——我见过一个SQL解析拦截器把慢查询又拖慢了一倍。解决办法是加缓存以SQL文本哈希为key缓存解析后的SQL AST对象或者只对必要的表名做拦截。第二多拦截器共存的顺序问题不是“配一次就完事”每次在拦截器链里增删组件都要回归测试一遍分页、统计、多租户这几条链路。我自己就经历过一次添加了防全表更新拦截器后乐观锁的更新操作莫名其妙失败查了大半天才发现是拦截器顺序把OptimisticLockerInnerInterceptor该处理的版本号字段给改掉了。第三线上环境记得给MybatisPlusInterceptor打日志至少在debug级别输出“执行了哪个插件、耗时多少”。不是所有问题都能靠日志解决但很多诡异问题没日志根本无从下手。我通常的做法是在每个InnerInterceptor的入口和出口各记一行耗时超过500毫秒就告警这样能第一时间发现SQL解析逻辑是否失控。自定义插件这块说穿了就是一个“改SQL的艺术”。你越尊重底层机制、越克制地在合适的点位做最小改动这套方案就越稳定。对开发者来说看懂Interceptor背后的代理与责任链相当于掌握了MyBatis家族的核心钥匙——往后不管遇到分页失效还是SQL改写需求你都不会再对着报错日志发怵了。
返回列表