概述
前文回顾: 在系列首篇《MyBatis 架构全解》中,我们拆解了 SqlSession、Executor、StatementHandler 等核心组件以及插件拦截链。但在日常开发中,程序员面对的直接接口是 Mapper。形如 userMapper.selectById(1) 这行平静的代码,背后隐藏着 MapperProxy 的动态代理拦截、XMLMapperBuilder 解析出的 MappedStatement 精准定位,以及动态 SQL 引擎的实时编译与执行。本文将沿方法调用栈,正面攻破从 Mapper 接口到 SQL 执行这条完整映射链路。
总结性引言: MyBatis 最受推崇的设计之一便是“接口即映射”。开发者仅需定义一个 UserMapper 接口并编写对应的 XML 或注解,框架便能自动将方法调用转换为 JDBC SQL 请求。这背后的魔法并非黑盒,而是 JDK 动态代理、XML 配置解析 与 动态 SQL 引擎 三者的精密协作:MapperProxy 充当接口的代理,将调用路由至 SqlSession;SqlNode 树对含有 <if>、<foreach> 的动态 SQL 进行解释执行,最终生成可执行的静态 SQL;TypeHandler 与 ResultMap 则完成参数绑定与结果集映射。本文将深入这些核心机制的源码,揭示 MyBatis 如何将“零实现”的接口转变为功能完备的数据访问层。
核心要点:
- MapperProxy 的动态代理:
InvocationHandler拦截接口方法调用,转发给SqlSession执行。 - XML 映射文件解析:
XMLMapperBuilder将<select>、<resultMap>等标签构建为MappedStatement和ResultMap对象。 - 动态 SQL 引擎:
IfSqlNode、ForeachSqlNode等节点构建抽象语法树,结合 Ognl 表达式求值动态生成 SQL。 - 设计模式:代理模式、建造者模式、解释器模式在 MyBatis 映射器中的综合运用。
文章组织架构图
flowchart TD
n1["1. MapperProxy: JDK 动态代理如何让接口“活”起来"] --> n2["2. MapperMethod: 从方法调用到 SQL 命令的映射"]
n2 --> n3["3. XML 映射文件解析: XMLMapperBuilder 与 MappedStatement"]
n3 --> n4["4. 动态 SQL 引擎: SqlNode 体系与 Ognl 表达式求值"]
n4 --> n5["5. 参数映射与结果映射: ParameterHandler 与 ResultSetHandler 的协同"]
n5 --> n6["6. 一个 Mapper 方法的完整执行时序"]
n6 --> n7["7. 设计模式总结与架构思考"]
n7 --> n8["8. 生产事故排查专题"]
n8 --> n9["9. 面试高频专题"]
classDef topic fill:#f8f9fa,stroke:#333,stroke-width:2px,rx:5,color:#333;
class n1,n2,n3,n4,n5,n6,n7,n8,n9 topic;
架构图说明
总览说明: 全文 9 个模块严格以“一个 Mapper 方法调用如何从接口代理一路追踪到 SQL 执行”为主线。模块 1 和 2 建立入口代理与方法映射;模块 3 和 4 揭示了静态 XML 如何被解析,动态 SQL 如何在运行时转换为可执行的 BoundSql;模块 5 聚焦数据类型的转换与映射;模块 6 将所有环节串联为完整的时序;模块 7 从设计模式视角进行抽象;模块 8 和 9 回归工程实践与面试考核,形成从理论到落地的完整闭环。
逐模块说明:
- 模块 1-2:入口部分。剖析
MapperProxy如何利用 JDK 动态代理接管接口调用,以及MapperMethod如何将方法签名映射为具体的 SQL 命令类型和参数配置。 - 模块 3-4:配置与 SQL 构建。
XMLMapperBuilder使用建造者模式逐元素解析mapper.xml,生成MappedStatement;动态 SQL 引擎以解释器模式构建SqlNode抽象语法树,在运行时结合参数生成最终 SQL。 - 模块 5:数据映射。
ParameterHandler根据ParameterMapping将 Java 参数设置到PreparedStatement;ResultSetHandler根据ResultMap将数据库结果集转换为 Java 对象。 - 模块 6:全链路时序。将模块 1-5 的所有组件串联,展示一次
selectById调用经历的 20 余个关键步骤。 - 模块 7:设计模式提炼。总结代理模式、建造者模式、解释器模式、模板方法模式的应用,并与 Spring Data 的 Repository 代理设计进行对比。
- 模块 8-9:实战与应试。拆解因映射机制理解不足导致的生产事故,并提供 12 道以上高频面试题,强化知识掌握。
关键结论: MyBatis 的映射器是“代理模式 + 建造者模式 + 解释器模式”的综合运用,理解 MapperProxy 的拦截逻辑和 SqlNode 的动态解析是掌握 MyBatis 高级定制的关键。
1. MapperProxy:JDK 动态代理如何让接口“活”起来
在 MyBatis 中,我们定义的 UserMapper 接口并没有实现类。通过 SqlSession.getMapper(Class<T> type) 获取的实例,实际上是一个 JDK 动态代理对象。这个代理对象的创建和拦截逻辑由 MapperProxyFactory 和 MapperProxy 共同完成。
1.1 MapperProxyFactory:创建代理的工厂
MapperProxyFactory 缓存了 Mapper 接口的 Class 对象,并为每个接口实例化一个 MapperProxy,然后使用 Proxy.newProxyInstance 生成代理。
// org.apache.ibatis.binding.MapperProxyFactory
public class MapperProxyFactory<T> {
private final Class<T> mapperInterface;
private final Map<Method, MapperMethod> methodCache = new ConcurrentHashMap<>();
public MapperProxyFactory(Class<T> mapperInterface) {
this.mapperInterface = mapperInterface;
}
public T newInstance(SqlSession sqlSession) {
final MapperProxy<T> mapperProxy = new MapperProxy<>(sqlSession, mapperInterface, methodCache);
return (T) Proxy.newProxyInstance(mapperInterface.getClassLoader(),
new Class[] { mapperInterface },
mapperProxy);
}
}
设计解读:
- 代理模式:JDK 动态代理要求目标必须是接口。MyBatis 将
MapperProxy(InvocationHandler实现)与接口绑定,任何对接口方法的调用都会被MapperProxy.invoke拦截。 - 方法缓存:
methodCache是一个ConcurrentHashMap<Method, MapperMethod>,因为同一 Mapper 接口的不同方法对应不同的MapperMethod,缓存避免了重复解析方法签名的开销。
1.2 MapperProxy.invoke:拦截的三大逻辑分支
MapperProxy 实现了 InvocationHandler,其 invoke 方法对所有方法调用进行统一拦截。内部需要区分三种情况:Object 类的方法、接口的 default 方法、真正的 Mapper 方法。
// org.apache.ibatis.binding.MapperProxy
public class MapperProxy<T> implements InvocationHandler, Serializable {
private final SqlSession sqlSession;
private final Class<T> mapperInterface;
private final Map<Method, MapperMethod> methodCache;
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 如果是 Object 声明的方法,直接执行
if (Object.class.equals(method.getDeclaringClass())) {
return method.invoke(this, args);
}
// 如果是 default 方法,使用 MethodHandle 或常规反射执行
if (method.isDefault()) {
return invokeDefaultMethod(proxy, method, args);
}
// 否则为 Mapper 方法,从缓存中取出 MapperMethod 并执行
final MapperMethod mapperMethod = cachedMapperMethod(method);
return mapperMethod.execute(sqlSession, args);
}
private MapperMethod cachedMapperMethod(Method method) {
return methodCache.computeIfAbsent(method, k -> new MapperMethod(mapperInterface, method, sqlSession.getConfiguration()));
}
// ... invokeDefaultMethod 实现略
}
设计解读:
- 职责分离:
MapperProxy仅仅承担方法路由的职责,具体的 SQL 执行逻辑委托给MapperMethod,符合单一职责原则。 - Object 方法处理:例如
toString()、hashCode(),如果不加判断直接按 Mapper 方法处理,会导致从Configuration中查找不存在的 SQL 语句而报错。 - Default 方法支持:Java 8 后允许接口有默认实现,MyBatis 也支持直接调用默认方法,而非强制映射为 SQL。
1.3 MapperProxy 动态代理序列图
下面用序列图展示从客户端调用 userMapper.selectById(1) 到 SqlSession 被触发的完整拦截链路。
sequenceDiagram
participant Client
participant MapperProxy
participant MapperMethod
participant SqlSession
participant Executor
Client->>MapperProxy: selectById(1)
MapperProxy->>MapperProxy: invoke(Object proxy, Method method, Object[] args)
alt 方法属于 Object
MapperProxy-->>Client: 直接执行 Object 方法
else default 方法
MapperProxy-->>Client: 执行接口默认实现
else Mapper 方法
MapperProxy->>MapperProxy: cachedMapperMethod(method)
MapperProxy->>MapperMethod: execute(sqlSession, args)
MapperMethod->>SqlSession: selectOne(statement, parameter)
SqlSession->>Executor: query(ms, parameter, ...)
Executor-->>SqlSession: 返回结果
SqlSession-->>MapperMethod: 返回结果
MapperMethod-->>MapperProxy: 返回结果
end
MapperProxy-->>Client: 返回 User 对象
图表主旨概括: 该序列图清晰描述了 MapperProxy.invoke 如何根据方法类型分支,将 Mapper 方法的调用最终路由到 SqlSession。
逐层/逐元素分解:
- Client 调用的对象实际上是 JDK 动态代理生成的
Proxy实例,其内部持有MapperProxy作为InvocationHandler。 - MapperProxy 接收到调用后,通过
method.getDeclaringClass()判断方法来源,区分 Object、default 和业务方法。 - 对于业务方法,从缓存获取
MapperMethod,并调用execute。MapperMethod进一步根据 SQL 命令类型调用SqlSession的不同方法。 SqlSession再将请求委托给Executor(前文已详述)。
设计原理映射: 这是典型的代理模式。MapperProxy 充当了真实访问对象(即 SqlSession 中的操作)的代理,控制了访问路径,并且可以在方法调用前后添加额外的逻辑(如方法缓存)。
工程联系与关键结论: MapperProxy 是整个映射器机制的入口,它将无侵入的接口定义与 MyBatis 内核无缝连接,开发者面对接口编程的背后,是动态代理在静默工作。
2. MapperMethod:从方法调用到 SQL 命令的映射
当 MapperProxy 将调用委派给 MapperMethod 后,后者需要根据方法签名决定要执行什么类型的 SQL 命令,以及如何处理参数和返回值。MapperMethod 内部由两个静态内部类完成这项任务:SqlCommand 和 MethodSignature。
2.1 SqlCommand:解析 SQL 标识与类型
SqlCommand 持有 MappedStatement 的唯一标识(格式为 namespace.id,如 com.xxx.UserMapper.selectById)和 SQL 命令类型(SqlCommandType)。
// org.apache.ibatis.binding.MapperMethod.SqlCommand
public static class SqlCommand {
private final String name; // 如 "com.xxx.UserMapper.selectById"
private final SqlCommandType type; // SELECT, INSERT, UPDATE, DELETE, FLUSH
public SqlCommand(Configuration configuration, Class<?> mapperInterface, Method method) {
final String methodName = method.getName();
final Class<?> declaringClass = method.getDeclaringClass();
// 1. 从 Configuration 中查找 MappedStatement
MappedStatement ms = resolveMappedStatement(mapperInterface, methodName, declaringClass, configuration);
if (ms == null) {
// 2. 如果是 FLUSH 注解的方法,特殊处理
if (method.isAnnotationPresent(Flush.class)) {
name = null;
type = SqlCommandType.FLUSH;
} else {
throw new BindingException("Invalid bound statement (not found): " + mapperInterface.getName() + "." + methodName);
}
} else {
name = ms.getId();
type = ms.getSqlCommandType();
if (type == SqlCommandType.UNKNOWN) {
throw new BindingException("Unknown execution method for: " + name);
}
}
}
// resolveMappedStatement 方法会在接口及其父接口中递归查找绑定的 SQL
}
设计解读:
- Statement 定位:通过接口全限定名加方法名拼出
statementId,再在Configuration.mappedStatements中查找对应的MappedStatement。这要求 XML 的namespace必须和接口全限定名完全一致,<select>等标签的id必须和方法名一致。 - FLUSH 命令:如果方法标注了
@Flush注解,则视为批处理刷新命令。
2.2 MethodSignature:解析返回类型与参数
MethodSignature 负责解析方法的返回类型是否是多结果集(returnsMany)、是否返回 Map、Cursor 或者 void,以及如何处理 @Param 注解等。
public static class MethodSignature {
private final boolean returnsMany; // 返回 List 或 Collection
private final boolean returnsMap; // 返回 Map
private final boolean returnsVoid; // 返回 void
private final boolean returnsCursor; // 返回 Cursor
private final boolean returnsOptional; // 返回 Optional
private final Class<?> returnType; // 返回类型
private final String mapKey; // @MapKey 的 value
private final Integer resultHandlerIndex; // ResultHandler 参数索引
private final Integer rowBoundsIndex; // RowBounds 参数索引
private final ParamNameResolver paramNameResolver; // 参数名解析器
public MethodSignature(Configuration configuration, Class<?> mapperInterface, Method method) {
// 通过反射解析方法泛型、返回值、参数注解
// ... 赋值过程略
}
}
2.3 MapperMethod.execute:根据 SqlCommandType 分发
MapperMethod.execute 根据 SqlCommandType 调用 SqlSession 的对应方法,并处理返回值的包装。
public Object execute(SqlSession sqlSession, Object[] args) {
Object result;
switch (command.getType()) {
case INSERT: {
Object param = method.convertArgsToSqlCommandParam(args);
result = rowCountResult(sqlSession.insert(command.getName(), param));
break;
}
case UPDATE: {
Object param = method.convertArgsToSqlCommandParam(args);
result = rowCountResult(sqlSession.update(command.getName(), param));
break;
}
case DELETE: {
Object param = method.convertArgsToSqlCommandParam(args);
result = rowCountResult(sqlSession.delete(command.getName(), param));
break;
}
case SELECT:
if (method.returnsVoid() && method.hasResultHandler()) {
executeWithResultHandler(sqlSession, args);
result = null;
} else if (method.returnsMany()) {
result = executeForMany(sqlSession, args);
} else if (method.returnsMap()) {
result = executeForMap(sqlSession, args);
} else if (method.returnsCursor()) {
result = executeForCursor(sqlSession, args);
} else {
Object param = method.convertArgsToSqlCommandParam(args);
result = sqlSession.selectOne(command.getName(), param);
if (method.returnsOptional() && (result == null || !method.getReturnType().equals(result.getClass()))) {
result = Optional.ofNullable(result);
}
}
break;
case FLUSH:
result = sqlSession.flushStatements();
break;
default:
throw new BindingException("Unknown execution method for: " + command.getName());
}
// 如果返回类型是基本类型且结果为null,且方法签名要求原始类型,则抛出异常
if (result == null && method.getReturnType().isPrimitive() && !method.returnsVoid()) {
throw new BindingException("Mapper method '" + command.getName() + "' attempted to return null from a method with a primitive return type.");
}
return result;
}
MapperMethod 流程图
flowchart TD
Start([MapperProxy 调用 MapperMethod.execute]) --> CheckType{SqlCommandType?}
CheckType -->|INSERT/UPDATE/DELETE| ConvertParam[method.convertArgsToSqlCommandParam<br/>组装参数]
ConvertParam --> ExecuteUpdate[调用 sqlSession.insert/update/delete]
ExecuteUpdate --> RowCount[返回影响行数]
CheckType -->|SELECT| AnalyzeReturn[分析 MethodSignature 返回类型]
AnalyzeReturn -->|returnsVoid+ResultHandler| ExecuteHandler[executeWithResultHandler]
AnalyzeReturn -->|returnsMany| ExecuteList[sqlSession.selectList]
AnalyzeReturn -->|returnsMap| ExecuteMap[sqlSession.selectMap]
AnalyzeReturn -->|returnsCursor| ExecuteCursor[sqlSession.selectCursor]
AnalyzeReturn -->|单一对象| ExecuteOne[sqlSession.selectOne]
AnalyzeReturn -->|Optional| ExecuteOneOptional[sqlSession.selectOne<br/>然后包装 Optional]
CheckType -->|FLUSH| Flush[sqlSession.flushStatements]
RowCount --> CheckPrimitive{返回类型是原始类型<br/>且结果为 null?}
ExecuteHandler --> CheckPrimitive
ExecuteList --> CheckPrimitive
ExecuteMap --> CheckPrimitive
ExecuteCursor --> CheckPrimitive
ExecuteOne --> CheckPrimitive
ExecuteOneOptional --> CheckPrimitive
Flush --> ReturnResult[返回结果]
CheckPrimitive -->|是| ThrowException[抛出 BindingException]
CheckPrimitive -->|否| ReturnResult
图表主旨概括: 该流程图展示了 MapperMethod.execute 如何根据 SQL 命令类型及方法签名,分发到不同的 SqlSession 方法,并最终处理返回值。
逐层/逐元素分解:
- INSERT/UPDATE/DELETE 类操作统一将参数转换为 SQL 命令参数,调用
SqlSession的更新方法,并将结果转换为方法签名要求的行数类型。 - SELECT 分支最为复杂,需要根据
MethodSignature的标志位选择selectOne、selectList、selectMap或使用ResultHandler。 - 在返回结果之前,还会对原始返回类型为基本类型且结果为 null 的情况进行防御性检查。
设计原理映射: MapperMethod 充当了策略模式的上下文,它持有一个 SqlCommand 和 MethodSignature,根据类型执行不同的分支。同时它也是方法调用与 SqlSession 之间的适配器。
工程联系与关键结论: 理解 MapperMethod 的分支逻辑,有助于快速定位“为什么我的 selectOne 返回了 null 却抛异常了”或“方法名相同但返回 List 和返回单个对象有什么区别”之类的问题。
3. XML 映射文件解析:XMLMapperBuilder 与 MappedStatement
MyBatis 的启动过程中,XMLConfigBuilder 解析 mybatis-config.xml 全局配置,然后通过 <mappers> 标签触发 XMLMapperBuilder 对每个 mapper.xml 的解析。
3.1 XMLMapperBuilder 解析入口
XMLMapperBuilder 继承自 BaseBuilder,其 parse() 方法会从 <mapper> 根元素开始,解析命名空间和各个子元素。
// org.apache.ibatis.builder.xml.XMLMapperBuilder
public class XMLMapperBuilder extends BaseBuilder {
private final XPathParser parser;
private final String resource;
private String currentNamespace;
public void parse() {
// 检查该 resource 是否已被加载,防止重复解析
if (!configuration.isResourceLoaded(resource)) {
configurationElement(parser.evalNode("/mapper")); // 解析 <mapper> 节点
configuration.addLoadedResource(resource); // 标记已加载
bindMapperForNamespace(); // 绑定 Mapper 接口
}
// 处理解析过程中产生的未完成结果映射等
parsePendingResultMaps();
// ...
}
private void configurationElement(XNode context) {
try {
String namespace = context.getStringAttribute("namespace");
if (namespace == null || namespace.isEmpty()) {
throw new BuilderException("Mapper's namespace cannot be empty");
}
this.currentNamespace = namespace; // 设置命名空间
// 依次解析各个子元素
parameterMapElement(context.evalNodes("/mapper/parameterMap"));
resultMapElements(context.evalNodes("/mapper/resultMap"));
sqlElement(context.evalNodes("/mapper/sql"));
buildStatementFromContext(context.evalNodes("select|insert|update|delete"));
} catch (Exception e) {
throw new BuilderException("Error parsing Mapper XML. Cause: " + e, e);
}
}
}
设计解读:
- 建造者模式:
XMLMapperBuilder自身并不直接构建最终的Configuration,而是逐步解析 XML,将构建细节委托给XMLStatementBuilder、MapperBuilderAssistant等更专用的建造者,然后将它们组装到全局Configuration对象中。 - 命名空间:
namespace是 Mapper 接口全限定名,也是生成MappedStatement.id的前缀。
3.2 XMLStatementBuilder:构建单个 MappedStatement
对于每个 <select>、<insert>、<update>、<delete> 节点,调用 buildStatementFromContext 内部会创建 XMLStatementBuilder 并调用 parseStatementNode()。
// org.apache.ibatis.builder.xml.XMLStatementBuilder
public void parseStatementNode() {
String id = context.getStringAttribute("id");
String databaseId = context.getStringAttribute("databaseId");
// 根据数据库厂商标识决定是否使用该 SQL
if (!databaseIdMatchesCurrent(id, databaseId, this.requiredDatabaseId)) {
return;
}
Integer fetchSize = context.getIntAttribute("fetchSize");
Integer timeout = context.getIntAttribute("timeout");
String parameterType = context.getStringAttribute("parameterType");
Class<?> parameterTypeClass = resolveClass(parameterType);
String resultMap = context.getStringAttribute("resultMap");
String resultType = context.getStringAttribute("resultType");
// 解析 <selectKey>、SQL 源码
// ...
boolean useCache = context.getBooleanAttribute("useCache", isSelect);
boolean flushCache = context.getBooleanAttribute("flushCache", !isSelect);
// 构建 SqlSource:先解析动态 SQL 标签或直接文本
SqlSource sqlSource = langDriver.createSqlSource(configuration, context, parameterTypeClass);
String statementType = context.getStringAttribute("statementType", StatementType.PREPARED.name());
// 使用建造者构建 MappedStatement
builderAssistant.addMappedStatement(id, sqlSource, statementType, sqlCommandType,
fetchSize, timeout, parameterMap, parameterTypeClass, resultMap, resultTypeClass,
resultSetType, flushCache, useCache, resultOrdered,
keyGenerator, keyProperty, keyColumn, databaseId, langDriver, resultSets);
}
最终通过 MapperBuilderAssistant.addMappedStatement 方法,利用 MappedStatement.Builder 构建出完整的 MappedStatement 对象,并注册到 Configuration 的 mappedStatements 映射表里。
关键数据结构 MappedStatement:
id:全限定语句 IDsqlCommandType:SELECT/INSERT/UPDATE/DELETEresultMaps:结果映射列表parameterMap:参数映射(已废弃,通常使用内联参数)sqlSource:SQL 源,可以是DynamicSqlSource或RawSqlSourcekeyGenerator、cache等配置
XMLMapperBuilder 解析序列图
sequenceDiagram
participant XMLConfigBuilder
participant XMLMapperBuilder
participant XMLStatementBuilder
participant MapperBuilderAssistant
participant Configuration
XMLConfigBuilder->>XMLMapperBuilder: parse()
XMLMapperBuilder->>XMLMapperBuilder: configurationElement(<mapper>节点)
XMLMapperBuilder->>XMLMapperBuilder: 解析 namespace 并设为 currentNamespace
loop 子元素处理
XMLMapperBuilder->>XMLMapperBuilder: resultMapElements()
XMLMapperBuilder->>XMLMapperBuilder: sqlElement()
XMLMapperBuilder->>XMLMapperBuilder: buildStatementFromContext()
end
XMLMapperBuilder->>XMLStatementBuilder: parseStatementNode()
XMLStatementBuilder->>XMLStatementBuilder: 解析属性: id, parameterType, resultMap...
XMLStatementBuilder->>XMLLanguageDriver: createSqlSource()
XMLLanguageDriver-->>XMLStatementBuilder: 返回 SqlSource
XMLStatementBuilder->>MapperBuilderAssistant: addMappedStatement(id, sqlSource, ...)
MapperBuilderAssistant->>MappedStatement.Builder: build()
MappedStatement.Builder-->>MapperBuilderAssistant: MappedStatement 实例
MapperBuilderAssistant->>Configuration: addMappedStatement(ms)
XMLMapperBuilder-->>XMLConfigBuilder: 解析完成
图表主旨概括: 序图展示了 XMLMapperBuilder 如何逐步解析 mapper.xml,并将各个 <select>、<resultMap> 等元素转变为 Configuration 中注册的 MappedStatement 对象。
逐层/逐元素分解:
XMLMapperBuilder作为顶层解析器,控制解析顺序,确保resultMap在statement之前解析,因为后者可能引用前者。XMLStatementBuilder负责单个 SQL 节点的全部属性提取与校验。XMLLanguageDriver则根据 SQL 节点是否包含动态标签,创建DynamicSqlSource或RawSqlSource。MapperBuilderAssistant利用建造者模式组装MappedStatement,并注入到Configuration这个注册中心。
设计原理映射: 整个解析过程是建造者模式的典型应用。XMLMapperBuilder 和 XMLStatementBuilder 分别担任不同粒度的建造者,将 XML 配置文档这一“原材料”逐步加工成领域对象 MappedStatement。
工程联系与关键结论: 如果启动时遇到“Invalid bound statement (not found)”错误,实质是 MappedStatement 的注册环节出了问题。常见原因包括 namespace 与接口全限定名不匹配,或 XML 文件未被扫描到。
4. 动态 SQL 引擎:SqlNode 体系与 Ognl 表达式求值
MyBatis 的动态 SQL 是其核心特性之一。在解析 XML 时,如果 SQL 节点包含 <if>、<where>、<foreach> 等标签,XMLLanguageDriver 会创建 DynamicSqlSource,并通过 XMLScriptBuilder 将 XML 节点树转换为 SqlNode 抽象语法树。
4.1 SqlNode 体系:解释引擎的核心
SqlNode 接口只有一个方法:
boolean apply(DynamicContext context);
每个动态标签都对应一个 SqlNode 实现类:
- MixedSqlNode:组合多个子节点,按顺序依次调用
apply。 - IfSqlNode:持有 Ognl 表达式和子节点,当表达式计算结果为
true时应用子节点。 - ChooseSqlNode:类似
switch,依次尝试when子句。 - TrimSqlNode 和 WhereSqlNode、SetSqlNode:处理前后缀修剪。
- ForeachSqlNode:遍历集合,循环应用子节点。
- TextSqlNode:纯文本 SQL 片段,处理
${}占位符(直接拼接)。 - StaticTextSqlNode:静态文本,不包含任何占位符。
解释器模式体现:每个 SqlNode 是一个终结符或非终结符表达式。DynamicContext 是上下文,记录 SQL 片段和参数。apply 方法负责解释动态部分并将结果追加到上下文的 SQL 构建器中。
4.2 IfSqlNode 与 Ognl 求值
// org.apache.ibatis.scripting.xmltags.IfSqlNode
public class IfSqlNode implements SqlNode {
private final ExpressionEvaluator evaluator;
private final String test; // 例如: "name != null"
private final SqlNode contents; // 条件为真时的 SQL 节点
@Override
public boolean apply(DynamicContext context) {
if (evaluator.evaluateBoolean(test, context.getBindings())) {
contents.apply(context);
return true;
}
return false;
}
}
ExpressionEvaluator 内部封装了 Ognl 引擎:
// org.apache.ibatis.scripting.xmltags.ExpressionEvaluator
public boolean evaluateBoolean(String expression, Object parameterObject) {
Object value = OgnlCache.getValue(expression, parameterObject);
if (value instanceof Boolean) return (Boolean) value;
if (value instanceof Number) return !new BigDecimal(String.valueOf(value)).equals(BigDecimal.ZERO);
return value != null;
}
OgnlCache 会缓存解析后的 Ognl 表达式树,以提高性能。
4.3 ForeachSqlNode:遍历参数集合
// org.apache.ibatis.scripting.xmltags.ForEachSqlNode
public class ForEachSqlNode implements SqlNode {
private final ExpressionEvaluator evaluator;
private final String collectionExpression; // 集合表达式
private final SqlNode contents;
private final String open; // 前缀
private final String close; // 后缀
private final String separator; // 分隔符
private final String item; // 元素变量名
private final String index; // 索引变量名
@Override
public boolean apply(DynamicContext context) {
Map<String, Object> bindings = context.getBindings();
final Iterable<?> iterable = evaluator.evaluateIterable(collectionExpression, bindings);
boolean first = true;
applyOpen(context);
for (Object o : iterable) {
DynamicContext oldContext = context;
if (first) {
first = false;
} else {
applySeparator(context);
}
context = new PrefixedContext(context, ""); // 每次循环用新上下文隔离
bindings.put(item, o); // 绑定 item 变量
if (index != null) bindings.put(index, i); // 绑定 index 变量
contents.apply(context); // 应用子节点
}
applyClose(context);
return true;
}
}
设计解读: ForeachSqlNode 通过为每次迭代创建隔离的上下文,并动态绑定 item 变量,实现了灵活的集合遍历,这是解释器模式处理循环构造的经典做法。
4.4 DynamicSqlSource 与 RawSqlSource
- DynamicSqlSource:持有
SqlNode根节点,每次调用getBoundSql都会重新执行整棵语法树的apply,生成新的静态 SQL 和参数映射。适用于包含了<if>等动态标签的语句。 - RawSqlSource:在构建阶段就已完成 SQL 的解析(包括
#{}替换),持有SqlSource静态实例,每次调用直接返回同一个BoundSql。适用于静态 SQL,避免运行时重复解析。
// org.apache.ibatis.scripting.xmltags.DynamicSqlSource
public class DynamicSqlSource implements SqlSource {
private final Configuration configuration;
private final SqlNode rootSqlNode;
@Override
public BoundSql getBoundSql(Object parameterObject) {
DynamicContext context = new DynamicContext(configuration, parameterObject);
rootSqlNode.apply(context); // 动态生成 SQL 文本
SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
Class<?> parameterType = parameterObject == null ? Object.class : parameterObject.getClass();
// 将 #{} 替换为 ? 并构建 ParameterMapping
SqlSource sqlSource = sqlSourceParser.parse(context.getSql(), parameterType, context.getBindings());
BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
// 将 foreach 等生成的额外上下文绑定合并到 BoundSql
for (Map.Entry<String, Object> entry : context.getBindings().entrySet()) {
boundSql.setAdditionalParameter(entry.getKey(), entry.getValue());
}
return boundSql;
}
}
SqlSourceBuilder.parse 方法会扫描 SQL 文本,将 #{} 替换为 JDBC 的占位符 ?,同时构建一个 ParameterMapping 列表,记录每个占位符对应的属性名、类型处理器等信息。
动态 SQL 引擎解析序列图
sequenceDiagram
participant SqlSession
participant Executor
participant MappedStatement
participant DynamicSqlSource
participant SqlNode
participant Ognl
participant SqlSourceBuilder
participant BoundSql
SqlSession->>Executor: query(statement, parameter)
Executor->>MappedStatement: getBoundSql(parameter)
MappedStatement->>DynamicSqlSource: getBoundSql(parameterObject)
DynamicSqlSource->>DynamicSqlSource: 创建 DynamicContext
DynamicSqlSource->>SqlNode: apply(context) (根节点为 MixedSqlNode)
activate SqlNode
SqlNode->>SqlNode: 遍历子节点 IfSqlNode, ForeachSqlNode 等
loop 每个 IfSqlNode
SqlNode->>Ognl: evaluateBoolean(test, bindings)
Ognl-->>SqlNode: true/false
alt true
SqlNode->>SqlNode: 应用 contents (子节点)
end
end
SqlNode-->>DynamicSqlSource: SQL 文本片段追加到 context
deactivate SqlNode
DynamicSqlSource->>SqlSourceBuilder: parse(context.sql, parameterType, bindings)
SqlSourceBuilder->>SqlSourceBuilder: 正则匹配 #{property} → ?
SqlSourceBuilder->>SqlSourceBuilder: 构建 ParameterMapping 列表
SqlSourceBuilder-->>DynamicSqlSource: StaticSqlSource (包含 ? 占位符的 SQL)
DynamicSqlSource->>StaticSqlSource: getBoundSql(parameter)
StaticSqlSource-->>DynamicSqlSource: BoundSql (SQL, ParameterMappings, parameterObject)
DynamicSqlSource-->>MappedStatement: BoundSql
MappedStatement-->>Executor: BoundSql
图表主旨概括: 此序列图刻画了 DynamicSqlSource 如何通过遍历 SqlNode 语法树、调用 Ognl 求值、再经 SqlSourceBuilder 转换,最终生成可执行的 BoundSql 的完整过程。
逐层/逐元素分解:
DynamicContext提供一个StringBuilder拼接 SQL,以及一个Bindings映射存放参数。- 根 SqlNode 通常为
MixedSqlNode,它内部有序地包含TextSqlNode、IfSqlNode、WhereSqlNode等。 - Ognl 引擎动态求值表达式,决定哪些 SQL 片段被拼入。
- SqlSourceBuilder 将
#{property}替换为?,并产生ParameterMapping,这是 JDBC 参数设置的关键依据。
设计原理映射: 整个动态 SQL 引擎是解释器模式的教科书级范例。SqlNode 体系的每个节点都负责解释一部分语法,DynamicContext 是全局上下文,运行时逐节点解析,最终组合出完整 SQL。SqlSourceBuilder 则使用了建造者模式,从原始 SQL 字符串构建出 StaticSqlSource。
工程联系与关键结论: 动态 SQL 的强大性源于其解释器模式设计,开发者自定义 <if> 条件时,实际上是向 Ognl 引擎传递了基于参数对象的布尔表达式。务必注意 Ognl 表达式中引用的是参数对象的属性名或已绑定的变量,而非数据库列名。
5. 参数映射与结果映射:ParameterHandler 与 ResultSetHandler 的协同
5.1 ParamNameResolver:@Param 注解与参数名解析
为了支持多参数传递,MyBatis 使用 ParamNameResolver 生成参数名映射。当方法有多个参数且没有使用 @Param 注解时,在 JDK 8 环境中可以通过 -parameters 编译选项保留参数名,或使用反射获取(但通常不可靠)。
// org.apache.ibatis.reflection.ParamNameResolver
public ParamNameResolver(Configuration config, Method method) {
// 获取参数类型数组
final Class<?>[] paramTypes = method.getParameterTypes();
final Annotation[][] paramAnnotations = method.getParameterAnnotations();
final SortedMap<Integer, String> map = new TreeMap<>();
int paramCount = paramAnnotations.length;
for (int paramIndex = 0; paramIndex < paramCount; paramIndex++) {
String name = null;
for (Annotation annotation : paramAnnotations[paramIndex]) {
if (annotation instanceof Param) {
name = ((Param) annotation).value();
break;
}
}
if (name == null) {
// 没有 @Param,尝试使用实际参数名(编译保留)或 arg0, arg1...
name = getActualParamName(method, paramIndex);
if (name == null) {
name = String.valueOf(map.size());
}
}
map.put(paramIndex, name);
}
names = Collections.unmodifiableSortedMap(map);
}
解析出的参数名用于构建 ParameterMapping,以及动态 SQL 中 Ognl 上下文中的键。
5.2 DefaultParameterHandler:参数绑定到 PreparedStatement
DefaultParameterHandler 遍历 BoundSql 中的 ParameterMapping 列表,使用 TypeHandler 将参数值设置到 PreparedStatement。
// org.apache.ibatis.scripting.defaults.DefaultParameterHandler
public void setParameters(PreparedStatement ps) {
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
if (parameterMappings != null) {
for (int i = 0; i < parameterMappings.size(); i++) {
ParameterMapping parameterMapping = parameterMappings.get(i);
if (parameterMapping.getMode() != ParameterMode.OUT) {
Object value;
String propertyName = parameterMapping.getProperty();
if (boundSql.hasAdditionalParameter(propertyName)) {
value = boundSql.getAdditionalParameter(propertyName);
} else if (parameterObject == null) {
value = null;
} else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
value = parameterObject;
} else {
MetaObject metaObject = configuration.newMetaObject(parameterObject);
value = metaObject.getValue(propertyName);
}
TypeHandler typeHandler = parameterMapping.getTypeHandler();
JdbcType jdbcType = parameterMapping.getJdbcType();
if (value == null && jdbcType == null) jdbcType = configuration.getJdbcTypeForNull();
typeHandler.setParameter(ps, i + 1, value, jdbcType);
}
}
}
}
5.3 DefaultResultSetHandler:结果集映射
DefaultResultSetHandler.handleResultSets 读取 ResultSet,使用 ResultMap 和自动映射行为创建 Java 对象。
- 显式 ResultMap:通过
<resultMap>定义列与属性的映射,支持嵌套结果映射(<association>、<collection>)和延迟加载。 - 自动映射:根据
autoMappingBehavior配置,尝试将下划线命名的列自动映射到驼峰命名的属性(mapUnderscoreToCamelCase)。
// 核心流程简述
private void handleRowValues(ResultSetWrapper rsw, ResultMap resultMap, ResultHandler<?> resultHandler, RowBounds rowBounds) throws SQLException {
if (resultMap.hasNestedResultMaps()) {
handleRowValuesForNestedResultMap(rsw, resultMap, ...);
} else {
handleRowValuesForSimpleResultMap(rsw, resultMap, ...);
}
}
在映射时,同样使用 TypeHandler 获取列值并设置为属性。
自动映射与显式 resultMap 协作: 如果 <select> 指定了 resultMap,则以显式映射为准,未覆盖的列交由自动映射处理。如果指定 resultType,则完全使用自动映射。
6. 一个 Mapper 方法的完整执行时序
将上述所有组件串联,即可得到一次完整的 Mapper 方法调用生命周期。
sequenceDiagram
participant Client
participant MapperProxy
participant MapperMethod
participant SqlSession
participant CachingExecutor
participant BaseExecutor
participant StatementHandler
participant ParameterHandler
participant ResultSetHandler
participant TypeHandler
Client->>MapperProxy: userMapper.selectById(1)
MapperProxy->>MapperMethod: execute(sqlSession, args)
MapperMethod->>SqlSession: selectOne("com.xxx.UserMapper.selectById", 1)
SqlSession->>CachingExecutor: query(ms, parameter, rowBounds, resultHandler)
CachingExecutor->>BaseExecutor: query(ms, parameter, rowBounds, resultHandler)
BaseExecutor->>MappedStatement: getBoundSql(parameter)
MappedStatement->>DynamicSqlSource: getBoundSql(parameter)
DynamicSqlSource-->>MappedStatement: BoundSql (SQL和ParameterMappings)
BaseExecutor->>BaseExecutor: 创建缓存Key
BaseExecutor->>StatementHandler: prepare(connection, transactionTimeout)
StatementHandler->>ParameterHandler: setParameters(ps)
ParameterHandler->>TypeHandler: setParameter(ps, i, value, jdbcType)
StatementHandler->>StatementHandler: query(ps, resultHandler)
StatementHandler->>ResultSetHandler: handleResultSets(ps)
ResultSetHandler->>TypeHandler: getResult(rs, columnName)
ResultSetHandler-->>StatementHandler: 映射后的对象
StatementHandler-->>BaseExecutor: 结果列表
BaseExecutor-->>CachingExecutor: 结果列表
CachingExecutor-->>SqlSession: 结果列表
SqlSession-->>MapperMethod: 单个 User 对象
MapperMethod-->>MapperProxy: User 对象
MapperProxy-->>Client: User 对象
图表主旨概括: 本时序图展现了一次 selectById 调用从动态代理到结果集映射的完整 18 步链路,涵盖了 MyBatis 所有核心组件的交互。
逐层/逐元素分解: 调用经过 MapperProxy 和 MapperMethod 到达 SqlSession,再穿过二级缓存、一级缓存进入 BaseExecutor。BaseExecutor 获取 BoundSql 并交给 StatementHandler 创建 PreparedStatement;ParameterHandler 负责参数绑定,ResultSetHandler 负责结果映射。 TypeHandler 是连接 Java 类型与 JDBC 类型的桥梁。
设计原理映射: 整个执行链展示了模板方法模式(BaseExecutor 定义了查询骨架,子类实现具体 doQuery)、代理模式(MapperProxy 和 CachingExecutor)、建造者模式(MappedStatement 和 BoundSql 的构建)和解释器模式(SqlNode 处理动态 SQL)。
工程联系与关键结论: 深入理解此全链路时序,是分析 MyBatis 性能瓶颈、SQL 执行异常和插件扩展的基石。任何一环的行为出现预期外,都可以沿着这条链路逐段排查。
7. 设计模式总结与架构思考
7.1 设计模式应用总结
- 代理模式:
MapperProxy为 Mapper 接口创建代理,将方法调用转发到SqlSession;CachingExecutor也是一级/二级缓存的代理。 - 建造者模式:
XMLMapperBuilder、XMLStatementBuilder以及SqlSourceBuilder一步步构建复杂对象(MappedStatement、SqlSource)。MappedStatement.Builder是经典的建造者实现。 - 解释器模式:
SqlNode树解析动态 SQL,每个节点解释一部分动态语义,配合上下文生成最终 SQL。 - 模板方法模式:
BaseExecutor.query定义了缓存查询、数据库查询的骨架,子类SimpleExecutor、ReuseExecutor、BatchExecutor分别实现doQuery等抽象方法;BaseStatementHandler亦是如此。 - 策略模式:
MapperMethod.execute依据SqlCommandType选择执行策略;Executor的选择也可视为策略。
7.2 与 Spring Data Repository 代理对比
Spring Data 也对 Repository 接口进行动态代理,但它的目标是生成基于方法命名约定的查询,最终翻译为 JPA 的 CriteriaQuery 或 MyBatis 的 MappedStatement。Spring Data 的代理同样使用 JDK 动态代理,但其 InvocationHandler 内部会解析方法名 findByLastName,构造查询,而 MyBatis 的 MapperProxy 则是查找预定义好的 MappedStatement。两者都通过代理模式隐藏了实现细节,但映射机制不同:MyBatis 是声明式 XML/注解绑定,Spring Data 是约定命名解析。
8. 生产事故排查专题
8.1 动态 SQL 中 #{} 与 ${} 误用导致 SQL 注入
事故场景:某订单查询接口允许用户输入订单号,开发人员为了灵活拼接表名或排序字段,在 WHERE 条件中使用了 ${orderId}。攻击者输入 ' OR '1'='1 导致全表泄露。
原因:${} 在动态 SQL 中是直接字符串替换,不会经过 ? 占位符和预编译,因此可被注入恶意 SQL。#{} 则会被替换为 ? 并由 TypeHandler 安全设置值。
排查序列图:
sequenceDiagram
participant Client as 攻击者
participant Controller
participant MapperInterface
participant DynamicSqlSource
participant SqlSourceBuilder
participant PreparedStatement
Client->>Controller: orderId = " ' OR '1'='1 "
Controller->>MapperInterface: selectByOrderId(" ' OR '1'='1 ")
MapperInterface->>DynamicSqlSource: getBoundSql(parameter)
DynamicSqlSource->>DynamicSqlSource: context.sql 拼接: SELECT * FROM orders WHERE id = ${orderId}
Note over DynamicSqlSource: 直接替换为: WHERE id = ' OR '1'='1'
DynamicSqlSource->>SqlSourceBuilder: parse(恶意 SQL)
SqlSourceBuilder-->>DynamicSqlSource: StaticSqlSource (恶意SQL已成定局)
DynamicSqlSource-->>PreparedStatement: 执行恶意SQL,泄露数据
修复:将所有 where 条件中的 ${} 替换为 #{}。若确实需要动态表名等,必须在应用层做严格的白名单校验,绝不可直接使用用户输入。
8.2 resultMap 配置不当导致 N+1 查询
事故场景:查询用户列表时,每个用户都关联查询了其订单列表,配置了嵌套查询(<collection select="selectOrdersByUserId" column="id"/>)。接口响应时间从 50ms 飙升至 5s,数据库连接池耗尽。
原因:MyBatis 默认的嵌套查询是“急加载”时延迟加载配置未开启或处理不当,导致对主查询的每一行结果,都会额外执行一次 selectOrdersByUserId 查询,即 N+1 问题。
排查步骤:通过 MyBatis 日志发现大量相同的 SQL 执行;检查 resultMap 中的 <collection>,发现其 fetchType 未设置或为 EAGER,并且 mybatis-config.xml 中 lazyLoadingEnabled 为 false。
修复:
- 开启延迟加载:
<setting name="lazyLoadingEnabled" value="true"/>,并将<collection>的fetchType设为lazy。 - 或改写为嵌套结果映射(联表查询),一次性加载所有数据。
关键结论: 嵌套查询虽写法简便,但务必配合延迟加载使用,否则极易引发 N+1 问题;在性能敏感场景,优先使用嵌套结果(联表)映射。
好的,我们来详细深化面试专题部分。
9. 面试高频专题
9.1 Mapper 接口没有实现类,为什么能执行 SQL?请从 JDK 动态代理源码角度说明。
回答要点:
MyBatis 启动时会扫描 Mapper 接口,并通过 MapperProxyFactory 为每个接口创建一个 MapperProxy 实例(它实现了 InvocationHandler 接口),然后通过 Proxy.newProxyInstance(mapperInterface.getClassLoader(), new Class[]{mapperInterface}, mapperProxy) 生成动态代理对象。当调用 userMapper.selectById(1) 时,JVM 会派发到 MapperProxy.invoke(proxy, method, args)。
在 invoke 方法内部,首先判断是否为 Object 声明的方法(如 toString、hashCode),若是则直接反射执行;其次判断是否为接口的 default 方法,若是则通过 MethodHandle 或常规反射直接执行默认实现;剩下的就是需要映射的 Mapper 方法。Mapper 方法会被委托给 MapperMethod 执行,而 MapperMethod 又通过 SqlCommand 定位到 MappedStatement,最终调用 SqlSession 的 CRUD 方法。整个过程屏蔽了实现细节,是代理模式的经典运用。
9.2 MapperProxy.invoke 内部如何区分 Object 方法、default 方法和 Mapper 方法?源码中做了哪些特殊处理?
回答要点:
在 invoke 方法中:
- Object 方法:通过
method.getDeclaringClass().equals(Object.class)判断,若是则直接执行return method.invoke(this, args),因为这些方法与 SQL 无关。 - default 方法:通过
method.isDefault()判断(Java 8 特性)。如果是默认方法,MyBatis 内部会尝试使用MethodHandle调用,若失败则降级为传统反射调用。这使得 Mapper 接口可以写默认方法来实现一些辅助逻辑,例如default User toUser(Map map) { ... }。 - Mapper 方法:其余方法都视为需要映射的 SQL 方法,通过
cachedMapperMethod(method)获取MapperMethod并调用execute。
特殊处理还包括:methodCache 是一个 ConcurrentHashMap,确保 MapperMethod 仅解析一次,之后直接复用,避免重复解析方法签名的开销。这种缓存设计在高并发下体现了良好的性能。
9.3 请描述 MappedStatement 的结构,并说明它是何时、如何被创建的。
回答要点:
MappedStatement 是 MyBatis 中封装一条 SQL 语句所有元数据的核心领域对象。其关键属性包括:
id:全限定名,格式namespace.statementId。sqlCommandType:枚举,UNKNOWN, INSERT, UPDATE, DELETE, SELECT, FLUSH。sqlSource:提供getBoundSql获取可执行 SQL 和参数映射,可能是DynamicSqlSource或RawSqlSource。resultMaps:结果映射列表(通常只有一个ResultMap),定义了如何将结果集映射到对象。parameterMap:已废弃的参数映射,现在多使用内联参数。cache、keyGenerator、flushCacheRequired、useCache等配置。statementType:STATEMENT, PREPARED, CALLABLE,默认PREPARED。
创建时机发生在解析 Mapper XML 或注解配置时。对 XML 配置,XMLMapperBuilder 解析 mapper.xml 到 select|insert|update|delete 节点时,会为每个节点创建 XMLStatementBuilder 并调用 parseStatementNode()。该方法解析节点属性,然后调用 MapperBuilderAssistant 的 addMappedStatement 方法。此方法内部使用 MappedStatement.Builder 建造者模式构建对象,并注册到 Configuration.mappedStatements(一个 Map<String, MappedStatement>)中。对注解方式,则是通过 MapperAnnotationBuilder 解析 @Select、@Insert 等注解来构建。
9.4 DynamicSqlSource 与 RawSqlSource 的实现区别是什么?为什么 MyBatis 需要区分这两者?
回答要点:
两者都是 SqlSource 的实现,区别在于生成 BoundSql 的方式:
- DynamicSqlSource:持有根
SqlNode(动态 SQL 语法树),每次调用getBoundSql(parameterObject)都会重新创建DynamicContext,然后调用rootSqlNode.apply(context)遍历整个语法树生成 SQL 文本。随后再通过SqlSourceBuilder将#{}替换为?并构建ParameterMapping,返回一个新的BoundSql实例。适用于包含<if>、<foreach>等动态标签的语句,因为每次执行时 SQL 结构可能不同。 - RawSqlSource:在构建阶段(通常是启动时)就已经通过
SqlSourceBuilder完成 SQL 解析,生成一个StaticSqlSource(包含静态 SQL 字符串和参数映射列表),之后每次getBoundSql调用直接返回相同的BoundSql(除参数对象引用外)。适用于纯静态 SQL(无动态标签),避免了运行时重复解析,提升性能。
区分的原因:MyBatis 通过判断 SQL 节点是否包含动态标签,在 XMLLanguageDriver.createSqlSource 时就决定创建哪一种,这是策略模式的体现,既保证灵活性又兼顾性能。
9.5 MyBatis 如何将 <if test="name != null"> 中的 test 表达式转换为布尔值?请结合 Ognl 源码解析。
回答要点:
IfSqlNode.apply 方法通过 ExpressionEvaluator.evaluateBoolean(test, context.getBindings()) 来求值。ExpressionEvaluator 内部调用 OgnlCache.getValue(expression, parameterObject)。OgnlCache 会使用 Ognl 的 OgnlParser 解析表达式字符串生成 Node 语法树,并缓存起来以提高性能。求值时 Ognl 从 context.getBindings() 这个 Map 中获取变量,包括参数对象(如 parameterObject)以及由 foreach 等绑定的临时变量(如 item)。例如 name != null 中的 name 实际上是 parameterObject 的属性,Ognl 通过反射在参数对象中去寻找该属性。如果 name 属性存在且不为 null,表达式返回 Boolean.TRUE,否则 FALSE。这种设计将条件逻辑从 Java 代码分离到 XML 配置中,采用了解释器模式。
9.6 请简述 ForeachSqlNode 的工作原理,它如何在上下文隔离中避免污染?
回答要点:
ForeachSqlNode.apply(context) 首先解析 collectionExpression(如 list),通过 ExpressionEvaluator 获取 Iterable 集合。然后它创建新的 DynamicContext(具体为 PrefixedContext)并叠加到原有上下文之上,以隔离每次迭代的影响。在每次循环开始:
- 第一次迭代拼接
open(如(); - 非首次时拼接
separator(如,); - 将当前元素绑定到 context 的
bindings中,key 为item属性的值,同时绑定可选的index; - 调用子
SqlNode应用(通常包含TextSqlNode和IfSqlNode等),子节点从上下文中取值时会拿到迭代变量的值。
循环结束后拼接 close。通过这种上下文切换,完美实现了“循环体内部可以安全引用 item 变量,且拼接出的 SQL 片段包含正确的分隔符”的效果。这种处理是解释器模式中对循环结构的实现,利用局部上下文保证了正确性。
9.7 #{} 和 ${} 的本质区别是什么?为什么推荐使用 #{}?请从 SQL 注入和预编译原理分析。
回答要点: 从源码角度:
#{}解析:SqlSourceBuilder.parse使用正则表达式\\#\\{([\\w\\.\\[\\]]+)\\}匹配,将其替换为 JDBC 的占位符?,同时生成一个ParameterMapping记录属性名、类型处理器等。在执行时,DefaultParameterHandler.setParameters遍历这些映射,通过TypeHandler.setParameter将值安全地设置到PreparedStatement的对应索引上。JDBC 驱动会对参数值进行转义和类型检查,从根本上防止 SQL 注入。${}解析:TextSqlNode.apply方法在遍历时,如果遇到${}部分,会调用GenericTokenParser配合BindingTokenParser进行直接字符串替换。BindingTokenParser从 Ognl 上下文中获取变量的值,然后将其转为字符串直接拼接到 SQL 文本中,这个拼接过程发生在BoundSql生成阶段,之后 SQL 就作为固定字符串发给数据库。用户输入的任何特殊字符都会成为 SQL 的一部分,造成注入风险。
因此,除非是动态表名、列名等无法使用占位符的场景,并且经过严格白名单校验,否则应始终使用 #{}。这是构建安全应用的黄金法则。
9.8 ParameterMapping 是在什么阶段生成的?它包含哪些信息?在参数设置中如何发挥作用?
回答要点:
ParameterMapping 在 SqlSourceBuilder.parse 方法中生成,该方法遍历 SQL 语句字符串,匹配每个 #{} 占位符,为每个占位符创建一个 ParameterMapping 对象。它包含以下关键字段:
property:参数属性名,如"name"或"user.name"。javaType:属性对应的 Java 类型,如String.class,用于选择合适的TypeHandler。jdbcType:数据库列类型,如VARCHAR,可能由parameterMap或#{property,jdbcType=VARCHAR}指定。typeHandler:负责 Java 类型到 JDBC 类型的转换。mode:参数模式,默认IN,也可为OUT或INOUT(用于存储过程)。
在 DefaultParameterHandler.setParameters 中,按索引遍历 ParameterMapping 列表,每个映射对应 PreparedStatement 的一个参数位置。它通过 property 从参数对象中取值(例如使用 MetaObject 反射),然后用 typeHandler.setParameter(ps, i, value, jdbcType) 将值设置到预编译语句中。这个机制使得参数绑定完全解耦于 SQL 构建。
9.9 ResultMap 中的自动映射与显式映射是如何协同工作的?嵌套查询中的延迟加载原理是什么?
回答要点:
- 协同工作:
DefaultResultSetHandler在处理每一行结果时,会先根据resultMap的显式映射(即<result column="id" property="id"/>)进行赋值;对于未在显式映射中出现的列,则根据autoMappingBehavior配置决定是否进行自动映射。若开启(默认PARTIAL,即只对未明确映射的列且没有嵌套映射时使用),会尝试将下划线列名自动转为驼峰属性名(如user_name→userName),并使用对应的TypeHandler进行类型转换。显式映射优先级高于自动映射。 - 嵌套查询延迟加载:当
resultMap中配置了<association select="xxx" column="id" fetchType="lazy"/>,MyBatis 不会立即执行select,而是创建一个代理对象(CGLIB 或 Javassist)。当程序第一次访问该代理对象的非延迟属性或调用equals/hashCode/toString时,代理会触发拦截器去执行实际的select查询,并填充目标属性。此机制依赖于lazyLoadingEnabled=true全局配置和aggressiveLazyLoading行为。注意延迟加载需要在同一个SqlSession生命周期内,否则会报LazyInitializationException。
9.10 如何在实际开发中排查 MyBatis 动态 SQL 生成的最终语句与预期不符的问题?请给出排错步骤。
回答要点: 通常可通过以下步骤系统排查:
- 启用 MyBatis 日志:在
mybatis-config.xml中配置<setting name="logImpl" value="SLF4J"/>,并在日志框架(如 Logback)中将 Mapper 接口包路径设为DEBUG级别。此时控制台会打印类似==> Preparing: SELECT * FROM user WHERE id = ?和==> Parameters: 1(Integer)的日志。 - 分析参数绑定:如果 SQL 结构与预期不符(如缺少条件),检查动态 SQL 标签的
test表达式,例如<if test="name != null and name != ''">中的变量名是否与参数对象的属性名一致,是否因为参数未传递导致 Ognl 求值为 false。 - 利用第三方插件:如 MyBatis Log Plugin(IDEA 插件)可以直接还原完整的可执行 SQL。
- 自定义插件拦截:实现
Interceptor拦截Executor.update或query方法,在BoundSql中获取getSql()和getParameterMappings(),结合参数打印完整 SQL,方便调试。 - Debug 源码:在
DynamicSqlSource.getBoundSql中设置断点,查看context.getSql()的内容和context.getBindings()中的参数,明确定位问题节点。
9.11 请列举 MyBatis 映射器模块用到的设计模式,并结合源码说明其对扩展性的贡献。
回答要点:
- 代理模式:
MapperProxy为 Mapper 接口生成代理,隐藏了与SqlSession交互的复杂性。若需要添加对 Mapper 方法的统一监控或限流,只需在invoke方法中添加切面逻辑,无需修改业务代码。 - 建造者模式:
XMLStatementBuilder和MapperBuilderAssistant逐步构建MappedStatement对象。该模式使得解析逻辑清晰,扩展新的 SQL 节点属性时可在建造过程中加入新参数,而不破坏构建流程。 - 解释器模式:
SqlNode体系是自解释的动态 SQL 语法树。如果想增加一个新的动态标签(如自定义<bind>增强),只需新增一个SqlNode实现并在XMLScriptBuilder中注册即可,修改封闭但扩展开放。 - 模板方法模式:
BaseExecutor.query定义了查询的骨架(缓存处理、延迟加载清理等),具体的doQuery由子类SimpleExecutor、ReuseExecutor、BatchExecutor实现。想要扩展新的 Executor 只需继承BaseExecutor并实现抽象方法,执行流程被复用。 - 策略模式:
MapperMethod.execute依据SqlCommandType选择不同的执行分支,实际上就是策略模式。新增一种命令类型(如TRUNCATE)时,可以增加新的case分支处理,保持了扩展性。
9.12 系统设计题:请设计一个简化版的 MyBatis Mapper 映射框架,支持接口方法到 SQL 的自动映射,并处理动态条件。
分析与设计要点:
- 核心要求:实现一个 “零实现 Mapper” 框架,通过 JDK 动态代理将接口方法与预定义的 SQL 绑定,同时支持类似
<if>的简单动态条件。 - 核心组件设计:
- 配置解析器:解析 YAML/XML 配置文件,读取
namespace和<select>/<update>节点,构建Statement对象(存储 SQL 模板和参数信息)。可以使用 DOM 或 SAX 解析。 - MapperProxy:实现
InvocationHandler,内部维护一个Map<String, Statement>。在invoke方法中,拼接namespace.methodName得到 Statement 键,然后获取对应 SQL 模板。 - 简易动态 SQL 引擎:支持
#{property}和${property},并提供<if test="expression">的处理。实现一个简单的SqlNode体系,将 SQL 模板解析为TextSqlNode和IfSqlNode的混合。使用 Ognl 或 Spring EL 表达式求值test条件。动态生成最终 SQL 和参数映射List<String>。 - 参数映射与执行:通过反射从方法参数中提取值,利用
PropertyTokenizer嵌套取值(如user.name)。构建PreparedStatement,按?占位符顺序设置参数。 - 结果映射:支持简单的自动结果映射,将
ResultSet通过resultType反射创建对象并利用BeanWrapper设置属性。
- 配置解析器:解析 YAML/XML 配置文件,读取
- 设计模式体现:代理模式(MapperProxy)、建造者模式(Statement 构建)、解释器模式(SqlNode)、策略模式(根据 SQL 命令类型调用不同 JDBC 方法)。
- 面试时可展示的类图与时序:
**UserMapper**接口 →MapperProxy.invoke→SimpleSqlNodeEngine.apply→JdbcTemplate.query。
延伸阅读
- MyBatis 官方文档:mybatis.org/mybatis-3/z…
- Ognl 表达式引擎:commons.apache.org/proper/comm…
- “MyBatis 源码解析系列”:探索
Executor和缓存模块的更多细节。