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) { // 从注解里取出要拦截的接口列表和方法签名 Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor.getClass()); Class<?> targetClass = target.getClass(); // 只处理拦截范围内的接口,其他的一律跳过 Set<Class<?>> 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 { 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); } catch (Exception e) { throw unwrapThrowable(e); } }关键点就一个:命中的时候,并不是直接调invocation.proceed(),而是把你的插件逻辑包在中间执行。Invocation这个对象持有三个东西——目标对象、当前方法、入参数组,proceed()就是一句:
public Object proceed() throws InvocationTargetException, IllegalAccessException { return method.invoke(target, args); }你可以把整条链路想象成洋葱结构:最外层是最后一个配置的插件,最里层是真实的目标对象。调用的时候一层层往里剥,每一层都有机会在proceed()前后插入逻辑。
2.4 四大核心对象和插件场景的匹配关系
搞清楚每个对象负责什么,选型几乎不用过脑:
| 核心对象 | 核心方法 | 适合干的活 |
|---|---|---|
| Executor | query / update / commit / rollback | SQL执行监控、全局慢SQL统计、事务边界埋点 |
| StatementHandler | prepare / parameterize / batch / update / query | 改写SQL(分页、数据权限)、参数优化 |
| ParameterHandler | setParameters | 敏感字段加密(写库前自动加密) |
| ResultSetHandler | handleResultSets / 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和update
@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 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告警,耗时[{}]ms,SQL[{}]", 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(); } List<ParameterMapping> 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 interceptor="com.example.DataScopeInterceptor"> <property name="includeMappers" value="com.example.mapper.OrderMapper,com.example.mapper.UserMapper"/> <property name="excludeMappers" value="com.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。不管哪种,入口都是handleResultSets:
@Intercepts({ @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 interceptor="com.example.SlowSqlInterceptor"/> <plugin interceptor="com.example.DataScopeInterceptor"/> <plugin interceptor="com.example.EncryptInterceptor"/> </plugins>根据前面讲的代理包装逻辑,后配置的插件会包在更外层。也就是说,调用链的入口是最外层的插件,出口也是它。用上面的配置举例子:
EncryptInterceptor在最外层,它会先拿到原始请求,在调用proceed()时触发DataScopeInterceptor;DataScopeInterceptor改写SQL后,调用proceed()触发SlowSqlInterceptor;SlowSqlInterceptor计时并调用proceed(),最终进入真实的Executor执行SQL。
所以,耗时计算如果在最外层插件里做,那它统计的时间其实包含了内层插件的处理时间。这一点在慢SQL监控里会造成误差——你以为SQL本身跑了500ms,实际上可能只是数据权限插件和加解密插件多耗了300ms。
6.2 协作模式:插件之间如何传递数据
多个插件协作的场景里,一个常见的需求是:DataScopeInterceptor改写了SQL,SlowSqlInterceptor打印日志的时候希望能拿到改写后的完整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的开发者,哪怕是写个最简单的耗时监控插件,也值得自己动手试一遍——原理性的东西,亲手拆一次比看十遍文档管用。