资讯动态

MyBatis插件机制深度剖析:从动态代理到SQL改写与加解密实战

发布时间:2026/10/13 23:58:38 来源:尧图企业网站定制
1. 插件机制的本质不是魔法是接口的排列组合我最早接触Mybatis插件的时候把文档翻来覆去看了好几遍脑子里一直是糊的。原因很简单网上大部分教程上来就丢一个拦截器的代码然后告诉你“这样就能自动分页了”但没人真正讲清楚插件为什么能拦得住SQL、又是靠什么拦的。等你把原理吃透了再回头看会发现这东西其实一点都不玄乎——核心就两样东西JDK动态代理和责任链模式。Mybatis允许你拦截的只有四大核心对象Executor、StatementHandler、ParameterHandler、ResultSetHandler。这四个对象分别负责什么直接决定了你的插件能做什么事Executor执行器的顶层入口负责调用JDBC的增删改查所有SQL的执行都会经过这里一级缓存的管理也在这一层。StatementHandler负责准备Statement、绑定参数、执行SQL是SQL真正落地的地方。ParameterHandler专门负责预编译参数的处理比如把#{id}替换成?之后的赋值逻辑。ResultSetHandler把数据库返回的ResultSet映射成Java对象的地方所有结果集处理都归它管。这四个对象有一个共同点它们都是接口而且Mybatis在运行时会通过Proxy为它们创建代理对象。插件机制的本质就是在你拿到的这个代理对象外面再包一层代理。每加一个插件就多包一层。调用的时候一层层往里走最终才到达真实的对象。先记住这个结论插件的底层实现是“代理套代理”而多个插件之间的执行顺序取决于你在mybatis-config.xml里配置的先后顺序。这个顺序问题后面实战部分会再踩一次。2. 插件运行原理深度拆解拦截、包装、放行的完整链路2.1 三个核心接口负责声明、执行、放行自定义一个插件只需要实现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是核心逻辑你要干的活都写在这里plugin默认实现会调用Plugin.wrap生成代理对象setProperties用来接收配置文件里传进来的参数。再配合Intercepts和Signature两个注解声明你要拦截谁、拦哪个方法、参数列表长什么样Intercepts({ Signature( type Executor.class, method query, args {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class} ) }) public class MyPlugin implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { // 你的逻辑 return invocation.proceed(); } }这里有一个特别容易绕晕的点为什么Signature里连方法参数都要写死因为Java的反射在匹配方法的时候光靠方法名是不够的——重载方法太多了必须用“方法名参数列表”才能唯一定位。比如Executor接口里query方法有四个重载版本你不把参数写清楚Mybatis根本不知道你要拦的是哪一个。2.2 代理生成的核心Plugin.wrap做了什么Plugin.wrap是插件机制的心脏它的逻辑其实很朴素把你传进来的目标对象和当前插件做个匹配如果目标对象的接口里有你在Signature里声明的那几个方法就生成一个代理对象如果没有匹配上就原样返回。public static Object wrap(Object target, Interceptor interceptor) { // 从注解里取出要拦截的接口列表和方法签名 MapClass?, SetMethod signatureMap getSignatureMap(interceptor.getClass()); Class? targetClass target.getClass(); // 只处理拦截范围内的接口其他的一律跳过 SetClass? interfaces findAllInterfaces(signatureMap, targetClass); if (interfaces null || interfaces.size() 0) { return target; } // 返回一个JDK动态代理 return Proxy.newProxyInstance( targetClass.getClassLoader(), interfaces.toArray(new Class?[interfaces.size()]), new Plugin(target, interceptor, signatureMap) ); }注意看wrap返回的可能是原始对象也可能是代理对象。也就是说如果你的插件声明要拦截ResultSetHandler那Mybatis只会给创建出来的ResultSetHandler实例包一层代理StatementHandler那边一概不动。这样设计的好处是性能损耗最小化不会没拦到的地方也白白多走一遍代理逻辑。2.3 代理对象调用时发生了什么Invocation的使命当代理对象的方法被调用时实际上进入的是Plugin类的invoke方法public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { try { SetMethod methods signatureMap.get(method.getDeclaringClass()); if (methods ! null methods.contains(method)) { // 命中拦截方法走插件的核心逻辑 return interceptor.intercept(new Invocation(target, method, args)); } // 没命中直接放行 return method.invoke(target, args); } catch (Exception e) { throw unwrapThrowable(e); } }关键点就一个命中的时候并不是直接调invocation.proceed()而是把你的插件逻辑包在中间执行。Invocation这个对象持有三个东西——目标对象、当前方法、入参数组proceed()就是一句public Object proceed() throws InvocationTargetException, IllegalAccessException { return method.invoke(target, args); }你可以把整条链路想象成洋葱结构最外层是最后一个配置的插件最里层是真实的目标对象。调用的时候一层层往里剥每一层都有机会在proceed()前后插入逻辑。2.4 四大核心对象和插件场景的匹配关系搞清楚每个对象负责什么选型几乎不用过脑核心对象核心方法适合干的活Executorquery / update / commit / rollbackSQL执行监控、全局慢SQL统计、事务边界埋点StatementHandlerprepare / parameterize / batch / update / query改写SQL分页、数据权限、参数优化ParameterHandlersetParameters敏感字段加密写库前自动加密ResultSetHandlerhandleResultSets / handleOutputParameters敏感字段解密读库后自动解密、结果集脱敏举个例子就能明白了如果你想做的是“SQL执行完自动打印耗时”拦Executor最合适因为所有操作都会从这里路过但如果你想做的是“查询列表的时候自动把某些列加密”那拦ResultSetHandler才是正确的位置因为只有这里能拿到最原始的结果集。3. 实战案例一SQL执行监控与慢SQL告警3.1 需求场景与设计思路很多团队会引入专门的监控组件来收集SQL耗时但如果你只想轻量地给某个老项目加一个“超过500ms的SQL自动打印告警日志”的能力再引一套重型组件就有点杀鸡用牛刀了。这种场景直接用Mybatis插件实现成本极低而且能拿到第一手的原始SQL和参数信息。拦截对象选Executor因为无论是select、insert、update、delete最后都会汇入Executor.query()和Executor.update()两个方法。在这两个方法的前后夹上计时逻辑就是最简单有效的方案。3.2 实现代码拦截Executor的query和updateIntercepts({ 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 static final Logger log LoggerFactory.getLogger(SlowSqlInterceptor.class); private static final long SLOW_THRESHOLD_MS 500L; Override public Object intercept(Invocation invocation) throws Throwable { long startTime System.currentTimeMillis(); try { return invocation.proceed(); } finally { long costTime System.currentTimeMillis() - startTime; if (costTime SLOW_THRESHOLD_MS) { MappedStatement ms (MappedStatement) invocation.getArgs()[0]; Object parameter invocation.getArgs()[1]; String sql ms.getBoundSql(parameter).getSql(); log.warn(慢SQL告警耗时[{}]msSQL[{}], costTime, sql.replaceAll(\\s, )); } } } Override public Object plugin(Object target) { return Plugin.wrap(target, this); } Override public void setProperties(Properties properties) { // 可以从配置里动态读取阈值 } }这段代码的核心逻辑在finally里不管SQL执行成功还是抛出异常耗时的统计都会执行。invocation.proceed()执行完之后从getArgs()[0]里取出MappedStatement再用getBoundSql(parameter)拿到最终绑定的SQL文本。这里有个细节值得注意MappedStatement是每个Mapper方法编译后的产物里面存着原始SQL模板。但是模板里的占位符不是真实的参数值如果直接把ms.getBoundSql(parameter).getSql()打印出来看到的还是带?的预编译语句。所以打印日志的时候我习惯把SQL里的连续空格压缩一下要不然控制台输出会被换行和缩进灌满看起来非常费劲。3.3 参数绑定如何拿到真实的参数值打印SQL模板容易拿到真实参数值却不简单。因为预编译的?里的值是存在BoundSql的additionalParameters或者parameterMappings里的。常用的做法是写一个工具方法把参数映射和真实值对拍起来private String formatSql(BoundSql boundSql, Object parameterObject) { if (parameterObject null) { return boundSql.getSql(); } ListParameterMapping parameterMappings boundSql.getParameterMappings(); String sql boundSql.getSql(); TypeHandlerRegistry typeHandlerRegistry configuration.getTypeHandlerRegistry(); for (ParameterMapping parameterMapping : parameterMappings) { String propertyName parameterMapping.getProperty(); Object value null; if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) { // 单个参数直接取值 value parameterObject; } else { // 通过反射从对象里取属性值 MetaObject metaObject configuration.newMetaObject(parameterObject); value metaObject.getValue(propertyName); } sql sql.replaceFirst(\\?, value ); } return sql; }这个工具方法在日志监控、慢SQL排查、问题复现场景中非常实用。你可以在本地跑一下把Mapper方法的入参对象传进去打印出来的就是一条可以直接在数据库客户端里执行的完整SQL。3.4 这个方案的优势与局限优势很明显一行配置就能全局生效侵入性为零不需要改动任何Mapper接口和XML。局限也真实存在System.currentTimeMillis()在高并发场景下会存在时间源偏移的问题如果你要统计微秒级别的耗时建议换成System.nanoTime()另外如果你想做更精细的慢SQL统计分析比如按接口维度的聚合统计那还是要交给专业的APM工具来做。4. 实战案例二数据权限过滤的SQL改写4.1 为什么会需要SQL改写插件数据权限是一个很常见的需求同一个列表接口管理员能看到所有数据普通员工只能看到本部门的数据外包人员只能看到自己名下负责的数据。很多项目最初的实现方式是在业务代码里手动拼接条件比如WHERE后面根据当前登录人的角色动态加一段and dept_id 1001。这种做法虽然简单但是会带来两个问题一是业务代码里到处充斥着权限判断逻辑维护成本直线上升二是不同开发者写出来的拼接风格五花八门有的人把条件写死在XML里有的人在Service里用QueryWrapper拼接时间一长根本没法统一管理。用Mybatis插件做数据权限过滤思路就完全不同权限规则集中在一个拦截器里维护业务层的代码完全无感知SQL执行前自动拼接上权限条件。这不仅统一了权限控制逻辑而且切换权限规则只需要改插件不需要动任何Mapper。4.2 实现思路拦截Executor的query方法改写BoundSql在动手之前必须先想清楚一个问题为什么要拦Executor而不是StatementHandler这俩都能拿到BoundSql但时机完全不同。Executor.query执行的时候BoundSql已经创建好了但还没有传给StatementHandler去执行而StatementHandler的阶段SQL已经进入执行流程再去改BoundSql会更容易出问题。所以改写SQL的理想位置是Executor层。核心逻辑分三步Intercepts({ Signature( type Executor.class, method query, args {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class} ) }) public class DataScopeInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { MappedStatement mappedStatement (MappedStatement) invocation.getArgs()[0]; Object parameter invocation.getArgs()[1]; BoundSql boundSql mappedStatement.getBoundSql(parameter); String originalSql boundSql.getSql(); // 1. 根据当前登录人拼接权限条件 String scopeSql appendScopeCondition(originalSql); // 2. 通过反射替换BoundSql里的sql字段 ReflectUtil.setFieldValue(boundSql, sql, scopeSql); // 3. 放行 return invocation.proceed(); } }第2步的反射替换是整个方案最核心也最容易出错的点。BoundSql里有三个字段sql、parameterMappings、parameterObject。理论上你可以直接在constructor里新建一个BoundSql但新建的成本更高反射直接改字段反而更简单。4.3 拼接条件时容易踩的坑表别名和数据源类型假设原始SQL是select * from order where status 1你要拼接的权限条件是“只看当前用户创建的订单”那最终SQL应该变成select * from order where status 1 and creator_id 10001。这里有一个隐藏的坑如果原始SQL里有表别名比如select * from o where o.status 1那拼接条件必须写成and o.creator_id 10001否则数据库会报“字段不明确”的错误。更麻烦的是多表关联查询一个表有creator_id列另一个表也有creator_id列你必须先解析出条件所属的表名。这个解析过程就别想着用正则硬干了容易被各种嵌套查询搞崩建议直接用JSqlParser这种SQL解析库把select语句解析成语法树再往where节点上追加条件。public class SqlParserUtil { public static String appendScopeCondition(String originalSql, String condition) { try { Select select (Select) CCJSqlParserUtil.parse(originalSql); PlainSelect plainSelect (PlainSelect) select.getSelectBody(); if (plainSelect.getWhere() null) { plainSelect.setWhere(new HexValue(condition)); } else { plainSelect.setWhere(new AndExpression(plainSelect.getWhere(), new HexValue(condition))); } return plainSelect.toString(); } catch (JSQLParserException e) { // 解析失败时降级为字符串拼接 return originalSql and condition; } } }用JSqlParser的好处是遇到复杂SQL也能正确处理条件优先级坏处是引入了额外依赖而且每条SQL都要走一遍解析会有微小性能损耗。我的建议是只对有大表、复杂查询的Mapper方法启用权限拦截其他方法通过Signature的MappedStatement的ID做白名单过滤能省则省。4.4 配置方式与统一管理配置的时候可以给DataScopeInterceptor加一个setProperties从mybatis-config.xml里读白名单plugins plugin interceptorcom.example.DataScopeInterceptor property nameincludeMappers valuecom.example.mapper.OrderMapper,com.example.mapper.UserMapper/ property nameexcludeMappers valuecom.example.mapper.ConfigMapper/ /plugin /plugins这个方案在实际项目中用得很稳我自己的体会是做数据权限插件最难的不是写拦截器而是想清楚权限规则的归属。规则到底放在数据库里配置还是写在代码里是按角色划分还是按数据范围划分这个没定好插件写得再漂亮也是白搭。5. 实战案例三敏感字段自动加解密5.1 一个老项目里的真实痛点我之前参与维护过一个老系统业务表里存着用户的手机号和身份证号明文存储。等安全审计发现问题的时候系统已经跑了五年几百个Mapper方法都在直接操作这几张表。如果要求业务开发手动在SQL里加AES_ENCRYPT之类的函数改动面太大而且散落在各处下次再漏掉几个地方又要重新审计。用Mybatis插件做自动加解密思路就清晰得多写库的时候拦ParameterHandler把参数里指定的几个字段加密之后塞回去读库的时候拦ResultSetHandler把结果集里这几个字段解密之后返回。业务代码完全不用知道加解密的存在安全审计的时候只需要确认拦截器的白名单配置正确就行。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 parameterHandler (ParameterHandler) invocation.getTarget(); // 拿到当前参数对象 Object parameterObject parameterHandler.getParameterObject(); if (parameterObject ! null) { encryptFields(parameterObject); } return invocation.proceed(); } }关键就在于invocation.getTarget()拿到的是真实的ParameterHandler然后调用它暴露的getParameterObject()方法获取参数对象再通过反射遍历白名单字段找到就加密替换。注意一点必须在调用proceed()之前完成加密因为proceed()真正执行的时候会去调用PreparedStatement.setXxx()把参数值写进去。5.3 实现代码读取时解密读库的解密逻辑稍微复杂一点因为结果集的映射方式有两种一种是返回值是List实体类Mybatis会逐行映射另一种是返回值是Map。不管哪种入口都是handleResultSetsIntercepts({ Signature( type ResultSetHandler.class, method handleResultSets, args {Statement.class} ) }) public class DecryptInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { Object result invocation.proceed(); if (result instanceof List?) { for (Object item : (List?) result) { decryptFields(item); } } else if (result instanceof Map) { decryptFields(result); } return result; } }这里有个性能细节不管查询结果有多少条数据回调逻辑都会逐条反射遍历。对于大结果集的查询反射开销是实打实的。所以白名单字段列表一定要在启动的时候构建好反射缓存不要在每条记录上都重新调用getDeclaredField。实测下来走了反射缓存之后加解密对查询耗时的损耗能控制在2%以内。5.4 加解密方案对比应用层加密 vs 数据库函数加密想直接在SQL里用AES_DECRYPT这种数据库函数的思路看起来很省事实际上问题更多一是数据库函数在不同版本、不同数据库之间不通用一旦做数据库迁移全SQL要重写二是数据库函数的密钥管理在数据库层安全审计容易产生争议三是最重要的——数据库函数加密后SQL里WHERE phone xxx这种等值查询直接失效因为数据库里存的是密文。应用层加密配合插件实现等值查询只需要先对入参做一次加密再传入查询条件。不过这又引出一个新问题如果两个加密方案用的算法不同或者同一个字段既密文查询又模糊查询就很麻烦。我的建议是手机号、身份证这类字段尽量避免模糊查询真要模糊查询就单独建映射表或者用专门的搜索引擎。6. 多插件共存时的执行顺序与协作机制6.1 多个插件配置的前后顺序决定了什么开发到后期一个项目里往往不止一个插件比如既要SQL监控又要数据权限过滤还要字段加解密。此时mybatis-config.xml里插件的排列顺序就至关重要了。plugins plugin interceptorcom.example.SlowSqlInterceptor/ plugin interceptorcom.example.DataScopeInterceptor/ plugin interceptorcom.example.EncryptInterceptor/ /plugins根据前面讲的代理包装逻辑后配置的插件会包在更外层。也就是说调用链的入口是最外层的插件出口也是它。用上面的配置举例子EncryptInterceptor在最外层它会先拿到原始请求在调用proceed()时触发DataScopeInterceptorDataScopeInterceptor改写SQL后调用proceed()触发SlowSqlInterceptorSlowSqlInterceptor计时并调用proceed()最终进入真实的Executor执行SQL。所以耗时计算如果在最外层插件里做那它统计的时间其实包含了内层插件的处理时间。这一点在慢SQL监控里会造成误差——你以为SQL本身跑了500ms实际上可能只是数据权限插件和加解密插件多耗了300ms。6.2 协作模式插件之间如何传递数据多个插件协作的场景里一个常见的需求是DataScopeInterceptor改写了SQLSlowSqlInterceptor打印日志的时候希望能拿到改写后的完整SQL。但这种传递并不是开箱即用的因为每个插件的intercept方法拿到的Invocation对象相互独立数据传递有两个常用方案方案一把共享数据塞到MappedStatement里不现实因为它是全局共享的很容易被并发场景下的多线程写坏。方案二使用ThreadLocal做线程内的上下文传递进入拦截器时设置离开时清理。第二种方案更靠谱但一定要在finally里清掉ThreadLocal否则线程池复用会导致数据串到下一个请求。6.3 插件顺序设计的最佳实践我自己的习惯是把耗时统计类插件放在最内层因为它希望统计的是“纯SQL执行时间”把SQL改写类插件放中间把字段处理类插件放最外层。这样既不会互相干扰又能保证日志反映的是真实数据库性能。7. 高频踩坑记录插件不生效、报错、性能问题排查7.1 插件不生效全程静默无日志最常见的插件失效原因就是Signature里的方法签名和真实接口不一致。比如你想拦截Executor.query但方法名写成了select或者参数列表写成了{MappedStatement.class, Object.class}少了一个RowBounds——这种情况下Plugin.wrap在匹配签名的时候根本找不到对应方法就默认不生成代理整个插件静默失效。排查方法很简单在Plugin.wrap的findAllInterfaces处设置断点或者临时在插件intercept方法里加一行日志看看到底有没有被调用。如果确定被调用了但逻辑没生效再从Signature的参数细节上检查。7.2 多个版本环境兼容问题MyBatis 3.x版本下的SQL变更Mybatis的源码在不同3.x版本里有过一些细节调整比如BoundSql字段的访问方式、MappedStatement里某些方法的变动。有些旧插件代码在3.4.x上跑得好好的升级到3.5.x就出现反射失效或者方法签名对不上的情况。我自己遇到过PropertyTokenizer相关的解析问题在新版本里需要多传一个参数。应对策略是升级Mybatis版本之前先把插件做一轮回归测试尤其是涉及到反射访问内部字段的插件很容易被版本变动拉爆。7.3 SQL改写后参数映射错位在DataScopeInterceptor里改写了SQL后如果你直接通过反射把sql字段替换掉没有同步修改parameterMappings就很容易出现参数错位问题。想象一下原始SQL有where a ? and b ?你追加了一个and c ?但parameterMappings列表里只有两个映射执行时第三个?就会因为没有对应的值而报错。解决方案是同时在parameterMappings里追加一条新的映射并且把对应的参数值也存到additionalParameters里。这个操作比较繁琐建议封装成一个工具方法每次改SQL都走同一条路径。7.4 插件里调用proceed()写错位置很多新手写拦截器的时候会把业务逻辑放在proceed()之后看起来像这样public Object intercept(Invocation invocation) throws Throwable { Object result invocation.proceed(); // 这里的逻辑其实是在SQL执行完之后跑的 encrypt(result); return result; }如果目的是“写库前加密”这段代码就是错的——因为proceed()执行的时候加密还没有发生参数已经以明文形式写进数据库了。所以一定要想清楚要在proceed()之前做的放在前面要在proceed()之后做的放在后面。这个类比就像做饭先切菜再炒菜顺序不能反过来。7.5 性能损耗控制Mybatis插件在带来极大灵活性的同时也会带来额外的反射调用和代理链开销。对于一个每秒几千次查询的系统这个开销虽然占比不大但也不能忽视。我的建议是能用一次反射解决的事情不要在循环里重复反射能在启动时初始化的东西不要放到运行期间反复构建。具体到实现层面可以做两层优化第一层插件内部把Method对象缓存到Map里避免每次调用getDeclaredMethod第二层对于结果集加解密这类高频率操作使用ReflectASM或者MethodHandle替代标准反射实测能再压缩一半耗时。8. 插件注册方式与框架集成细节8.1 三种注册方式什么时候用哪种Mybatis插件注册有三种途径适用场景完全不同全局注册在mybatis-config.xml里配置plugin标签对所有SqlSessionFactory生效。适合做公共能力比如耗时监控、SQL日志。单工厂注册在代码里通过Configuration.addInterceptor()动态注册。适合不同SqlSessionFactory需要不同插件的场景比如读写分离的架构里写库不需要解密插件读库需要。Spring Boot场景下的注册如果项目用的是Mybatis-Spring-Boot-Starter直接声明一个Bean返回Interceptor实现类框架会自动注册到全局配置。Bean public Interceptor slowSqlInterceptor() { return new SlowSqlInterceptor(); }8.2 插件与分页插件的兼容性如果你用了业界常用的分页插件再注册自己的插件就要特别小心互相覆盖的问题。分页插件本质上也是Mybatis插件它会在Executor#query里把SQL改写成带LIMIT的形式。如果你的数据权限插件在它之前执行你拿到手的是原始SQL如果在它之后执行你拿到的就是已经带分页的SQL。处理这种问题常规的做法是将自己的数据权限插件顺序放在分页插件的外层确保先拼权限条件再走分页改写。如果你不确定自己项目的插件顺序可以通过在intercept方法里打印invocation.getTarget()的类名来判断当前代理嵌套的层次。8.3 插件调试技巧临时打开SQL日志开关开发阶段调试插件逻辑最直接的办法是把Mybatis的SQL日志级别调到DEBUG。这样你在控制台能看到每条SQL的执行文本插件有没有改写SQL一目了然logging: level: com.example.mapper: debug不过要注意开了DEBUG日志之后SQL文本和参数值都会打印到日志文件里如果是生产环境安全要求高务必关闭或者脱敏。9. 插件扩展思路从拦截SQL到拦截更多9.1 自定义类型处理器和插件互补的另一种手段很多人容易混淆Mybatis插件和TypeHandler的职责边界。严格来说TypeHandler负责Java类型和JDBC类型之间的转换是比插件更底层的机制。以加密为例你完全可以写一个自定义TypeHandler在setParameter和getResult里加解密。它和插件方案的区别在于TypeHandler需要显式指定在哪个字段上使用插件可以全局统一拦截自动识别白名单字段。我的实际经验是字段量少、规则简单优先TypeHandler字段量多、需要动态识别、不想改动Mapper配置优先插件方案。两者不冲突甚至可以嵌套使用——插件负责全局路由TypeHandler负责具体加密算法。9.2 插件也可以做读写分离的路由除了前面讲的监控、权限、加解密插件还有一个经典用途读写分离。我在一个低并发但要求读性能尽量高的项目里实践过思路是拦截Executor.query方法获取当前SQL的MappedStatement的SqlCommandType——这个方法会告诉你当前是SELECT还是INSERT、UPDATE、DELETE再结合当前线程上下文里的事务状态决定把数据源切换到主库还是从库。这个方案比自己封装DataSource要轻量得多但要特别注意事务边界如果事务里先有写操作又执行了读操作读操作必须仍走主库否则会读到旧数据。判断逻辑可以这样写读取当前事务是否活跃活跃则一律走主库。9.3 插件是不是万能的边界在哪里插件能做的事很多但也不是没有限制。它的限制恰恰来自“只能拦截四大接口”这个设计比如你在Cache对象上想做缓存策略的定制Mybatis插件就无能为力了。另一个限制是插件拦截的是SQL读写链路对Mybatis执行之外的逻辑比如Spring事务管理器、连接池监控没有切入点这些还是得靠Spring AOP或者专门的监控组件。所以选不选择插件方案我的判断标准就两条一是需求是否落在四大对象的职责范围内二是无侵入和全局统一是否比局部手写更优先。10. 从原理到实践的建议这个插件机制我有段时间用得特别频繁总结下来最省心的做法就是先想清楚你要动的是SQL层面、参数层面还是结果集层面再反向选择拦截对象。SQL改写找Executor参数拦截找ParameterHandler结果处理找ResultSetHandler定位准确了后面写代码基本不会跑偏。如果非要给一条实用建议的话我会说给所有自定义插件都封装一层异常兜底。因为插件一旦抛出异常整个SQL执行就会中断影响面比业务代码出错大得多。加解密失败、SQL解析失败、反射字段不存在都不应该成为整个请求失败的元凶。我在生产上就遇到过一次权限规则里配置的字段名在某个实体类里不存在反射直接抛了NoSuchFieldException整个查询接口当场挂掉。后来把异常逻辑改成解析失败就跳过权限过滤并在日志里告警接口的可用性才稳下来。插件写多了之后你还会发现一个有意思的现象它其实在逼着你更深入地理解Mybatis的执行模型。以前我用这个框架只停留在写Mapper.xml的阶段对SQL是怎么从接口方法走到数据库的完全不关心。等自己手写过这几个拦截器SqlSession、Executor、BoundSql这些概念才真正串成了一条链路。这也是为什么我建议刚接触Mybatis的开发者哪怕是写个最简单的耗时监控插件也值得自己动手试一遍——原理性的东西亲手拆一次比看十遍文档管用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑