1. 为什么把“启动流程”和“拦截器”放在一起讲:一套面试组合拳
1.1 一个高频面试题,卡住一半人的关键在哪
兄弟,先问你个问题:MyBatis的启动流程,你能顺口说出几条线?再追问一层:你自己写的拦截器,在启动流程里到底是被谁、在哪一步、怎么注册进去的?两个问题分开答,不少人都能说个七七八八;但面试官要是把这两个揉成一个问题——“结合拦截器描述一下MyBatis的启动流程”,当场能打断一半人的思路。
这个组合之所以刁钻,是因为它考的不是死记硬背,而是你对MyBatis生命周期的整体理解。启动流程是“配置加载→对象装配→工厂产出”的阶段,拦截器机制则是“启动时注册、运行时生效”的横切点。两者在时间轴上是一前一后的关系,但代码层面却咬合得很紧:解析<plugins>节点的逻辑,就嵌在启动流程的一长串配置解析步骤中间。你把拦截器的注册时机搞清楚了,等于在启动流程的时间线上钉下了一颗钉子,前后顺序自然就串起来了。
这篇文章就以拦截器为观察视角,先走一遍启动流程的主干代码,再落到pluginsElement()这个关键方法上,最后给出一套可以直接跑的实验代码,用自定义拦截器把“启动阶段”和“执行阶段”分别打点,让你从日志里反推出整个生命周期。适合正在准备MyBatis面试的Java开发,也适合读过源码但总觉得“差点意思”的朋友。
1.2 拦截器是启动流程里唯一能“反向观察”的钩子
为啥非要用拦截器来观察启动流程,而不是直接打断点?因为启动流程里的那些类——XMLConfigBuilder、Configuration、TypeAliasRegistry——大多是启动时一次性使用完就丢弃的对象,断点打一堆不说,还得反复重启。而拦截器给了你三个天然的观察点。
第一个观察点在setProperties()。注意这个方法的名字:它是在启动流程解析到<plugins>节点、实例化拦截器之后立刻被调用的。只要你的拦截器在启动时打出了这段日志,就证明启动流程已经走到了“插件解析”这一步,而且Configuration对象已经存在了。
第二个观察点在plugin(Object target)。这个方法是在运行期每次创建Executor、StatementHandler、ParameterHandler、ResultSetHandler这四个核心对象时被调用的。你在重写它的时候可以打印target.getClass(),就能精确看到某一个核心对象是在什么时候被创建、被谁创建的。
第三个观察点在intercept(Invocation)。它记录的是SQL真正执行的调用链。把这三个点的日志拼起来,你得到的就是一幅完整的生命周期图:启动阶段注册、会话阶段建对象、执行阶段走调用。这才是“结合拦截器描述启动流程”这道题的正确打开方式。
2. MyBatis启动流程主线:从XML到SqlSessionFactoryBuilder到Configuration
2.1 入口链路:SqlSessionFactoryBuilder.build()到底干了什么
所有MyBatis应用的启动,几乎都从下面这行代码开始:
String resource = "mybatis-config.xml"; InputStream inputStream = Resources.getResourceAsStream(resource); SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);顺着build(InputStream)往里追,核心链路是这样的(我刻意省略了异常包装和 finally 关闭输入流的代码,实际源码里这两块是有的):
public SqlSessionFactory build(InputStream inputStream, String environment, Properties properties) { XMLConfigBuilder parser = new XMLConfigBuilder(inputStream, environment, properties); Configuration configuration = parser.parse(); return new DefaultSqlSessionFactory(configuration); }这里有个很多人没注意到的细节:SqlSessionFactoryBuilder本身是一次性的,它的职责就是把“输入流”转换成“配置对象”,再交给DefaultSqlSessionFactory,之后这个Builder就可以被回收了。真正干活的是XMLConfigBuilder.parse(),它会解析mybatis-config.xml的根节点<configuration>,把里面的每一项配置转换成Configuration对象上的字段。
而DefaultSqlSessionFactory拿到Configuration之后,本身并没有做太多事,启动流程的主体工作到这里已经结束了。所以面试的时候,你把SqlSessionFactoryBuilder比作“入口保安”,把XMLConfigBuilder比作“实际施工队”,把Configuration比作“施工图纸”,这条链路就非常好记了。
2.2 parseConfiguration()里的十几步解析,顺序为什么不能乱
XMLConfigBuilder.parse()最终会调到parseConfiguration(XNode root),这个方法就是整个启动流程的地图。看源码(以MyBatis 3.5.x为例):
private void parseConfiguration(XNode root) { try { propertiesElement(root.evalNode("properties")); Properties settings = settingsAsProperties(root.evalNode("settings")); loadCustomVfs(settings); loadCustomLogImpl(settings); typeAliasesElement(root.evalNode("typeAliases")); pluginsElement(root.evalNode("plugins")); objectFactoryElement(root.evalNode("objectFactory")); objectWrapperFactoryElement(root.evalNode("objectWrapperFactory")); reflectorFactoryElement(root.evalNode("reflectorFactory")); settingsElement(settings); environmentsElement(root.evalNode("environments")); databaseIdProviderElement(root.evalNode("databaseIdProvider")); typeHandlersElement(root.evalNode("typeHandlers")); mappersElement(root.evalNode("mappers")); } catch (Exception e) { throw new BuilderException("Error parsing SQL Mapper Configuration. Cause: " + e, e); } }一共十几步,按顺序分别是:加载外部properties、读取settings、加载自定义VFS和日志实现、注册别名、解析插件(拦截器)、配置对象工厂、对象包装工厂、反射工厂、应用settings、解析环境(数据源+事务)、识别数据库方言、注册类型处理器、最后加载Mapper映射。
这里有一个值得注意的细节:pluginsElement排在typeAliasesElement之后、objectFactoryElement之前,并且早于environmentsElement和mappersElement。这个顺序是有依赖关系在里面的。pluginsElement里要用resolveClass(interceptor)加载拦截器类,而resolveClass会先查TypeAliasRegistry。如果你在interceptor属性里写的是别名而不是全限定类名,前面的别名注册就必须已经完成。反过来,拦截器又不依赖数据源和Mapper映射,所以它被放在 environments 和 mappers 前面,也是很合理的。
2.3 Configuration:启动流程产出的核心容器,拦截器链就挂在上面
经过上面十几步解析,产出的是一个Configuration实例。你可以把它理解成一个巨大的“注册中心”:数据源、事务工厂、类型处理器、别名、Mapper映射、以及我们要重点关注的InterceptorChain,全都挂在它身上。
Configuration里和拦截器相关的是这段代码:
protected final InterceptorChain interceptorChain = new InterceptorChain(); public void addInterceptor(Interceptor interceptor) { interceptorChain.addInterceptor(interceptor); }InterceptorChain内部就是一个ArrayList<Interceptor>,方法也只有三个:addInterceptor往列表里追加,pluginAll遍历列表对目标对象逐个包装,getInterceptors返回只读列表。
看清楚这个数据结构,你就知道“注册”这件事的本质了:启动流程解析到<plugins>节点时,拦截器被实例化、注入属性、放进InterceptorChain的列表里,仅此而已。pluginAll虽然重要,但它不在启动流程里被调用,而是在每次创建核心对象时被调用。这个时间差,就是理解启动流程和拦截器关系的关键。
3. 拦截器在启动流程中的注册与装配:pluginsElement()源码拆解
3.1 pluginsElement()的四行代码,对应四个考点
再来看pluginsElement的具体实现:
private void pluginsElement(XNode parent) throws Exception { if (parent != null) { for (XNode child : parent.getChildren()) { String interceptor = child.getStringAttribute("interceptor"); Properties properties = child.getChildrenAsProperties(); Interceptor interceptorInstance = (Interceptor) resolveClass(interceptor) .getDeclaredConstructor().newInstance(); interceptorInstance.setProperties(properties); configuration.addInterceptor(interceptorInstance); } } }这段代码只有四件事,对应四个考点。
第一,resolveClass(interceptor)。它先用TypeAliasRegistry解析类名,所以interceptor属性既可以写com.example.MyInterceptor这样的全限定名,也可以写已经注册的别名。解析失败会在这里直接抛异常,启动失败,而不是运行期失败。
第二,getDeclaredConstructor().newInstance()。这是反射调用无参构造方法,意味着你的拦截器必须有默认构造器。如果只有一个带参构造,启动时这里就会报NoSuchMethodException。这个坑我早期踩过,后面细说。
第三,setProperties(properties)。<plugin>标签里的每个<property>子标签都会被转成Properties注入进来。注意时机:这是在启动流程里执行的,你可以用这个时机做初始化,比如读取开关、打印启动日志。
第四,configuration.addInterceptor(interceptorInstance)。把实例追加到InterceptorChain的列表里,完成注册。
提示:启动阶段只注册拦截器,不做任何代理包装。很多人误以为配置完拦截器之后,MyBatis启动时就会把四大对象全部代理一遍,这是不对的。代理是懒创建的,要等到运行期真正new对象时才发生。
3.2 自定义拦截器三件套:接口、注解、配置
一个能真正跑起来的自定义拦截器,需要三个部分配合。
第一部分是Interceptor接口。它有三个方法:intercept(Invocation)是拦截逻辑;plugin(Object target)是包装逻辑,默认实现是Plugin.wrap(target, this);setProperties(Properties)是属性注入。最低限度要实现intercept,其余两个用默认实现就行。
第二部分是@Intercepts和@Signature注解。这是MyBatis插件机制的“瞄准镜”,声明你要拦哪个接口、哪个方法、参数列表是什么:
@Intercepts({ @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class}) })第三部分是配置文件里的注册。在mybatis-config.xml里加<plugins>节点,或者在Spring/Spring Boot的配置类里注册为Bean。两种方式的原理一致,最终都会走进Configuration.addInterceptor()。
3.3 后注册的先执行:拦截器顺序与代理链方向
前面说了InterceptorChain.pluginAll是遍历列表逐个包装,那多个拦截器被包装出来之后,谁的代理在最外层?答案是列表里靠后的拦截器,因为pluginAll是这么写的:
public Object pluginAll(Object target) { for (Interceptor interceptor : interceptors) { target = interceptor.plugin(target); } return target; }第一轮包装把target变成代理A,第二轮包装把代理A当成新target再包一层代理B。最后的对象结构是“代理B包着代理A包着原始对象”。调用方法时从外往里走,所以B先执行,A后执行。也就是说,在<plugins>里写在后面的拦截器,优先级更高、先执行。这个结论和很多人的直觉相反,面试里专门问这个细节的人不少。
放到Spring Boot场景里也一样:多个Interceptor Bean的注册顺序取决于Bean的创建顺序,而Bean的创建顺序又受配置类里方法声明顺序、自动配置顺序、@Order等影响。想精确控制拦截器生效顺序,最稳妥的办法是在自己的配置类里用一个方法显式addInterceptor,不依赖Spring的隐性装配。
4. 实操:写一个启动流程跟踪拦截器,用日志反推生命周期
4.1 拦截器代码实现:在三个方法里打点
理论说再多,不如直接跑一个。我写了一个LifecycleTraceInterceptor,在setProperties、plugin、intercept三个方法里分别打印日志,用来观察MyBatis从启动到执行的全过程。
@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}), @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class}), @Signature(type = ParameterHandler.class, method = "setParameters", args = {PreparedStatement.class}), @Signature(type = ResultSetHandler.class, method = "handleResultSets", args = {Statement.class}) }) public class LifecycleTraceInterceptor implements Interceptor { private String name; @Override public Object intercept(Invocation invocation) throws Throwable { Object target = invocation.getTarget(); System.out.println("[intercept] " + name + " 拦住 " + target.getClass().getSimpleName() + "." + invocation.getMethod().getName()); return invocation.proceed(); } @Override public Object plugin(Object target) { System.out.println("[plugin] " + name + " 正在包装 " + target.getClass().getSimpleName()); return Plugin.wrap(target, this); } @Override public void setProperties(Properties properties) { this.name = properties.getProperty("name", "trace"); System.out.println("[setProperties] " + name + " 实例化完成," + "说明启动流程已解析到 <plugins> 节点"); } }然后在mybatis-config.xml里注册:
<plugins> <plugin interceptor="com.demo.LifecycleTraceInterceptor"> <property name="name" value="lifecycle-trace"/> </plugin> </plugins>这里我故意重写了plugin方法,而不是直接依赖默认实现。原因很简单:plugin被调用的时机就是核心对象被包装的时机,打印出 target 的类型,你就能精确知道每个核心对象是什么时候被new出来的。默认实现把包装逻辑封装死了,反而看不到这个过程。
4.2 运行一次查询,看看启动与执行的先后顺序
跑一个最简单的查询,控制台会依次出现这些日志:
[setProperties] lifecycle-trace 实例化完成,说明启动流程已解析到 <plugins> 节点 [plugin] lifecycle-trace 正在包装 CachingExecutor [intercept] lifecycle-trace 拦住 CachingExecutor.query [plugin] lifecycle-trace 正在包装 DefaultParameterHandler [plugin] lifecycle-trace 正在包装 DefaultResultSetHandler [plugin] lifecycle-trace 正在包装 RoutingStatementHandler [intercept] lifecycle-trace 拦住 RoutingStatementHandler.prepare [intercept] lifecycle-trace 拦住 DefaultParameterHandler.setParameters [intercept] lifecycle-trace 拦住 DefaultResultSetHandler.handleResultSets对照日志解读一下:setProperties只有一行,发生在启动阶段,对应parseConfiguration里的pluginsElement。这一行之后,启动流程继续走完 environments、typeHandlers、mappers 这些步骤,但拦截器的日志不会再出现,因为启动阶段确实只做注册。
真正密集的日志在第一次执行查询时出现。CachingExecutor被包装,说明我开了二级缓存;如果你没开,这里target会变成SimpleExecutor。从这一行能看出,打开SqlSession时才会创建Executor,而不是启动时创建。紧接着Executor.query被拦截,这是SQL执行的入口。进到SimpleExecutor.doQuery之后要建StatementHandler,它在构造过程中又顺手创建并包装了DefaultParameterHandler和DefaultResultSetHandler,最后RoutingStatementHandler本身也被包装。这几个 plugin 日志连续出现,表示“创建映射器语句处理器”这一步正在发生。
然后调用链往下走:prepare预编译SQL、setParameters绑定参数、handleResultSets映射结果。这三行对应的是执行阶段,顺序固定,从日志里一眼就能看出来。
4.3 一张表看懂启动阶段与运行阶段的边界
把上面的日志整理成一张对照表,启动和执行的边界就非常清楚了:
| 日志出现点 | 发生阶段 | 对应环节 | 说明 |
|---|---|---|---|
setProperties | 启动阶段 | pluginsElement()解析<plugins> | 拦截器实例化 + 属性注入 + 注册到拦截器链 |
plugin(包装CachingExecutor) | 运行阶段 | openSession()创建 Executor | 二级缓存开启时 target 是 CachingExecutor,否则是 SimpleExecutor |
intercept(Executor.query/update) | 运行阶段 | sqlSession 调用SQL入口 | 拦截器在代理层先于真实方法执行 |
plugin(包装ParameterHandler) | 运行阶段 | 创建 StatementHandler 的内部构造 | 在BaseStatementHandler构造时触发 |
plugin(包装ResultSetHandler) | 运行阶段 | 同上 | 结果集处理器也被纳入拦截链 |
plugin(包装RoutingStatementHandler) | 运行阶段 | newStatementHandler()尾部 | 最外层 StatementHandler 才被包装 |
intercept(prepare/setParameters/handleResultSets) | 运行阶段 | SQL预编译、参数绑定、结果映射 | 三大执行环节的拦截点 |
这张表拿去应付面试其实已经够了。但把这套日志放在实际项目里跑一遍,比背十遍源码都记得牢。我建议你自己照着这个拦截器改一改,把System.out换成日志框架,然后把二级缓存开关分别打开、关闭各跑一次,对比CachingExecutor和SimpleExecutor的差异,对MyBatis整个生命周期会有一个非常直观的认识。
5. 拦截器代理链与四大核心对象的插桩机制
5.1 Plugin.wrap:为什么拦截器只能拦这四个接口
为什么拦截器只能拦那四个接口?答案在Plugin.wrap的实现里。wrap做的事情是:解析@Signature注解拿到方法签名表,然后遍历target的接口,找到与签名类型匹配的接口做JDK动态代理。
public static Object wrap(Object target, Interceptor interceptor) { Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor); Class<?> type = target.getClass(); Set<Class<?>> interfaces = getAllInterfaces(type, signatureMap); if (interfaces.size() > 0) { return Proxy.newProxyInstance( type.getClassLoader(), interfaces.toArray(new Class<?>[interfaces.size()]), new Plugin(target, interceptor, signatureMap)); } return target; }注意getAllInterfaces的返回条件:只有同时满足“target实现了该接口”和“签名声明了该接口”两个条件的接口才会被纳入代理。如果某个拦截器声明了根本不存在的接口组合,或者target压根不实现签名里的接口,interfaces列表为空,wrap会原样返回target——拦截器静默失效,但启动流程不会报错。
这就是很多人“拦截器配了但没生效”的根本原因之一。启动时一切正常,运行时也没有异常,但那个方法就是没被拦截。排查方向就是检查target类型和签名类型是否匹配。
Plugin实现了InvocationHandler,它的invoke逻辑也很简单:当前被调用的方法如果命中签名表,就走interceptor.intercept(new Invocation(target, method, args));没命中就反射调用原方法。这个“命中才拦、没命中放行”的策略,保证了拦截器不会干扰MyBatis内部的正常调用。
5.2 Executor与StatementHandler:两个关键插桩时机
XML配置里注册的拦截器,到底在哪一行代码被用上?这个问题的答案藏在Configuration的两个工厂方法里。
第一个是newExecutor,它在SqlSession打开时被调用:
public Executor newExecutor(Transaction transaction, ExecutorType executorType) { // ... 根据 executorType 创建 SimpleExecutor / ReuseExecutor / BatchExecutor if (cacheEnabled) { executor = new CachingExecutor(executor); } executor = (Executor) interceptorChain.pluginAll(executor); return executor; }注意顺序:先根据ExecutorType创建基础执行器,如果启用了二级缓存就包一层CachingExecutor,最后才调用pluginAll把整个对象链再包装一次。所以拦截器看到的target经常是CachingExecutor而不是SimpleExecutor。
第二个是newStatementHandler,它在每次SQL执行时被调用:
public StatementHandler newStatementHandler(Executor executor, MappedStatement mappedStatement, ...) { StatementHandler statementHandler = new RoutingStatementHandler(...); statementHandler = (StatementHandler) interceptorChain.pluginAll(statementHandler); return statementHandler; }RoutingStatementHandler只是一个路由壳子,构造时会根据SQL类型创建PreparedStatementHandler等实际处理器。真正干活的方法都在这些实际处理器里,但它们本身不参与pluginAll,代理只套在RoutingStatementHandler外面。这也是为什么很多面试题会问“拦截器拦的是RoutingStatementHandler还是PreparedStatementHandler”——答案是前者。
5.3 ParameterHandler与ResultSetHandler:藏在构造函数里的包装
很多人以为四大对象是分别独立包装的,其实ParameterHandler和ResultSetHandler的包装是在BaseStatementHandler的构造函数里顺手完成的:
// BaseStatementHandler 构造方法内部 this.parameterHandler = configuration.newParameterHandler(mappedStatement, parameterObject, boundSql); this.resultSetHandler = configuration.newResultSetHandler(executor, mappedStatement, rowBounds, parameterHandler, resultHandler, boundSql);而newParameterHandler和newResultSetHandler内部,同样调用了interceptorChain.pluginAll(...)。所以当你看到RoutingStatementHandler被包装的日志之前,会先看到DefaultParameterHandler和DefaultResultSetHandler被包装的日志——这个先后关系,正是代码执行顺序的忠实镜像。
到这里可以总结一个记忆口诀:启动时注册,运行期包装,构造期生成子处理器,调用期命中即拦。面试官问你“拦截器什么时候生效”,标准答案不是“启动时”,而是“首次创建四大对象时,通过 interceptorChain.pluginAll 逐个包装生效”。
6. 拦截器没生效?常见问题与排查技巧实录
6.1 三大排查方向:签名、注册、顺序
我把这些年见过的拦截器问题归成三类,按出现频率排一下。
第一类:@Signature 方法签名不匹配。这是最高频的坑。Executor.query在接口里有两个重载,一个带CacheKey和BoundSql,一个不带。你只拦了四参的query,但MyBatis内部实际走的是六参的query,拦截器自然不触发。解法是把Executor接口源码打开,逐个对着签名抄,参数类型一个都不能差。记住MyBatis校验签名用的是equals,多一个参数、少一个参数、类型顺序不对,全部不命中。
第二类:拦截器注册位置不对。在Spring Boot项目里,如果你同时用了mybatis-config.xml和配置类注入Interceptor,可能出现重复注册或者注册顺序错乱。我的建议是二选一:纯XML项目用<plugins>;Spring Boot项目直接用配置类,把Interceptor显式add进去。
第三类:多个拦截器之间的执行顺序反了。后注册的先执行这个规则,在前面已经讲透了。分页插件、审计插件这种对执行顺序敏感的场景,一旦顺序错了,表现就是分页数据不对或者审计日志漏记。排查时先打印全部注册的拦截器列表,再根据业务需要调整注册顺序。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 拦截器完全没触发 | 签名不匹配 / 没注册 / 接口没实现 | 打印InterceptorChain.getInterceptors(),核对注解 |
| 执行了但日志没打 | 命中签名但方法内部条件不满足 | 在plugin方法里打点,确认包装发生 |
| 多拦截器执行顺序反了 | 后注册的先执行 | 调整<plugins>声明顺序或Bean创建顺序 |
| 启动直接抛异常 | 拦截器没有无参构造 / 类名写错 | 检查默认构造器,检查interceptor属性全限定名 |
6.2 踩过的坑:拦截器内部递归与生产环境日志
有一个坑特别值得单独拎出来说:如果你的拦截器拦截了Executor.query,而拦截逻辑内部又通过SqlSession去执行了一次SQL,就会触发二次拦截。这种代码看着没问题,运行起来却可能栈溢出或者逻辑混乱。我自己处理这类需求时,习惯在拦截器里加一个ThreadLocal标志位:进入拦截器时置位,执行完真实方法后复位,内部二次查询命中标志位就直接放行。这个技巧在写“操作审计”“数据权限”这类拦截器时非常实用。
另外,生产环境的拦截器日志一定要用日志框架,千万别用System.out。上面演示代码用System.out是为了教学直观,但线上拦截器往往在热门SQL上高频触发,打印到标准输出会严重拖垮性能。日志里尽量不要打印整个MappedStatement或BoundSql对象,SQL文本、参数列表这些信息量很大,字段又多,容易把日志刷爆。我一般只打印方法名、目标类型、耗时,必要时才输出截断后的SQL。
6.3 把拦截器思路复用到框架排查上
理解了拦截器在启动流程里的注册机制之后,排查其他框架问题会顺手很多。比如MyBatis-Plus的分页插件PaginationInnerInterceptor、乐观锁插件OptimisticLockerInnerInterceptor,本质都是Interceptor实现,走的是同一套启动注册流程。你在Spring Boot里看到“分页失效”这类问题,第一反应就应该是去检查拦截器是否注册成功、注册顺序是否被别的拦截器影响,而不是去翻分页代码。
再比如想在项目里统一打印慢SQL日志,完全可以用上面这套思路:写一个拦截StatementHandler的拦截器,在prepare前后记录耗时,超过阈值就告警。因为prepare里面有真实的Connection预编译调用,这个位置的耗时能反映数据库连接获取和SQL预编译的实际情况,比在业务代码里手动打点可靠得多。
最后再分享一个实际操作中的体会:拦截器这套机制代码量不大,但牵扯的知识点非常集中——动态代理、责任链、反射、启动顺序、运行期对象创建时机,全串在一起了。我当初为了彻底吃透它,照着上面的实验代码改了不下十版拦截器,从只拦Executor到四个对象全部打点,再到加ThreadLocal防重入、加耗时统计。把这一套跑顺了之后,再去看MyBatis源码里那些工厂方法,基本就是按图索骥。你要是也在准备面试,别急着背答案,先把这个拦截器跑起来,日志会教你一切。