概述
在之前的系列中,我们深入剖析了 MappedStatement、BoundSql、Executor 等运行时核心组件的运作机制。然而,这些对象在 MyBatis 启动时是如何被凭空创建出来的?mybatis-config.xml 和 UserMapper.xml 又是如何被解析成 Configuration 对象中的一个个 MappedStatement?本文回到 MyBatis 生命周期的起点,完整串联从 XML 文件到 SqlSessionFactory 的初始化全链路。
MyBatis 的初始化过程是建造者模式的教科书级应用。XMLConfigBuilder 像一位总工程师,依次解析数据源、事务管理器、别名、插件等全局配置;每当遇到 <mappers> 节点,便委派 XMLMapperBuilder 去解析具体的 mapper.xml;XMLMapperBuilder 再将每个 <select>、<insert> 等节点交给 XMLStatementBuilder 去构建 MappedStatement。这套层层递进的建造者流水线,将分散在多个文件中的配置最终收敛到全局唯一的 Configuration 对象中,并由 SqlSessionFactoryBuilder 将其封装为 SqlSessionFactory。本文将沿着这条建造者流水线,逐层拆解每个 Builder 的解析逻辑与设计巧思。
核心要点
- 初始化入口 :
SqlSessionFactoryBuilder.build创建XMLConfigBuilder并启动解析。 - 建造者流水线 :
XMLConfigBuilder→XMLMapperBuilder→XMLStatementBuilder的层层委托。 - 配置项解析 :
properties、settings、typeAliases、environments、mappers的逐一处理。 - SQL 片段递归 :
XMLIncludeTransformer处理<sql>和<include>标签的替换机制。 - 注解合并 :
MapperAnnotationBuilder将@Select等注解转化为MappedStatement。
文章组织架构图
分层详尽的文字说明
- 总览说明:全文 10 个模块从初始化总览开始,逐步深入各层 Builder 的解析逻辑,最后通过设计模式总结、事故排查和面试完成闭环。
- 逐模块说明:模块 1 建立初始化全链路的全局认知;模块 2-6 逐层拆解各 Builder 的内部实现;模块 7 讲解最终工厂的构建;模块 8 提炼设计思想;模块 9-10 落地实践与应试。
- 关键结论 :MyBatis 的初始化是建造者模式的精妙编排------
XMLConfigBuilder、XMLMapperBuilder、XMLStatementBuilder层层委托,将分散的配置最终收敛为Configuration单例对象,再由SqlSessionFactoryBuilder封装为SqlSessionFactory。
1. MyBatis 初始化流程总览
MyBatis 的启动可分为两大阶段:配置解析 与工厂构建。
- 配置解析阶段 :将 XML、注解等外部描述转化为内存中的
Configuration对象,该对象持有所有的MappedStatement、ResultMap、ParameterMap、Cache、插件拦截链以及环境设置。 - 工厂构建阶段 :将完整的
Configuration对象包装进DefaultSqlSessionFactory,成为应用创建SqlSession的工厂。
整个流程的入口是 SqlSessionFactoryBuilder.build(reader),它委派给 XMLConfigBuilder 完成解析,最后调用 build(configuration) 生成 SqlSessionFactory。下图为全链路流程:
图表主旨概括 :展示从读取配置到生成 SqlSessionFactory 的完整调用链,体现建造者的逐级委托。
逐层分解 :
① 应用程序创建 SqlSessionFactoryBuilder 并传入 MyBatis 配置的 InputStream。
② Builder 创建 XMLConfigBuilder 实例,内部持有 XPathParser 和空 Configuration 对象。
③ XMLConfigBuilder.parse() 方法解析 XML 根节点,并调用 parseConfiguration 处理全局节点。
④ 解析 mappers 时,根据类型(resource/url/class/package)构造对应的 XMLMapperBuilder 并调用 parse()。
⑤ XMLMapperBuilder 解析映射文件中的 select|insert|update|delete,交给 XMLStatementBuilder 生成 MappedStatement。
⑥ 解析全部完成后,返回装配好的 Configuration 对象。
⑦ SqlSessionFactoryBuilder.build(Configuration) 创建 DefaultSqlSessionFactory。
设计原理映射:
- 建造者模式 :
XMLConfigBuilder、XMLMapperBuilder、XMLStatementBuilder分别负责不同粒度的对象构建。 - 工厂模式 :
SqlSessionFactoryBuilder最终产出SqlSessionFactory工厂。 - 组合模式 :动态 SQL 节点(
SqlNode)在解析阶段即构建成树型结构。
工程联系与关键结论 :
理解此流程对排查"找不到 MappedStatement"错误至关重要------若某 Mapper 未被解析,极大概率是mappers配置或 Resource 加载出了问题。在 Spring Boot 中,SqlSessionFactoryBean内部同样调用SqlSessionFactoryBuilder,本质流程一致。
下文将按照该流程展开每个环节的源码与设计细节。
2. SqlSessionFactoryBuilder:入口与委托
SqlSessionFactoryBuilder 是一个轻量级的构造器,主要提供多个 build 重载方法来接收不同形式的配置源(Reader、InputStream),最终都汇聚到 build(Configuration)。
源码片段:SqlSessionFactoryBuilder.build(InputStream, String, Properties)
java
public class SqlSessionFactoryBuilder {
public SqlSessionFactory build(InputStream inputStream, String environment, Properties properties) {
try {
// 创建 XMLConfigBuilder,传入配置文件和环境 id
XMLConfigBuilder parser = new XMLConfigBuilder(inputStream, environment, properties);
// 执行解析,返回 Configuration,然后构建 DefaultSqlSessionFactory
return build(parser.parse());
} catch (Exception e) {
throw ExceptionFactory.wrapException("Error building SqlSession.", e);
} finally {
// 关闭输入流
try {
inputStream.close();
} catch (IOException e) {
// ignore
}
}
}
public SqlSessionFactory build(Configuration config) {
return new DefaultSqlSessionFactory(config);
}
}
解读:
SqlSessionFactoryBuilder并不承担任何解析职责,它仅创建XMLConfigBuilder并调用parse(),后者才是真正的配置解析器。这是典型的委托模式 ,Builder 将构建过程委派给专业的XMLConfigBuilder。parse()返回完整的Configuration对象后,build(Configuration)简单地new DefaultSqlSessionFactory(config),可见Configuration是 MyBatis 的核心状态容器。- 关闭流的逻辑保证资源释放。
其它重载方法(如只传入 InputStream,或指定 environment)本质相同,省略部分参数的会使用默认值。在 Spring Boot 整合环境中,SqlSessionFactoryBean 最终也会调用 SqlSessionFactoryBuilder.build(Configuration),因此该入口是理解所有 MyBatis 启动的钥匙。
设计启示 :SqlSessionFactoryBuilder 是一次性的,用完即可丢弃,因为其产物 SqlSessionFactory 已具备生产 SqlSession 的能力。这也符合建造者模式的典型用法:建造者独立于产品,完成建造后移除。
3. XMLConfigBuilder:顶层配置的解析引擎
XMLConfigBuilder 继承自 BaseBuilder,是解析 mybatis-config.xml 的核心。其 parseConfiguration 方法内部采用模板方法 风格,按顺序调用各个 private 方法,将 XML 节点逐一映射到 Configuration 对象的相应属性。
图表主旨概括 :展示 XMLConfigBuilder.parseConfiguration 如何依序处理 settings、typeAliases、plugins、environments 和 mappers 等节点,并填充 Configuration。
逐层分解:
propertiesElement:读取<properties>节点及外部属性文件,并执行占位符替换(${})。settingsAsProperties:解析<settings>为Properties,后续调用settingsElement将具体值设置到Configuration(如cacheEnabled、lazyLoadingEnabled)。typeAliasesElement:注册类型别名,默认扫描TypeAliasRegistry中的预定义别名,同时支持package扫描。pluginElement:实例化拦截器并添加到Configuration.interceptorChain。environmentsElement:根据default环境 id 选取<environment>,分别构造事务工厂和 DataSource,然后通过Configuration.setEnvironment设置。mapperElement:处理<mappers>,支持resource、url、mapperClass、package四种方式映射。
设计原理映射:- 建造者模式 :每个
xxxElement方法负责建造Configuration的一个局部,parseConfiguration协调建造顺序。 - 策略模式 :
environments中可根据配置选择不同的TransactionFactory实现(JDBC/MANAGED)和不同的数据源实现(POOLED/UNPOOLED/JNDI)。
工程联系与关键结论 :
解析顺序严格遵守 MyBatis 规定,properties首先加载以保证后续节点可使用${}占位符。mappers最后解析是因为它依赖前面完成的别名注册、类型处理器等。若违反顺序自行拼接配置,可能导致占位符无法解析或别名未找到。
3.1 解析 mappers 节点的四种方式
在 XMLConfigBuilder.mapperElement(XNode parent) 中,会根据子节点的不同特征执行不同策略:
源码片段(简化)
java
private void mapperElement(XNode parent) throws Exception {
if (parent != null) {
for (XNode child : parent.getChildren()) {
if ("package".equals(child.getName())) {
// 方式一:包扫描
String mapperPackage = child.getStringAttribute("name");
configuration.addMappers(mapperPackage);
} else {
String resource = child.getStringAttribute("resource");
String url = child.getStringAttribute("url");
String mapperClass = child.getStringAttribute("class");
if (resource != null && url == null && mapperClass == null) {
// 方式二:resource 相对路径
InputStream inputStream = Resources.getResourceAsStream(resource);
XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, resource, configuration.getSqlFragments());
mapperParser.parse();
} else if (resource == null && url != null && mapperClass == null) {
// 方式三:绝对 URL
InputStream inputStream = Resources.getUrlAsStream(url);
XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, url, configuration.getSqlFragments());
mapperParser.parse();
} else if (resource == null && url == null && mapperClass != null) {
// 方式四:直接指定 Mapper 接口类
Class<?> mapperInterface = Resources.classForName(mapperClass);
configuration.addMapper(mapperInterface);
}
}
}
}
}
解读:
package扫描通过configuration.addMappers(package)内部使用ResolverUtil扫描指定包下的所有接口,然后逐个调用configuration.addMapper(mapperInterface),后者会触发MapperAnnotationBuilder解析注解。resource和url都是加载 XML 映射文件,区别在于路径解析方式。前者使用类加载器加载相对路径,后者支持绝对 URL,适用于配置文件位于远程或非类路径的情况。class直接指定 Mapper 接口,会尝试通过注解或同名 XML 进行解析(MapperAnnotationBuilder会检查是否存在同路径 XML)。- 无论哪种方式,最终目标都是将解析得到的
MappedStatement注册进Configuration。
工程提示 :Spring Boot 的 mybatis.mapper-locations 属性最终会转换为 Resource[],传递给 SqlSessionFactoryBean.setMapperLocations,其底层仍是通过 XMLMapperBuilder 解析每一个匹配的资源,与上述 resource 方式本质一致。
4. XMLMapperBuilder:SQL 映射文件的解析中枢
XMLMapperBuilder 专门负责解析单个 mapper.xml 文件,是连接全局配置与具体 SQL 语句的桥梁。
图表主旨概括 :展示 XMLMapperBuilder.parse() 如何解析 <mapper> 根节点,处理 namespace、resultMap 以及将各语句节点委托给 XMLStatementBuilder,并注册到 Configuration。
逐层分解:
- 首先检查资源是否已加载(
configuration.isResourceLoaded(resource)),避免重复解析。 - 调用
configurationElement解析根节点下的子元素。 namespace用于绑定对应的 Mapper 接口,最终存储在Configuration.mapperRegistry中。resultMapElement解析<resultMap>,构建ResultMapping列表并封装为ResultMap对象,存入Configuration.resultMaps。若存在继承或关联,可能在后续的parsePendingResultMaps中完成引用绑定。- 遍历
select|insert|update|delete节点,每个节点创建一个XMLStatementBuilder来生成MappedStatement。 - 解析完成后,会处理缓存引用、未完成的语句等挂起资源。
设计原理映射: - 建造者模式 :
XMLMapperBuilder组装ResultMap、MappedStatement等产品,但它又将语句节点的构建责任委托给更细粒度的XMLStatementBuilder。 - 责任链/组合 :动态 SQL 的解析过程会在
XMLStatementBuilder中构建SqlNode组合树,体现组合模式。
工程联系与关键结论 :
MappedStatement的唯一 id 由namespace + "." + statementId组成,这解释了为什么在调用sqlSession.selectOne("com.xx.UserMapper.getUser")时需要完整限定名。Spring Boot 中 Mapper 接口代理能自动拼接 namespace,也正是基于这一规则。详细内容见系列第 1、2 篇。
4.1 XMLStatementBuilder 构建 MappedStatement
XMLStatementBuilder 继承自 BaseBuilder,直接负责从单个 <select> 等节点构建 MappedStatement。
源码片段:XMLStatementBuilder.parseStatementNode() 核心逻辑
java
public void parseStatementNode() {
String id = context.getStringAttribute("id");
String databaseId = context.getStringAttribute("databaseId");
// ...读取各种属性(fetchSize, timeout, parameterMap, resultType, resultMap, useCache等)
// 1. 创建 SqlSource(动态SQL或静态SQL)
String lang = context.getStringAttribute("lang");
LanguageDriver langDriver = getLanguageDriver(lang);
SqlSource sqlSource = langDriver.createSqlSource(configuration, context, parameterTypeClass);
// 2. 使用 Builder 模式构造 MappedStatement
MappedStatement.Builder statementBuilder = new MappedStatement.Builder(configuration, id, sqlSource, sqlCommandType)
.resource(resource)
.fetchSize(fetchSize)
.timeout(timeout)
.parameterMap(parameterMap)
.resultMaps(resultMaps)
.cache(cache)
.useCache(isUseCache)
// ... 更多属性
.databaseId(databaseId);
MappedStatement statement = statementBuilder.build();
// 3. 注册到 Configuration
configuration.addMappedStatement(statement);
}
解读:
LanguageDriver负责将 XML 节点转化为SqlSource。若使用默认 XMLLanguageDriver,它会创建DynamicSqlSource(含动态标签)或RawSqlSource(纯文本),这是前文所述动态 SQL 引擎的源头。MappedStatement.Builder是典型的建造者模式,拥有链式调用,最终调用build()产出不可变的MappedStatement实例。configuration.addMappedStatement(statement)将其存入内部的mappedStatementsMap(键为 id)。该方法还会校验 id 唯一性,若重复会抛出IllegalArgumentException,确保每个语句定义的唯一标识。
与前文的关联 :在运行时,Executor 根据 statementId 从 Configuration 中取出 MappedStatement,获取 SqlSource 和 ResultMap,再生成 BoundSql 执行。这一流程的基础便是在此初始化阶段奠定的。
5. SQL 片段与 Include:XMLIncludeTransformer 的递归替换
<sql> 和 <include> 标签提供 SQL 复用能力,其解析由 XMLIncludeTransformer 完成,在 XMLStatementBuilder 构建 SqlSource 之前应用。
图表主旨概括 :展示 XMLIncludeTransformer.applyIncludes 如何深度遍历 XML 节点树,遇到 <include> 标签时查找并替换为对应的 <sql> 片段,并支持多层嵌套。
逐层分解:
- 方法入口时的
source参数通常是<select>等语句节点或其子节点。 - 对于每个子节点,若其名称为
include,则读取refid属性,从Configuration.sqlFragments中获取对应的<sql>节点(形式为 DOM Node)。 - 执行深拷贝,并递归调用
applyIncludes处理拷贝节点内部可能再次出现的<include>,实现多层嵌套替换。 - 替换完成后,原始的
<include>节点被删除,其位置被展开后的 SQL 文本节点替代。 - 针对普通元素节点(如
<if>、<where>等),继续递归其子节点列表,保证所有嵌套的include均被展开。
设计原理映射: - 组合模式 :XML 节点树天然是组合结构,
applyIncludes利用递归遍历处理这种层次结构。 - 装饰/转换 :原始带
include的节点被"转换"为纯 SQL,后续LanguageDriver再将其解析为SqlSource。
工程联系与关键结论 :
<include>的替换发生在 DOM 层面,而非字符串层面,因此能正确处理动态 SQL 与文本的混合。注意<include refid="columns"/>中的refid必须能在当前命名空间及共享空间中找到,否则会抛出 BuilderException。同时,由于替换用深拷贝,片段内的属性占位符和动态元素会在替换后继续参与后续解析。
5.1 源码示例:递归核心
java
public class XMLIncludeTransformer {
public void applyIncludes(Node source, final Properties variablesContext) {
if (source.getNodeName().equals("include")) {
// 找到对应的 sql 片段
String refid = getStringAttribute(source, "refid");
Node toInclude = findSqlFragment(refid, variablesContext);
// 深拷贝片段并递归展开内部 include
toInclude = toInclude.cloneNode(true);
applyIncludes(toInclude, variablesContext);
// 替换
source.getParentNode().replaceChild(toInclude, source);
// 继续处理替换后的节点(如果有未处理的属性)
applyIncludes(toInclude, variablesContext);
} else if (source.getNodeType() == Node.ELEMENT_NODE) {
NodeList children = source.getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
applyIncludes(children.item(i), variablesContext);
}
}
}
}
注意 findSqlFragment 会检查当前映射文件的内部片段,并支持跨命名空间引用(如 refid="com.xx.OtherMapper.columns")。
6. 注解映射的解析:MapperAnnotationBuilder
当 Mapper 接口未对应 XML 映射文件,或需要混合使用注解时,MyBatis 会调用 MapperAnnotationBuilder 解析接口上的 @Select、@Insert、@ResultMap 等注解,并生成对应的 MappedStatement。
源码片段:MapperAnnotationBuilder.parse()
java
public void parse() {
String resource = type.toString();
if (!configuration.isResourceLoaded(resource)) {
// 1. 查找同名的 XML 映射文件(如果存在,先解析以保证 XML 优先级)
loadXmlResource();
configuration.addLoadedResource(resource);
// 2. 解析 @CacheNamespace 等类级别注解
// ...
for (Method method : type.getMethods()) {
// 排除 Object 等基类方法
if (!method.isBridge()) {
parseStatement(method);
}
}
}
parsePendingMethods();
}
private void parseStatement(Method method) {
// 获取 SQL 命令类型(由 @Select/@Insert 等决定)
SqlCommandType sqlCommandType = getSqlCommandType(method);
// 获取各类选项注解
Options options = method.getAnnotation(Options.class);
// 构建 SqlSource:从注解的 value 属性读取 SQL,并包上动态解析能力(若有 <script> 或动态 SQL 注解)
LanguageDriver languageDriver = getLanguageDriver();
SqlSource sqlSource = buildSqlSourceFromAnnotations(method, languageDriver);
// 使用 MappedStatement.Builder 构建
String statementId = type.getName() + "." + method.getName();
MappedStatement.Builder builder = new MappedStatement.Builder(configuration, statementId, sqlSource, sqlCommandType);
// 设置各种属性(结果映射、二级缓存等)
// ...
configuration.addMappedStatement(builder.build());
}
解读:
loadXmlResource()会尝试从类路径加载与 Mapper 接口同路径的 XML 文件。若存在,XML 中的<select>等定义会优先注册(XML 的 statementId 与注解产生的 id 相同,由于configuration.addMappedStatement会检查重复,先注册者胜出------这里 MyBatis 设计成优先加载 XML,注解作为补充)。因此,当 XML 和注解同时定义相同 id 的语句时,XML 会覆盖注解。- 遍历
Method,通过反射读取@Select等注解的 SQL 文本,利用LanguageDriver创建SqlSource。如果 SQL 中包含<script>标签或动态 SQL 元素,MyBatis 会将其解析为DynamicSqlSource,实现了注解与动态 SQL 的兼容。 Configuration.addMappedStatement会在内部检查loadCompleted,若解析已完成但仍有新注册的语句,会触发异常,因此注解解析必须在Configuration锁定前完成。
注解与 XML 混合的最佳实践 :通常建议简单 SQL 使用注解,复杂动态 SQL 使用 XML,两者可用在不同方法上但共享同一 Mapper 接口。由于 loadXmlResource 的存在,完全可以将通用结果映射定义在 XML 中,增删改查用注解,保持清晰。
7. SqlSessionFactory 的最终构建与 Configuration 校验
当 XMLConfigBuilder.parse() 执行完毕,所有的全局配置、映射文件、注解均已被解析并写入 Configuration。紧接着 SqlSessionFactoryBuilder.build(Configuration) 创建 DefaultSqlSessionFactory:
java
public class DefaultSqlSessionFactory implements SqlSessionFactory {
private final Configuration configuration;
public DefaultSqlSessionFactory(Configuration configuration) {
this.configuration = configuration;
}
@Override
public SqlSession openSession() {
return openSessionFromDataSource(configuration.getDefaultExecutorType(), null, false);
}
// ...
}
Configuration 对象在此时已处于"只读"状态吗?严格说 MyBatis 并没有显式冻结 Configuration,但最佳实践是初始化完成后不应对其再修改,因为 DefaultSqlSessionFactory 会被多线程共享,修改配置可能导致线程安全问题。SqlSessionFactoryBuilder 完成任务后即可被丢弃,符合其一次性使用的语义。
Configuration 校验 :XMLConfigBuilder 在解析过程中会进行部分校验,如 databaseIdProvider 匹配、语句 id 唯一性等,但并未进行全量完整性检查。缺失的配置可能在运行时才暴露(如未配置 environment,打开 SqlSession 时会抛出异常)。因此生产环境建议在启动后立即执行一次 sqlSessionFactory.openSession().getConnection() 进行连接性验证,提前暴露环境配置错误。
8. 设计模式总结:建造者流水线的精妙编排
MyBatis 初始化流程集中体现了多种经典设计模式:
- 建造者模式 :
XMLConfigBuilder、XMLMapperBuilder、XMLStatementBuilder都是针对特定产品的建造者。XMLConfigBuilder建造Configuration全局设置;XMLMapperBuilder建造一个命名空间下的多个MappedStatement与ResultMap;XMLStatementBuilder建造单个MappedStatement。它们通过层层委托完成从粗粒度到细粒度的装配。 - 工厂模式 :
SqlSessionFactoryBuilder.build()最终产出SqlSessionFactory,后者又是创建SqlSession的工厂。TransactionFactory、DataSourceFactory等也遵循工厂方法。 - 组合模式 :动态 SQL 节点的解析产生
SqlNode树(如MixedSqlNode包含多个子SqlNode),XMLIncludeTransformer对 XML 节点树递归替换同样体现组合模式。 - 策略模式 :
LanguageDriver允许替换不同的 SQL 解析策略,TransactionFactory支持 Jdbc/Managed 两种事务管理策略。 - 模板方法 :
BaseBuilder定义了建造者的基本骨架(持有Configuration和TypeAliasRegistry等),各子类实现具体建造逻辑。
工程价值:理解这些模式有助于在阅读源码时快速定位职责边界。当需要扩展 MyBatis(如自定义节点解析),可参照现有 Builder 的结构进行扩展,而不是推翻重来。
9. 生产事故排查专题
9.1 事故一:mapper-locations 通配符失效,Mapper 全部 404
现象
Spring Boot 项目启动正常,但调用任何 Mapper 方法均抛出 org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): com.xxx.UserMapper.findById。查看日志,mybatis.mapper-locations=classpath:mapper/*.xml 配置无误。
排查过程
- 检查启动日志中
SqlSessionFactoryBean打印的加载信息,发现并没有加载预期的UserMapper.xml。 - 查看
target/classes/mapper目录,发现只有.class文件,没有.xml文件。 - 确认源代码
src/main/java/com/xxx/mapper/下放置了 XML 文件,但 Maven 默认只将src/main/resources中的文件复制到输出目录。
以下时序图重现了该问题:
根因
Maven 构建过程未将 src/main/java 下的非 Java 文件复制到输出目录。MyBatis 的 mapper-locations 通配符只能在类路径下搜索映射文件,而此类文件根本未进入类路径。
解决
在 pom.xml 中添加资源过滤配置:
xml
<build>
<resources>
<resource>
<directory>src/main/java</directory>
<includes>
<include>**/*.xml</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
</resource>
</resources>
</build>
或者直接将 XML 文件移动到 src/main/resources/mapper/ 目录下,保持路径与 mapper-locations 匹配。
最佳实践
- 统一将 Mapper XML 置于
resources/mapper,避免混淆。 - 在 CI/CD 流程中加入启动后调用一个"健康检查 Mapper"的方法,确保所有核心 SQL 可用。
9.2 事故二:type-aliases-package 配置错误,resultType 简写找不到类
现象
XML 中使用 resultType="User" 时抛出 org.apache.ibatis.type.TypeException: Could not resolve type alias 'User'。而 User 实体类位于包 com.example.entity,且在 application.yml 中配置了 mybatis.type-aliases-package: com.example.entity。
排查
- 打印
Configuration对象中的TypeAliasRegistry,发现已注册别名包实际为com.example.entities(多了一个s)。 - 对比配置项,发现 YAML 中写错:
type-aliases-package: com.example.entities。 - 别名注册使用
ResolverUtil扫描指定包,包名错误导致未找到任何类,因此User别名未被注册。
时序图:
根因
配置书写错误,导致别名注册失效,运行时无法根据简单类名映射到实体类。
解决
修正 YAML 中的拼写错误,并可使用 @Alias 注解显式指定别名增强健壮性。必要时通过 Configuration.getTypeAliasRegistry().getTypeAliases() 遍历调试。
最佳实践
- 使用全限定类名作为
resultType可完全规避别名问题,代价是冗长。 - 在项目中约定实体包路径后,通过集成测试验证别名加载(例如扫描
TypeAliasRegistry断言关键实体存在)。 - Spring Boot 配置元数据可提供提示,利用 IDE 的自动补全减少手误。
10. 面试高频专题
Q1:从读入 mybatis-config.xml 到创建 SqlSessionFactory,整个过程调用了哪些关键类与方法?请画出调用链条并说明每个环节的职责。
答:
调用链条如下:
SqlSessionFactoryBuilder.build(InputStream)
→ new XMLConfigBuilder(inputStream, environment, properties)
→ XMLConfigBuilder.parse()
→ parseConfiguration(parser.evalNode("/configuration"))
→ propertiesElement(root.evalNode("properties"))
→ settingsAsProperties(root.evalNode("settings"))
→ typeAliasesElement(root.evalNode("typeAliases"))
→ pluginElement(root.evalNode("plugins"))
→ environmentsElement(root.evalNode("environments"))
→ mapperElement(root.evalNode("mappers"))
→ 依 <mappers> 子元素类型创建 XMLMapperBuilder 或调用 configuration.addMapper()
→ 返回 Configuration 实例
→ new DefaultSqlSessionFactory(configuration)
SqlSessionFactoryBuilder:入口,只负责委派和最终工厂构建,本身无解析逻辑。XMLConfigBuilder:解析全局配置,填充Configuration的 settings、别名、插件、环境,触发映射文件加载。XMLMapperBuilder:解析单个 mapper.xml,注册ResultMap并逐个创建MappedStatement。XMLStatementBuilder:从 XML 节点构建单个MappedStatement,内部调用LanguageDriver创建SqlSource。XMLIncludeTransformer:在构建SqlSource前递归展开<include>标签。MapperAnnotationBuilder:解析接口注解,生成MappedStatement。
追问:parseConfiguration 为何必须按固定顺序调用各个解析方法?
答:因为存在依赖关系。properties 最先加载,以便后续节点可用 ${} 占位符;typeAliases 必须在 mappers 之前,否则 resultType="User" 无法解析别名;mappers 最后执行,因为它依赖已完成的别名注册、类型处理器和插件链。
Q2:XMLConfigBuilder 解析 <settings> 配置时,如何将 XML 中的驼峰设置映射到 Configuration 对象?源码如何保证设置安全?
答:
XMLConfigBuilder.settingsAsProperties(XNode) 会遍历 <settings> 下每一个子元素,将其 name 和 value 放入一个 Properties 对象。接着调用 settingsElement(Properties props),该方法通过 MetaObject 对 Configuration 进行代理:
java
MetaObject metaConf = SystemMetaObject.forObject(configuration);
for (String key : props.stringPropertyNames()) {
if (!metaConf.hasSetter(key)) {
throw new BuilderException("The setting " + key + " is not known. ...");
}
// 转换值类型,例如将 "true"/"false" 转为 boolean
Class<?> type = metaConf.getSetterType(key);
Object value = convertValue(props, type, key); // 使用 TypeHandler 或自定义转换
metaConf.setValue(key, value);
}
安全机制:
metaConf.hasSetter(key)会检查Configuration中是否存在该属性的 setter,若未知属性直接抛异常,防止误拼写(如lazyLoadingEnabled写错会立刻报错)。convertValue负责将字符串 "true"、"false"、"PARTIAL" 等正确转换为其目标类型,还会尝试使用TypeHandler完成复杂类型转换。这样 XML 中的字符串能安全注入到Configuration中。
追问:Settings 里 mapUnderscoreToCamelCase 开启后,在哪一步影响 ResultMap 构建?
答:该属性作用于 Configuration.isMapUnderscoreToCamelCase(),在 ResultMapBuilder 或 DefaultResultSetHandler 中构建自动结果的 ResultMapping 时,如果未显式配置 <result>,会调用 MetaObject.findProperty() 将数据库下划线字段转为驼峰属性进行匹配。
Q3:<mappers> 节点下四种引入方式(resource、url、class、package)的解析流程有何不同?它们的根本区别是什么?
答:
核心源码位于 XMLConfigBuilder.mapperElement():
- resource :读取类路径下的 XML 文件,
Resources.getResourceAsStream(resource)打开流,然后创建XMLMapperBuilder解析。 - url :通过
URL对象获取流,适用于绝对路径或远程文件。 - class :直接指定 Mapper 接口 Class,调用
configuration.addMapper(mapperClass),触发MapperAnnotationBuilder解析注解,并尝试加载同路径 XML。 - package :扫描整个包,找到所有接口后循环调用
configuration.addMapper(),效果等同于逐个指定class。
根本区别:
- resource 和 url 加载的是 XML 映射文件,必须存在对应的物理文件。
- class 和 package 以 Mapper 接口为驱动,会自动查找同路径 XML 并解析注解。
- 从注册
MappedStatement的角度看,两者最终都会注入Configuration.mappedStatements,只是触发入口不同。
工程注意点 :Spring Boot 的 mybatis.mapper-locations 会转换为 Resource 数组,底层仍然是调用 XMLMapperBuilder,相当于多条 resource。如果同时使用 @MapperScan 扫描接口和 XML 配置,需确保两者不会重复注册同一个 statement,否则会报错。
Q4:XMLMapperBuilder 在解析 namespace 时做了什么?为什么说 namespace 是绑定 Mapper 接口的关键?
答:
XMLMapperBuilder.configurationElement() 读取 <mapper namespace="com.xx.UserMapper">,然后调用 configuration.addMapper(Class.forName(namespace)) 或使用 MapperRegistry 完成接口绑定。核心逻辑位于 MapperRegistry.addMapper(type):
java
public <T> void addMapper(Class<T> type) {
if (type.isInterface()) {
if (hasMapper(type)) throw new BindingException("...");
boolean loadCompleted = false;
try {
knownMappers.put(type, new MapperProxyFactory<>(type));
// 触发 MapperAnnotationBuilder 解析接口注解(如果尚未解析)
MapperAnnotationBuilder parser = new MapperAnnotationBuilder(config, type);
parser.parse();
loadCompleted = true;
} finally {
if (!loadCompleted) knownMappers.remove(type);
}
}
}
knownMappers以接口 Class 为键,存放MapperProxyFactory,供后续创建 Mapper 代理时使用。- namespace 必须与某个接口的全限定名一致,否则运行时通过接口名查找
MapperProxyFactory将失败,导致BindingException。 - 同时,
XMLMapperBuilder还利用 namespace 构建语句 ID:String statementId = namespace + "." + sqlId;,这保证了每个MappedStatement的唯一性。
结论:namespace 是 XML 与 Java 接口的粘合剂,它完成了"接口方法→语句 ID"的映射基础。
Q5:XMLStatementBuilder 在构建 MappedStatement 时,如何决定使用 DynamicSqlSource 还是 RawSqlSource?动态与非动态的判定依据是什么?
答:
XMLStatementBuilder 调用 languageDriver.createSqlSource(configuration, context, parameterTypeClass)。以默认 XMLLanguageDriver 为例:
java
public SqlSource createSqlSource(Configuration configuration, XNode script, Class<?> parameterType) {
XMLScriptBuilder builder = new XMLScriptBuilder(configuration, script, parameterType);
return builder.parseScriptNode();
}
XMLScriptBuilder.parseScriptNode() 会解析 <select> 等节点的文本内容和子节点:
- 如果 XNode 的子节点中包含任何动态元素 (如
<if>、<where>、<foreach>、<choose>等),XMLScriptBuilder会将这些节点转化为SqlNode节点树,最终外层为MixedSqlNode,isDynamic标记为true,返回DynamicSqlSource。 - 如果只有纯文本,且没有
${}字符串替换符,isDynamic为false,返回RawSqlSource。 - 特殊情况:文本中包含
${}也会被判定为动态,因为${}需要在运行时通过TextSqlNode替换,无法在初始化时完全解析。
设计意义 :DynamicSqlSource 每次调用都会重新构建 BoundSql,以便根据参数动态改变 SQL 结构;RawSqlSource 则在初始化阶段就已确定完整的 SQL 和参数映射,执行时可直接复用,性能更优。
Q6:XMLIncludeTransformer.applyIncludes() 如何确保多层嵌套的 <include> 被完全展开?请结合源码描述其递归策略。
答:
源码片段:
java
public void applyIncludes(Node source, final Properties variablesContext) {
if (source.getNodeName().equals("include")) {
// 1. 查找片段
Node toInclude = findSqlFragment(getStringAttribute(source, "refid"));
// 2. 深拷贝
toInclude = toInclude.cloneNode(true);
// 3. 递归展开拷贝节点内部可能存在的 include
applyIncludes(toInclude, variablesContext);
// 4. 用展开后的节点替换原 include 节点
source.getParentNode().replaceChild(toInclude, source);
// 5. 再处理一次替换后的节点,以防属性遗留
applyIncludes(toInclude, variablesContext);
} else if (source.getNodeType() == Node.ELEMENT_NODE) {
// 遍历子节点递归
NodeList children = source.getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
applyIncludes(children.item(i), variablesContext);
}
}
}
递归机制:
- 第一个
if块直接处理include节点:递归调用applyIncludes(toInclude, ...),这将深入被引用片段内部,如果片段中又含有include,继续进入同一分支,形成递归下降。 - 递归的终止条件:当片段树中不再存在任何
include节点时,遍历结束,方法仅仅遍历普通元素的子节点而无匹配。 - 深拷贝
cloneNode(true)确保替换不会污染原始<sql>片段,使得同片段在不同地方引用时互不影响。
工程易错点 :如果出现循环引用(A 引用 B,B 引用 A),findSqlFragment 会再次获取到已展开的片段,但应用深拷贝和递归可能会陷入无限循环,导致栈溢出。因此实际开发中应避免循环 <include>。
Q7:当 Mapper 接口同时存在 XML 映射文件和方法注解时,如果它们定义了相同方法对应的 SQL 语句,谁会生效?源码层次是如何保证的?
答:
生效的是 XML。源码执行顺序如下:
-
当
XMLMapperBuilder.parse()运行到bindMapperForNamespace()或通过class方式触发configuration.addMapper()时,会调用MapperAnnotationBuilder.parse()。 -
MapperAnnotationBuilder.parse()首先执行loadXmlResource():javaprivate void loadXmlResource() { String resource = type.getName().replace('.', '/') + ".xml"; InputStream inputStream = type.getResourceAsStream(resource); if (inputStream != null) { XMLMapperBuilder xmlParser = new XMLMapperBuilder(inputStream, configuration, resource, configuration.getSqlFragments()); xmlParser.parse(); } }------此步会提前解析同路径的 XML 并注册所有
MappedStatement。 -
随后,
MapperAnnotationBuilder.parse()遍历接口方法,为每个带有 SQL 注解的方法生成MappedStatement,并调用configuration.addMappedStatement(statement)。 -
在
Configuration.addMappedStatement内部,如果mappedStatementsMap 已经包含相同的 statementId,且!isNotEmpty(current.id())判断(实际上对于已经存在的会抛出异常;但 MyBatis 的实现是:如果已经存在则throw new IllegalArgumentException,不会覆盖)。但在MapperAnnotationBuilder中,会捕获异常并忽略吗?源码中并未吞异常,而是直接让重复注册抛出错误。那么 XML 和注解同时存在时在标准 MyBatis 中是直接启动失败的。
更正:在最新 3.5.x 版本中,MapperAnnotationBuilder 解析注解产生的 statementId 可能与 XML 相同,导致 addMappedStatement 抛出异常。因此,实际上 MyBatis 不允许 XML 和注解定义同一个 statementId,一旦重复会启动失败。真正的优先级体现在:如果仅有注解且无 XML,注解生效;若只有 XML 无注解,XML 生效;两者都有但定义不同方法不会冲突;相同方法 ID 会导致重复 Key 异常。
所以严格来说,不存在"覆盖",而是互斥。面试时可突出这一点,并说明更早版本可能忽略重复,但主流版本会严格校验。
Q8:Configuration 对象在初始化完成后,MyBatis 是否提供了只读保护?如果要在运行时动态添加 MappedStatement,是否安全?
答:
MyBatis 没有为 Configuration 提供内置的冻结机制(如 Collections.unmodifiableMap)。但从设计协议上,期望 SqlSessionFactory 一旦构建完毕,Configuration 就不再被更改。若确实需要在运行时动态追加 MappedStatement:
- 可以通过
configuration.addMappedStatement(statement)直接添加,因为mappedStatements是普通的Map<String, MappedStatement>,但必须考虑线程安全,因为Configuration会被多个SqlSession共享,动态添加可能导致ConcurrentModificationException或可见性问题。 - 更好的实践是扩展
Configuration,在添加时加锁,并确保添加操作在应用初始化阶段完成,避免极致性能场景下的并发危险。 - 在 Spring Boot 中,通常通过编程式注册
SqlSessionFactoryBean的mapperLocations属性来完成,而不是运行时动态加。
面试点睛 :可以补充对比 Hibernate 的 Configuration -> SessionFactoryBuilder → SessionFactory 的不可变性设计,凸显 MyBatis 的灵活性但也暴露的风险。
Q9:解析过程中出现的 "pending result maps" 和 "pending statements" 是什么?它们解决了什么初始化顺序问题?
答:
在 XMLMapperBuilder 解析 <resultMap> 时,如果某个 <association> 或 <collection> 引用了其他尚未解析的 resultMap(可能定义在另一个文件且尚未加载),此时直接解析会找不到结果映射。为解决这种跨文件引用 和声明顺序问题,MyBatis 引入 "pending" 机制:
XMLMapperBuilder维护configuration级别的incompleteResultMaps、incompleteStatements、incompleteCacheRefs等集合。- 当解析引用失败时,
ResultMapResolver会将当前配置片段封装并放入待处理队列,然后继续向后解析。 - 在所有映射文件解析完成后,MyBatis 会依次调用
parsePendingResultMaps()等方法重试解析。此时所有被引用的对象均已注册,能够成功建立引用。
源码体现:
java
// 处理 resultMap 时
private ResultMap resultMapElement(XNode resultMapNode, ...) {
// ...解析中若引用其他未完成的resultMap
if (found == null) {
configuration.addIncompleteResultMap(new ResultMapResolver(resultMapNode, ...));
}
}
parsePendingResultMaps 遍历这些 resolver 再次调用 resolve() 完成绑定。
Q10:MyBatis 初始化时,如何验证 SQL 语句的语法正确性?是否存在工具层面的支持?
答:
默认初始化过程中,MyBatis 不会 校验 SQL 语法,它只负责解析 XML 结构和占位符,生成 SqlSource。真实的 SQL 语法校验延迟到数据库执行时,由 JDBC 驱动抛出 SQLSyntaxErrorException。
要在启动时校验,可采取以下手段:
- 调用
configuration.getMappedStatement(id).getBoundSql(null),对无需参数的简单 SQL 检查构建是否报错。 - 使用
DatabaseMetaData或连接池检测 ,例如启动后执行一条SELECT 1验证数据源连通性。 - 扩展
LanguageDriver,在createSqlSource方法内拼接验证逻辑,如用java.sql.Connection.prepareStatement预编译(但需数据库连接,可能侵入性较强)。 - 集成 Liquibase/Flyway 等数据库迁移工具,将表结构检查提前,间接保证 SQL 正确性。
面试可答:MyBatis 侧重于对象-结果映射,语法责任下放给 JDBC 驱动;工业上推荐结合 CI 的数据库环境运行集成测试,捕捉语法错误。
Q11:(系统设计题)如果一个核心系统的 Mapper XML 数量超过 500 个,启动时解析缓慢,如何在不改变 MyBatis 源码的前提下优化初始化性能?
答:
分析瓶颈:每个 XML 的解析都需要 DOM 构建、XPath 查找、动态 SQL 节点树生成,大量文件累积会造成 CPU 密集型延迟。
优化方案:
- 减少不必要扫描 :精确配置
mapper-locations,避免使用宽泛通配符加载无关 XML。 - 启用 MyBatis 的 XML 文件缓存 :解析后的
MappedStatement序列化到磁盘(如使用 mybatis-mapper-cache 扩展),下次启动直接反序列化加载,跳过 DOM 解析。 - 合并小 XML:将多个 Mapper 的 SQL 合并到一个文件中,减少 I/O 次数和解析开销(代价是管理复杂度上升)。
- 切换为注解模式:对于简单 SQL,用注解替代 XML,省却 XML 解析步骤。
- 异步预加载 :应用分层启动,先启动核心功能,非核心 Mapper 延迟加载(需自定义
Configuration和MapperRegistry,控制addMappedStatement时机)。 - 升级硬件和 JDK :高并发解析时利用多核 CPU 进行并行加载(自行管理线程安全),MyBatis 默认是单线程解析,可自定义
SqlSessionFactoryBuilder,分批次并发调用XMLMapperBuilder,最后合并Configuration(需注意对象引用的并发安全)。
最佳实践:实测 500 个 XML 文件在主流 JVM 下启动时间在 1~3 秒,一般可接受;如果仍然不足,首推 XML 文件预编译缓存方案(反序列化几乎是零解析成本)。
Q12:(系统设计题延续)如果现在要设计一个多租户系统,每个租户拥有独立的数据库但使用同一套业务代码,如何让 MyBatis 初始化出一个"基础 Configuration"并针对每个租户复制调整,实现租户数据源路由?
答:
采用 Configuration 模板复制 + 动态 Environment 的方案:
- 首先构建一个"模板 Configuration":解析基础 mybatis-config.xml 和所有 Mapper 接口,但不指定特定环境,而是留下一个占位的 Environment(如 ID 为 "template")。此时
Configuration.getMappedStatements()已完整。 - 当租户首次接入时,执行深拷贝或重新调用
SqlSessionFactoryBuilder.build(InputStream)传入租户特有数据源配置。为了避免重复解析 Mapper,可利用 MyBatis 的Configuration拷贝构造函数(3.5.x 中Configuration提供拷贝构造,但某些内部 Map 需要手动复制)。 - 通过工厂模式创建多个
DefaultSqlSessionFactory,每个绑定一个租户的数据源。 - 在运行时,通过
ThreadLocal或请求上下文中的租户 ID 选择对应的SqlSessionFactory获取SqlSession,或通过AbstractRoutingDataSource动态路由数据源,此时只需一个SqlSessionFactory但需要配置 MyBatis 的环境引用该路由数据源。 - 若采用一个工厂 + 路由器方式,需要注意一级缓存和二级缓存的租户隔离(可结合插件对 CacheKey 增加租户前缀)。
面试时强调:将租户隔离下推到数据源层,能保持业务代码零侵入,同时复用 MyBatis 的所有优化。需要处理好事务管理器的绑定,确保每个租户的连接不交叉。
文末速查表:MyBatis 初始化关键 Builder 一览
| Builder | 职责 | 输入 | 输出 | 使用场景 |
|---|---|---|---|---|
SqlSessionFactoryBuilder |
整体构建入口 | XML 流或 Configuration | SqlSessionFactory |
应用启动,一次性 |
XMLConfigBuilder |
解析全局配置 | mybatis-config.xml | Configuration(全局属性填充) |
顶层,解析 settings/environments 等 |
XMLMapperBuilder |
解析单个映射文件 | mapper.xml InputStream | Configuration(注册 ResultMap 和 MappedStatement) |
每个 mapper.xml 一次 |
XMLStatementBuilder |
构建单个 MappedStatement | <select> 等 XML 节点 |
MappedStatement |
在 MapperBuilder 内循环使用 |
XMLIncludeTransformer |
展开 <include> 标签 |
带 include 的 XML 节点 | 展开后的纯 SQL 节点 | 在 StatementBuilder 构建 SqlSource 之前 |
MapperAnnotationBuilder |
解析 Mapper 接口注解 | Class<?> (接口) | Configuration(注册 MappedStatement) |
接口 + 注解模式 |
MappedStatement.Builder |
构建 MappedStatement 对象 | 各属性值 | MappedStatement |
在 StatementBuilder 和 AnnotationBuilder 内部 |
关键结论:MyBatis 初始化将静态配置转化为动态能力,是整个框架的基石。彻底掌握这条建造者流水线,不仅能快速定位配置错误,也为自定义扩展(如接入自研数据源、改造 SQL 方言解析)提供了清晰的切入点。
延伸阅读
- MyBatis 官方文档:XML 配置、Mapper XML 映射
- 源码分析推荐:《MyBatis 3 源码深度解析》相关章节
- 本系列后续文章将继续深入执行阶段、插件开发与调优实践,敬请关注。