总有人在看了几天 MyBatis 源码之后问我:启动流程到底该从哪儿看起?我的建议一直很明确——先把拦截器这条线拎出来。拦截器在 MyBatis 里的位置非常特殊:它既不参与 SQL 解析,也不负责连接管理,但它的注册、排序和代理编织恰恰把XMLConfigBuilder、Configuration、以及四大核心组件的创建过程串成了一条完整的链路。你顺着拦截器走一遍,相当于把 MyBatis 启动流程的骨架摸了个遍。
这篇文章就结合拦截器来拆解 MyBatis 启动流程。我会从SqlSessionFactoryBuilder开始,讲到XMLConfigBuilder如何解析<plugins>配置,再讲Configuration里InterceptorChain如何收集拦截器,最后落到Executor、StatementHandler等组件的代理编织时机。适合正在啃 MyBatis 源码、准备面试题、或者想搞懂自定义拦截器为什么"有时候生效有时候不生效"的读者。
1. 全景先行:启动流程里拦截器到底参与了哪几步
先不急着看代码,我用一句话把 MyBatis 启动流程的核心链路讲清楚:MyBatis 启动的本质,是把 XML 配置、注解信息、Mapper 接口绑定等内容全部转译成一个Configuration对象,再基于这个对象构建出SqlSessionFactory。至于拦截器,它在这个转译过程里只做两件事:
- 第一,被
XMLConfigBuilder解析出来,注册进Configuration.interceptorChain; - 第二,在后续创建
Executor、StatementHandler、ParameterHandler、ResultSetHandler时,通过interceptorChain.pluginAll(target)对目标对象进行代理包装。
也就是说,拦截器真正"干活"是在启动完成之后的执行期,但它的身份和位置是在启动期被定死的。这就像新员工入职,启动流程负责录入系统、分配工位、确定汇报关系,真正开始产出是入职以后的事。
如果你跟着new SqlSessionFactoryBuilder().build(inputStream)这行代码往下走,实际会经过下面几个阶段:
XMLConfigBuilder创建,并初始化XPathParser,准备读取 XML 文件;parse()方法开始解析<configuration>根节点;- 按顺序解析
properties、settings、typeAliases、typeHandlers、objectFactory、objectWrapperFactory、reflectorFactory、plugins、environments、databaseIdProvider、mappers; - 解析完成后生成
Configuration; SqlSessionFactoryBuilder根据这个Configuration创建DefaultSqlSessionFactory。
拦截器的身影出现在第 3 步的pluginsElement解析里,但它的影响却贯穿第 5 步之后的所有 SQL 执行路径。所以当你把 MyBatis 启动流程和拦截器放在一起看时,视角会非常独特:你既能看到"配置怎么变成对象",又能看到"对象怎么变成代理"。
2. 从 parse() 开始:XMLConfigBuilder 是如何把插件配置转成拦截器对象的
2.1 解析顺序里藏着依赖关系
很多人以为 MyBatis 解析 XML 就是从头到尾一顺溜读完,其实不是。XMLConfigBuilder.parse()里有一个明显的前后依赖顺序:
public Configuration parse() { if (this.parsed) { throw new BuilderException("Each XMLConfigBuilder can only be used once."); } this.parsed = true; parseConfiguration(parser.evalNode("/configuration")); return configuration; }关键在于parseConfiguration:
private void parseConfiguration(XNode root) { try { propertiesElement(root.evalNode("properties")); Properties settings = settingsAsProperties(root.evalNode("settings")); loadCustomVfs(settings); loadCustomLogImpl(settings); typeAliasesElement(root.evalNode("typeAliases")); pluginElement(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")); typeHandlerElement(root.evalNode("typeHandlers")); mapperElement(root.evalNode("mappers")); } catch (Exception e) { throw new BuilderException("Error parsing SQL Mapper Configuration. Cause: " + e, e); } }你注意看,pluginElement的执行时机是在typeAliases之后、objectFactory之前。这意味着什么?意味着解析插件时,拦截器类名刚刚能被别名机制解析完毕,但objectFactory、objectWrapperFactory这些组件还没有注册。这个顺序不是随便排的——MyBatis 希望拦截器能尽早进入Configuration,这样后面创建任何组件时,拦截器链都已经准备就绪。
2.2 pluginElement 内部到底做了什么
pluginElement的逻辑相当简单,核心只有三步:
private void pluginElement(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属性值转换成Class<?>对象; - 再用无参构造器
newInstance()创建拦截器实例。所以你的拦截器类必须有无参构造器,否则启动直接抛异常; - 接着调用
setProperties(properties),把<property>子标签注入进去; - 最后调用
configuration.addInterceptor(interceptorInstance),把拦截器塞进Configuration。
这一步里最容易忽略的是child.getChildrenAsProperties()。它会把plugin标签下的所有property子标签解析成一个Properties对象。比如:
<plugins> <plugin interceptor="com.example.MyInterceptor"> <property name="prefix" value="biz_"/> </plugin> </plugins>这样在setProperties里就能拿到prefix=biz_。我见过很多人写拦截器时把参数硬编码在代码里,其实直接在 XML 里配property更灵活,环境切换时不用重新编译。
2.3 一个关键认知:解析拦截器不等于创建代理
很多刚接触源码的人会在这一步产生误解,以为addInterceptor之后,Executor就已经被包装好了。其实还没有。pluginElement阶段只是把拦截器对象放进一个"待命列表"。真正对Executor、StatementHandler等组件进行代理包装,要等到DefaultSqlSessionFactory打开会话、组件被创建的那一瞬间。
这就引出一个很实用的排查思路:如果你想确认某个拦截器是否被 MyBatis 正确加载,第一步应该去看Configuration.interceptorChain的interceptors列表大小,而不是直接打断点在intercept方法上——因为如果配置阶段就出了问题,intercept根本不会被触达。
3. Configuration 中的工位图:InterceptorChain 与 addInterceptor 的机制
3.1 Configuration 持有的核心字段
Configuration是启动流程的"中央仓库",几乎所有解析产物都会汇聚到这里。其中与拦截器直接相关的是:
protected final InterceptorChain interceptorChain = new InterceptorChain();这个InterceptorChain内部维护了一个List<Interceptor>:
public class InterceptorChain { private final List<Interceptor> interceptors = new ArrayList<>(); public Object pluginAll(Object target) { for (Interceptor interceptor : interceptors) { target = interceptor.plugin(target); } return target; } public void addInterceptor(Interceptor interceptor) { interceptors.add(interceptor); } public List<Interceptor> getInterceptors() { return Collections.unmodifiableList(interceptors); } }如果你是跟着启动流程走,这里就是拦截器"入册"的最终位置。addInterceptor只是把拦截器追加到 List 尾部,没有任何排序策略、没有优先级概念。
3.2 两类注册路径:XML 配置 vs 编程式配置
除了通过XMLConfigBuilder.pluginElement自动注册,还有很多项目在 Spring 环境下通过SqlSessionFactoryBean手动追加插件:
@Bean public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { MybatisSqlSessionFactoryBean factoryBean = new MybatisSqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); factoryBean.setPlugins(new Interceptor[]{new MyInterceptor()}); return factoryBean.getObject(); }无论走哪条路,最终都会落到configuration.addInterceptor(interceptor)。这也是为什么我一直强调:如果你在做框架集成,与其纠结 MyBatis 配置文件的加载顺序,不如直接去 SqlSessionFactoryBean 的实现里看它给 Configuration 挂了哪些钩子。
3.3 顺序问题:谁先被加入,谁先执行代理
InterceptorChain.pluginAll的循环逻辑决定了先注册的拦截器在嵌套代理的外层。这句话有点绕,我换种方式解释。
假设有两个拦截器 A 和 B,注册顺序是 A 先、B 后。那么pluginAll执行时:
target = A.plugin(target); // target 变成 A 的代理 target = B.plugin(target); // target 变成 B 的代理,且B包在A外面最终调用链看起来像B -> A -> 原始对象。也就是说,外层拦截器先拿到执行机会,也先决定是否proceed();内层拦截器只有在外层放行后才有机会执行。
这个东西在启动流程里不容易暴露问题,因为启动流程只负责按顺序把拦截器加进 List。但到了运行期,只要两个拦截器的@Signature有重叠,顺序错误会直接导致结果不符合预期。比如分页拦截器和数据权限拦截器,如果把分页放在最外层,它拿到的BoundSql可能已经被内部拦截器改写过了,分页 SQL 的拼接顺序就会很怪。
3.4 为什么把 InterceptorChain 理解成"待命列表"很重要
因为Configuration.interceptorChain在启动流程里的角色是静态注册表。它不关心目标对象是谁、什么时候创建,只负责保存拦截器实例。等到newExecutor、newStatementHandler这些方法被触发时,才把注册表里的拦截器逐一应用到具体目标上。
这种设计有一个好处:MyBatis 组件的创建路径非常统一,只有有限的几个方法会 new 出核心对象。只要在这几个方法里统一调用interceptorChain.pluginAll(...),就能保证"无论哪个入口创建组件,拦截器都不会漏掉"。
4. 编织时刻:四大核心组件的代理生成与 Plugin.wrap 的匹配逻辑
4.1 启动后第一次触发:Executor 的创建
真正的代理编织发生在运行期第一次访问数据库的时候。以最经典的DefaultSqlSessionFactory.openSession()为例,它会调用:
private SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) { final Environment environment = configuration.getEnvironment(); final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment); tx = transactionFactory.newTransaction(environment.getDataSource(), level, autoCommit); final Executor executor = configuration.newExecutor(tx, execType); return new DefaultSqlSession(configuration, executor, autoCommit); }里面那句configuration.newExecutor(tx, execType)是启动流程和运行流程的交汇点。Configuration.newExecutor源码大致如下:
public Executor newExecutor(Transaction transaction, ExecutorType executorType) { executorType = executorType == null ? defaultExecutorType : executorType; executorType = executorType == null ? ExecutorType.SIMPLE : executorType; Executor executor; if (ExecutorType.BATCH == executorType) { executor = new BatchExecutor(this, transaction); } else if (ExecutorType.REUSE == executorType) { executor = new ReuseExecutor(this, transaction); } else { executor = new SimpleExecutor(this, transaction); } if (cacheEnabled) { executor = new CachingExecutor(executor); } executor = (Executor) interceptorChain.pluginAll(executor); return executor; }注意这里有个容易忽略的点:如果配置了二级缓存,先包装CachingExecutor,然后再交给拦截器链。这会导致拦截器包在CachingExecutor外层还是内层?答案是:CachingExecutor先被包好,interceptorChain.pluginAll再对它做代理。所以拦截器能看到的是CachingExecutor的行为,而CachingExecutor内部还会调用被包装的SimpleExecutor。
4.2 StatementHandler、ParameterHandler、ResultSetHandler 的编织
Executor创建完成之后,MyBatis 开始执行 SQL,会继续创建StatementHandler。同样是在Configuration里:
public StatementHandler newStatementHandler(Executor executor, MappedStatement mappedStatement, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) { StatementHandler statementHandler = new RoutingStatementHandler(executor, mappedStatement, parameterObject, rowBounds, resultHandler, boundSql); statementHandler = (StatementHandler) interceptorChain.pluginAll(statementHandler); return statementHandler; }ParameterHandler和ResultSetHandler的创建也类似,分别在newParameterHandler和newResultSetHandler中调用了interceptorChain.pluginAll。
所以,MyBatis 被拦截器覆盖的目标对象一共有四个:Executor、StatementHandler、ParameterHandler、ResultSetHandler。你用@Intercepts指定type时,也只能在这四个类型里选,不能拦截别的类。
4.3 Plugin.wrap 的签名匹配细节
Interceptor.plugin(Object target)通常返回Plugin.wrap(target, this)。Plugin.wrap做的事情是:拿到拦截器上的@Intercepts注解,解析出@Signature数组,然后判断当前target的类型是否匹配某个@Signature指定的type。
public static Object wrap(Object target, Interceptor interceptor) { Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor.getClass()); Class<?> type = target.getClass(); Set<Method> methods = signatureMap.get(type); if (methods != null && methods.size() > 0) { return Proxy.newProxyInstance( type.getClassLoader(), new Class<?>[]{type}, new Plugin(target, interceptor, methods)); } return target; }这里有两个关键点:
- 如果
target的类型不在@Signature里,wrap直接返回原始 target,不生成代理; - 如果匹配,则用 JDK 动态代理生成一个实现了 target 接口的代理对象。注意 MyBatis 没有使用 CGLIB,所以Executor、StatementHandler 等都必须声明为接口类型,这也解释了为什么
pluginAll的返回值经常需要强转成接口。
对于想深挖的人,Plugin.invoke里还有一个细节:它会根据方法名和参数类型去匹配签名。匹配成功就调用interceptor.intercept(Invocation),否则直接method.invoke(target, args)。这意味着你的@Signature里method和args必须和实际调用的方法完全一致,否则拦截器看着注册了,实际根本不触发。
4.4 一个实际案例:通过拦截器观察启动是否完整
我在实际项目中做过一个"SQL 审计拦截器",只拦截StatementHandler.prepare。结果发现,当某个 Mapper 方法走的是MyBatis二级缓存命中路径时,我的拦截器根本不会触发,因为命中缓存后不会走到StatementHandler创建流程。
这个现象恰恰印证了上文说的编织时机:拦截器是在组件创建时被代理的,如果某个组件压根不创建,拦截器自然无从插手。所以你设计拦截器时,必须想清楚自己到底要拦截哪一层、什么时候才会真正创建那一层。
5. 顺着启动流程补齐两块拼图:缓存配置与 TypeHandler 的初始化
5.1 cacheEnabled 和 CachingExecutor 的关系
如果只盯着拦截器,你可能会漏掉启动流程里另一个关键字段:cacheEnabled。它来自<settings>里的cacheEnabled配置,默认值是 true。
在newExecutor里,正是这个cacheEnabled决定了executor是否被包装为CachingExecutor:
if (cacheEnabled) { executor = new CachingExecutor(executor); } executor = (Executor) interceptorChain.pluginAll(executor);从启动流程角度理解:settingsElement配置的解析发生在pluginElement之后,但cacheEnabled的值最终反映到 Configuration 的字段上,只有等到newExecutor才会生效。如果你想判断一个自定义拦截器是否会被CachingExecutor影响,必须清楚这个包装顺序。
5.2 mapper.xml 里的<cache>解析
除了全局cacheEnabled,每个 Mapper 是否开启二级缓存,取决于 Mapper XML 文件里的<cache>标签。这一步在XMLMapperBuilder中被解析,它属于mapperElement阶段的一部分。注意mapperElement在整个启动流程里排在最后,也就是说,只有当拦截器、环境、TypeHandler 都配置好之后,Mapper 才会被加载。
这个顺序带来的实际体验是:你在写 Mapper XML 时,如果namespace写错或者cache-ref引用了不存在的 namespace,启动流程会一路查到这里才报错。很多人在报错日志里看到BuilderException时一脸懵,其实只要清楚 MyBatis 是"先全局配置、后 Mapper 绑定"的顺序,定位起来就很快。
5.3 TypeHandler 注册与拦截器之间的潜在坑
typeHandlerElement会把自定义 TypeHandler 注册进Configuration.typeHandlerRegistry。它属于启动流程中不那么起眼、但非常重要的一环。因为等到ParameterHandler给 SQL 参数赋值、ResultSetHandler从结果集映射字段时,都要通过typeHandlerRegistry.getTypeHandler(...)找到对应的 TypeHandler。
这里和拦截器有一个容易踩的坑:当你自定义 TypeHandler 并希望它也参与结果映射时,启动顺序必须是"TypeHandler 先注册,Mapper 后解析"。因为XMLMapperBuilder解析 Mapper 时,会直接根据 Java 类型去 TypeHandlerRegistry 找已经注册的 handler。如果顺序反了,Mapper 解析阶段拿不到对应的 TypeHandler,启动虽然不会报错,但映射结果会不符合预期。
更隐蔽的是,如果你在拦截器里修改了ParameterHandler的参数对象,但你自定义 TypeHandler 没有覆盖该类型的转换逻辑,执行时仍然会走默认 StringTypeHandler 之类的逻辑,改了半天参数,映射出来的值还是不对。这类问题的排查思路通常不是盯着拦截器,而是回头看 TypeHandler 注册有没有生效。
5.4 拦截器调试的日志入口
启动流程里还有一个容易被忽略的设置:<settings>里的logImpl。如果你在排查拦截器问题时发现intercept方法一直没被调用,可以先设置:
<setting name="logImpl" value="STDOUT_LOGGING"/>然后观察 MyBatis 打印的 SQL 执行日志。日志里能看到Preparing: ...这样的输出,配合打断点就能确认StatementHandler.createCacheKey、prepare等方法的调用时机。我一度以为是拦截器没注册,最后发现是@Signature的method名称写错了,日志里 PREPARING 语句一直在走,但拦截器就是不触发。
6. 自定义 Configuration:想在启动时动脚手,可以从哪里突破
6.1 XMLConfigBuilder 里的 configuration 类型定制
MyBatis 允许你提供一个自定义Configuration子类。XMLConfigBuilder在构造时支持传入已有 Configuration:
XMLConfigBuilder parser = new XMLConfigBuilder(inputStream, null, null, configuration);如果你不用这种构造方式,MyBatis 会默认创建Configuration实例。如果想在启动流程中启用自己的 Configuration,一般两种做法:
- 在
SqlSessionFactoryBean里设置setConfiguration(configuration); - 或在源码集成时手动创建
XMLConfigBuilder并传入自定义对象。
自定义 Configuration 的价值在于:你可以覆盖newExecutor、newStatementHandler等方法,在 MyBatis 启动流程的"组件加工厂"层面插入自定义逻辑。比如:
public class CustomConfiguration extends Configuration { @Override public Executor newExecutor(Transaction transaction, ExecutorType executorType) { Executor executor = super.newExecutor(transaction, executorType); return new MyExecutorProxy(executor); } }这样就不需要靠Interceptor接口来切面,直接在组件创建源头做包装。当然,这种方式侵入性更强,需要你有足够把握,不建议第一版就玩这么花。
6.2 自定义 Configuration 和拦截器的协同
有一种很实用的组合:自定义 Configuration 里重写addInterceptor,可以在拦截器入册时打日志或者做校验。
public class CustomConfiguration extends Configuration { @Override public void addInterceptor(Interceptor interceptor) { System.out.println("interceptor registered: " + interceptor.getClass().getName()); super.addInterceptor(interceptor); } }这能帮你在启动流程中确认真实注册顺序。尤其是当你的工程里既有 XML 配置又有编程式插件时,通过重写addInterceptor观察调用顺序,比在 XML 里反复试配置要高效得多。
只不过要注意:这种方式依赖DefaultSqlSessionFactory能正确接到你的自定义 Configuration。如果你是 Spring Boot 环境,注意不要同时设置configuration和configLocation,两者同时出现时有的版本会直接忽略其中一个,排查起来相当费劲。
6.3 常见的启动失败原因与排查点位
结合拦截器和启动流程,我总结一下我实际遇到过的启动失败场景:
- 拦截器类没有无参构造器。因为
pluginElement使用newInstance(),一旦构造器带参数,启动直接抛异常; @Signature里的type写成了具体实现类而不是接口。由于Plugin.wrap判断的是接口类型,写成SimpleExecutor.class很可能根本不匹配,因为newExecutor返回等会经过强转,代理实际实现的是Executor接口;setProperties里接收到的 property 值全是字符串。如果你要转 int、boolean 等类型,需要自己做转换,MyBatis 不会帮你做;- 多个拦截器嵌套后,
target被包成了代理,有些拦截器再用instanceof判断类型会失真。如果你在一个拦截器里判断parameterHandler是不是某个自定义实现类,很可能因为外层代理而判断失败。
这几条里最隐蔽的是第三条和第四条。我见过一个团队在拦截器里读取properties的batchSize属性,直接用Integer.parseInt(properties.getProperty("batchSize"))没问题,但有人偷懒写成String batchSize = properties.getProperty("batchSize"),然后和数字比较,结果怎么都不对。
6.4 我的调试习惯
如果你也想从启动流程的角度去验证拦截器,我建议你在自己的项目里做一次最小化实验:
- 写一个最简单的拦截器,只拦截
StatementHandler.prepare; - 在拦截器
intercept方法里打印invocation.getTarget().getClass(); - 在
Configuration.newExecutor和Configuration.newStatementHandler两处打断点; - 分别观察
interceptorChain.pluginAll(...)前后的对象类型; - 注意查看
CachingExecutor被加入时的包装顺序。
跑通这一步,你对"拦截器和启动流程如何串联"的理解会比看十遍源码都深刻。之后遇到任何"拦截器不生效""启动时插件报错""多个插件顺序不对"的问题,都能从这条链路上找到对应环节,而不是靠瞎试。
我个人在实际操作中最受用的一个习惯是:永远先确认启动流程把拦截器注册到了哪个 List,再确认代理包装的顺序,最后才去查 @Signature 是否匹配。按这个顺序排查,基本没有解不开的拦截器问题。