在前面两讲中,我们详细介绍了 mybatis-config.xml 全局配置文件以及 Mapper.xml 映射文件的解析流程,MyBatis 会将 Mapper 映射文件中定义的 SQL 语句解析成 SqlSource 对象,其中的动态标签、SQL 语句文本等,会解析成对应类型的 SqlNode 对象。
在开始介绍 SqlSource 接口、SqlNode 接口等核心接口的相关内容之前,我们需要先来了解一下动态 SQL 中使用到的基础知识和基础组件。
OGNL 表达式语言是一款成熟的、面向对象的表达式语言。在动态 SQL 语句中使用到了 OGNL 表达式读写 JavaBean 属性值、执行 JavaBean 方法这两个基础功能。
OGNL 表达式是相对完备的一门表达式语言,我们可以通过“对象变量名称.方法名称(或属性名称)”调用一个 JavaBean 对象的方法(或访问其属性),还可以通过“@[类的完全限定名]@[静态方法(或静态字段)]”调用一个 Java 类的静态方法(或访问静态字段)。OGNL 表达式还支持很多更复杂、更强大的功能,这里不再一一介绍。
下面我就通过一个示例来帮助你快速了解 OGNL 表达式的基础使用:
public class OGNLDemo {
private static Customer customer;
private static OgnlContext context;
private static Customer createCustomer() {
customer = new Customer();
customer.setId(1);
customer.setName("Test Customer");
customer.setPhone("1234567");
Address address = new Address();
address.setCity("city-001");
address.setId(1);
address.setCountry("country-001");
address.setStreet("street-001");
ArrayList<Address> addresses = new ArrayList<>();
addresses.add(address);
customer.setAddresses(addresses);
return customer;
}
public static void main(String[] args) throws Exception {
customer = createCustomer(); // 创建Customer对象以及Address对象
// 创建OgnlContext上下文对象
context = new OgnlContext(new DefaultClassResolver(),
new DefaultTypeConverter(),
new OgnlMemberAccess());
// 设置root以及address这个key,默认从root开始查找属性或方法
context.setRoot(customer);
context.put("address", customer.getAddresses().get(0));
// Ognl.paraseExpression()方法负责解析OGNL表达式,获取Customer的addresses属性
Object obj = Ognl.getValue(Ognl.parseExpression("addresses"),
context, context.getRoot());
System.out.println(obj);
// 输出是[Address{id=1, street='street-001', city='city-001', country='country-001'}]
// 获取city属性
obj = Ognl.getValue(Ognl.parseExpression("addresses[0].city"),
context, context.getRoot());
System.out.println(obj); // 输出是city-001
// #address表示访问的不是root对象,而是OgnlContext中key为addresses的对象
obj = Ognl.getValue(Ognl.parseExpression("#address.city"), context,
context.getRoot());
System.out.println(obj); // 输出是city-001
// 执行Customer的getName()方法
obj = Ognl.getValue(Ognl.parseExpression("getName()"), context,
context.getRoot());
System.out.println(obj);
// 输出是Test Customer
}
}
MyBatis 为了提高 OGNL 表达式的工作效率,添加了一层 OgnlCache 来缓存表达式编译之后的结果(不是表达式的执行结果),OgnlCache 通过一个 ConcurrentHashMap
在 MyBatis 解析一条动态 SQL 语句的时候,可能整个流程非常长,其中涉及多层方法的调用、方法的递归、复杂的循环等,其中产生的中间结果需要有一个地方进行存储,那就是 DynamicContext 上下文对象。
DynamicContext 中有两个核心属性:一个是 sqlBuilder 字段(StringJoiner 类型),用来记录解析之后的 SQL 语句;另一个是 bindings 字段,用来记录上下文中的一些 KV 信息。
DynamicContext 定义了一个 ContextMap 内部类,ContextMap 用来记录运行时用户传入的、用来替换“#{}”占位符的实参。在 DynamicContext 构造方法中,会根据传入的实参类型决定如何创建对应的 ContextMap 对象,核心代码如下:
public DynamicContext(Configuration configuration, Object parameterObject) {
if (parameterObject != null && !(parameterObject instanceof Map)) {
// 对于非Map类型的实参,会创建对应的MetaObject对象,并封装成ContextMap对象
MetaObject metaObject = configuration.newMetaObject(parameterObject);
boolean existsTypeHandler = configuration.getTypeHandlerRegistry().hasTypeHandler(parameterObject.getClass());
bindings = new ContextMap(metaObject, existsTypeHandler);
} else {
// 对于Map类型的实参,这里会创建一个空的ContextMap对象
bindings = new ContextMap(null, false);
}
// 这里实参对应的Key是_parameter
bindings.put(PARAMETER_OBJECT_KEY, parameterObject);
bindings.put(DATABASE_ID_KEY, configuration.getDatabaseId());
}
ContextMap 继承了 HashMap 并覆盖了 get() 方法,在 get() 方法中有一个简单的降级逻辑:
后面在介绍 <foreach>
、<trim>
等标签的处理逻辑中,你可以看到向 DynamicContext.bindings 集合中写入 KV 数据的操作,但是读取这个 ContextMap 的地方主要是在 OGNL 表达式中,也就是在 DynamicContext 中定义了一个静态代码块,指定了 OGNL 表达式读写 ContextMap 集合的逻辑,这部分读取逻辑封装在 ContextAccessor 中。除此之外,你还可以看到 ContextAccessor 中的 getProperty() 方法会将传入的 target 参数(实际上就是 ContextMap)转换为 Map,并先尝试按照 Map 规则进行查找;查找失败之后,会尝试获取“_parameter”对应的 parameterObject 对象,从 parameterObject 中获取指定的 Value 值。
组合模式(有时候也被称为“部分-整体”模式)是将同一类型的多个对象组合成一个树形结构。在使用这个树形结构的时候,我们可以像处理一个对象那样进行处理,而不用关心其复杂的树形结构。
组合模式的核心结构如下图所示:
从上图中,我们可以看出组合模式的核心组件有下面三个。
通过以上对组合模式的介绍,你可以看出组合模式有以下两个优势:
在 MyBatis 处理动态 SQL 语句的时候,会将动态 SQL 标签解析为 SqlNode 对象,多个 SqlNode 对象就是通过组合模式组成树形结构供上层使用的。
下面我们就来讲解一下 SqlNode 的相关实现。首先,介绍一下 SqlNode 接口的定义,如下所示:
public interface SqlNode {
// apply()方法会根据用户传入的实参,解析该SqlNode所表示的动态SQL内容并
// 将解析之后的SQL片段追加到DynamicContext.sqlBuilder字段中暂存。
// 当SQL语句中全部的动态SQL片段都解析完成之后,就可以从DynamicContext.sqlBuilder字段中
// 得到一条完整的、可用的SQL语句了
boolean apply(DynamicContext context);
}
MyBatis 为 SqlNode 接口提供了非常多的实现类(如下图),其中很多实现类都对应一个动态 SQL 标签,但是也有 SqlNode 实现扮演了组合模式中 Composite 的角色,例如,MixedSqlNode 实现类。
SqlNode 继承关系图
下面我们就来逐一介绍这每个 SqlNode 实现类的功能和核心实现。
StaticTextSqlNode 用于表示非动态的 SQL 片段,其中维护了一个 text 字段(String 类型),用于记录非动态 SQL 片段的文本内容,其 apply() 方法会直接将 text 字段值追加到 DynamicContext.sqlBuilder 的最末尾。
MixedSqlNode 在整个 SqlNode 树中充当了树枝节点,也就是扮演了组合模式中 Composite 的角色,其中维护了一个 List<SqlNode>
集合用于记录 MixedSqlNode 下所有的子 SqlNode 对象。MixedSqlNode 对于 apply() 方法的实现也相对比较简单,核心逻辑就是遍历 List<SqlNode>
集合中全部的子 SqlNode 对象并调用 apply() 方法,由子 SqlNode 对象完成真正的动态 SQL 处理逻辑。
TextSqlNode 实现抽象了包含 “${}”占位符的动态 SQL 片段。TextSqlNode 通过一个 text 字段(String 类型)记录了包含“\({}”占位符的 SQL 文本内容,在 apply() 方法实现中会结合用户给定的实参解析“\){}”占位符,核心代码片段如下:
public boolean apply(DynamicContext context) {
// 创建GenericTokenParser解析器,这里指定的占位符的起止符号分别是"${"和"}"
GenericTokenParser parser = createParser(
new BindingTokenParser(context, injectionFilter));
// 将解析之后的SQL片段追加到DynamicContext暂存
context.appendSql(parser.parse(text));
return true;
}
这里使用 GenericTokenParser 识别“${}”占位符,在识别到占位符之后,会通过 BindingTokenParser 将“${}”占位符替换为用户传入的实参。BindingTokenParser 继承了TokenHandler 接口,在其 handleToken() 方法实现中,会根据 DynamicContext.bindings 这个 ContextMap 中的 KV 数据替换 SQL 语句中的“${}”占位符,相关的代码片段如下:
public String handleToken(String content) {
// 获取用户提供的实参数据
Object parameter = context.getBindings().get("_parameter");
if (parameter == null) { // 通过value占位符,也可以查找到parameter对象
context.getBindings().put("value", null);
} else if (SimpleTypeRegistry.isSimpleType(parameter.getClass())) {
context.getBindings().put("value", parameter);
}
// 通过Ognl解析"${}"占位符中的表达式,解析失败的话会返回空字符串
Object value = OgnlCache.getValue(content, context.getBindings());
String srtValue = value == null ? "" : String.valueOf(value);
checkInjection(srtValue); // 对解析后的值进行过滤
return srtValue; // 通过过滤的值才能正常返回
}
IfSqlNode 实现类对应了动态 SQL 语句中的 <if>
标签中使用可以通过 test 属性指定一个表达式,当表达式成立时,<if>
标签内的 SQL 片段才会出现在完整的 SQL 语句中。
在 IfSqlNode 中,通过 test 字段(String 类型)记录了 <if>
标签中的 test 表达式,通过 contents 字段(SqlNode 类型)维护了 <if>
标签下的子 SqlNode 对象。在 IfSqlNode 的 apply() 方法实现中,会依赖 ExpressionEvaluator 工具类解析 test 表达式,只有 test 表达式为 true,才会调用子 SqlNode 对象(即 contents 字段)的 apply() 方法。需要说明的是:这里使用到的 ExpressionEvaluator 工具类底层也是依赖 OGNL 表达式实现 test 表达式解析的。
TrimSqlNode 对应 MyBatis 动态 SQL 语句中的
在使用 <trim>
标签的时候,我们可以指定 prefix 和 suffix 属性添加前缀和后缀,也可以指定 prefixesToOverrides 和 suffixesToOverrides 属性来删除多个前缀和后缀(使用“|”分割不同字符串)。在 TrimSqlNode 中维护了同名的四个字段值,即 prefix 字段、suffix 字段(这两个是 String 类型)以及 prefixesToOverride 字段、suffixesToOverride 字段(这两个是 List<String>
类型)。
下面先来看一下 TrimSqlNode 的 apply() 方法的实现:
public boolean apply(DynamicContext context) {
FilteredDynamicContext filteredDynamicContext = new FilteredDynamicContext(context);
// 首先执行子SqlNode对象的apply()方法完成对应动态SQL片段的解析
boolean result = contents.apply(filteredDynamicContext);
// 使用FilteredDynamicContext.applyAll()方法完成前后缀的处理操作
filteredDynamicContext.applyAll();
return result;
}
从 apply() 方法的实现可以看出,TrimSqlNode 处理前后缀的核心逻辑是在 FilteredDynamicContext 中完成的。FilteredDynamicContext 可以看作是 DynamicContext 的装饰器。除了 DynamicContext 本身临时存储解析结果和参数的功能之外,FilteredDynamicContext 还通过其 applyAll() 方法实现了前后缀的处理,其中会判断 TrimSqlNode 下子 SqlNode 的解析结果的长度,然后执行 applyPrefix() 方法处理前缀,执行 applySuffix() 方法处理后缀。
另外,从前面的 SqlNode 继承关系图中还可以看出,WhereSqlNode 和 SetSqlNode 是 TrimSqlNode 的子类。
在 WhereSqlNode 中将 prefix 设置为“WHERE”字符串,prefixesToOverride 集合包含 “OR”“AND”“OR\n”“AND\n”“OR\r”“AND\r” 等字符串,这样就实现了删除 SQL 片段开头多余的 “AND”“OR” 关键字,并添加“WHERE”关键字的效果。
在 SetSqlNode 中将 prefix 设置为“SET”关键字,prefixesToOverride 集合和 suffixesToOverride 集合只包含“,”(逗号)字符串,这样就实现了删除 SQL 片段开头和结尾多余的逗号,并添加“SET”关键字的效果。
这一讲我们重点介绍了 MyBatis 中动态 SQL 语句中涉及的核心内容。