由 Mybatis 源码畅谈软件设计(二):MappedStatement 和 SqlSource

B站影视 2024-12-23 10:10 1

摘要:本节我们来介绍 org.apache.ibatis.mapping.Mappedstatement(映射SQL语句声明的类),它是 MyBatis 框架中的一个核心类,也是向后不断学习 Mybatis 源码的基础。在这部分源码中,最值得关注的设计原则是“信息隐

本节我们来介绍 org.apache.ibatis.mapping.Mappedstatement(映射SQL语句声明的类),它是 MyBatis 框架中的一个核心类,也是向后不断学习 Mybatis 源码的基础。在这部分源码中,最值得关注的设计原则是“信息隐藏”,它是在《软件设计哲学》中提到的一个观点,简单来说就是将把外部类不需要了解的信息隐藏在类内部,遵循最小知识原则,在与 MappedStatement 相关的类中,定义了很多内部类和内部接口,它们只在某些类内访问。此外,MappedStatement 的创建完美地遵循了 建造者模式,这也是学习该模式很好的实例。这部分源码较多,在看这部分源码时,也需要留意一下方法的长度,尝试着理解在《代码整洁之道》中强调的“每个方法只做一件事”的原则,考虑书中提到的按照方法行数拆分多个小方法的原则到底合适不合适,并关注方法的编排顺序,它是胡乱的安排方法的位置还是有一定规律?接下来,我们步入正文。

MappedStatement 负责存储和管理映射 Sql 语句的详细信息,每个 MappedStatement 对象对应 XML 映射文件中一个 , , , 或 标签,其中重要字段内容信息如下:

public final class MappedStatement { // 每个 MappedStatement 对象都有一个唯一的 ID,用于在 MyBatis 配置中标识和引用该语句(Mapper接口中的方法的全路径名称) // eg: org.apache.ibatis.domain.blog.mappers.AuthorMapper.selectAuthor private String id; // sqlSource 存储 SQL 语句,区分静态、动态sql private SqlSource sqlSource; // 描述输入参数的类型和映射关系 private ParameterMap parameterMap; // 描述返回结果的类型和映射关系 private List resultMaps; // SQL 类型 private SqlCommandType sqlCommandType; // 数据库厂商标识,用于多数据库支持 private String databaseId; // Mapper.xml 文件的路径,eg: org/apache/ibatis/builder/AuthorMapper.xml private String resource; // ...}

org.apache.ibatis.builder.xml.XMLStatementBuilder.parseStatementNode 方法是将 xml 文件中配置的 SQL 实例化成 MappedStatement 对象的方法:

public class XMLStatementBuilder extends BaseBuilder { // ... private final MapperBuilderAssistant builderAssistant; public void parseStatementNode { // 处理配置的参数 Boolean flushCache = context.getbooleanAttribute("flushCache", !isSelect); // ... // 创建 SqlSource SqlSource sqlSource = langDriver.createSqlSource(configuration, context, parameterTypeClass); // ... // 创建 MappedStatement 对象 builderAssistant.addMappedStatement(id, sqlSource, statementType, sqlCommandType, fetchSize, timeout, parameterMap, parameterTypeClass, resultMap, resultTypeClass, resultSetTypeEnum, flushCache, useCache, resultOrdered, keyGenerator, keyProperty, keyColumn, databaseId, langDriver, resultSets, dirtySelect); }}

验证创建 MappedStatement 对象过程逻辑采用的单测为 org.apache.ibatis.session.SqlSessionTest.shouldExecuteSelectOneAuthorUsingMapperClass,解析的 xml 文件为 AuthorMapper.xml。

在上述源码步骤中 SqlSource 的创建非常重要,该接口的源码注释如下:

Represents the content of a mapped statement read from an XML file or an annotation. It creates the SQL that will be passed to the database out of the input parameter received from the user.

表示从 XML 文件或注释读取的 SQL 语句,它可以根据用户输入的参数创建之后传递给数据库的SQL(org.apache.ibatis.mapping.SqlSource#getBoundSql 方法)。接下来我们重点看下这个接口:

public interface SqlSource { // 接受参数对象,并返回 BoundSql 对象。BoundSql 包含了生成的 SQL 语句以及相应的参数信息 BoundSql getBoundSql(Object parameterObject);}

MyBatis 提供了几个 SqlSource 的实现类,每个实现类适用于不同的场景:

StaticSqlSource: 用于处理静态 SQL 语句DynamicSqlSource: 用于处理动态 SQL 语句(包含 ${} 占位符和 , , , , 等动态标签)RawSqlSource: 这个实现类用于解析包含 #{} 占位符的 SQL 语句,其内部实现依然是 StaticSqlSourceProviderSqlSource: 用于处理使用 @Provider 注解的 SQL

它的实例化借助了 XMLscriptBuilder 实现:

public class XMLLanguageDriver implements LanguageDriver { // ... @Override public SqlSource createSqlSource(Configuration configuration, XNode script, Class parameterType) { XMLScriptBuilder builder = new XMLScriptBuilder(configuration, script, parameterType); return builder.parseScriptNode; }}

XMLScriptBuilder 中定义了 内部接口 动态标签解析器 NodeHandler,它对多种标签解析做了实现,负责将不同标签内容解析成不同的 SqlNode 对象:

public class XMLScriptBuilder extends BaseBuilder { private final Map nodeHandlerMap = new HashMap; public XMLScriptBuilder(Configuration configuration, XNode context, Class parameterType) { super(configuration); this.context = context; this.parameterType = parameterType; // 初始化SQL节点处理器 initNodeHandlerMap; } private void initNodeHandlerMap { nodeHandlerMap.put("Trim", new TrimHandler); nodeHandlerMap.put("where", new WhereHandler); nodeHandlerMap.put("set", new SetHandler); nodeHandlerMap.put("foreach", new ForEachHandler); nodeHandlerMap.put("if", new IfHandler); nodeHandlerMap.put("choose", new ChooseHandler); nodeHandlerMap.put("when", new IfHandler); nodeHandlerMap.put("otherwise", new OtherwiseHandler); nodeHandlerMap.put("bind", new BindHandler); } // 定义内部接口,因为其只与 XMLScriptBuilder 相关,隐藏在内部不泄露知识,降低复杂度 private interface NodeHandler { void handleNode(XNode nodeToHandle, List targetContents); } private class WhereHandler implements NodeHandler { // ... } // ...}

org.apache.ibatis.scripting.xmltags.XMLScriptBuilder.NodeHandler#handleNode 方法的第 2 个入参为 SQL 的节点判断信息,我们需要了解下 SqlNode 接口:

public interface SqlNode { /** * 负责将当前节点的 SQL 片段应用到上下文中 */ boolean apply(DynamicContext context); }

它的实现类如下所示:

StaticTextSqlNode 用于处理不包含占位符的纯文本节点TextSqlNode 用于处理包含 ${} 占位符的文本节点,它会 直接将占位符中的内容拼接到 SQL 上,所以它能实现 动态 SQL,与处理 #{} 占位符不同,#{} 占位符在处理字符串类型时,会添加上 ' 单引号,避免 SQL 注入问题,注意它们的区别,后续针对动态 SQL 问题还会讲解MixedSqlNode 比较特殊,因为 SQL 被解析完毕后,会有多个节点片段,这个对象是将所有的节点(SqlNode)保存起来,通过遍历来触发将 SQL 应用到上下文的 apply 方法:public class MixedSqlNode implements SqlNode { // 保存 SQL 中所有的节点片段 private final List contents; public MixedSqlNode(List contents) { this.contents = contents; } @Override public boolean apply(DynamicContext context) { contents.forEach(node -> node.apply(context)); return true; }}

其他实现类根据其命名表示 SQL 中对应的不同标签节点,非常简单就不再赘述了。我们继续回到 XMLScriptBuilder#parseScriptNode` 方法,它会对 xml 文件中定义的 SQL 进行解析:

public class XMLScriptBuilder extends BaseBuilder { // ... public SqlSource parseScriptNode { // 将 xml 文件中定义的 SQL 解析成不同的 SqlNode 对象,并都记录在 MixedSqlNode 对象中 MixedSqlNode rootSqlNode = parseDynamicTags(context); SqlSource sqlSource; if (isDynamic) { // 包含动态标签的 SQL sqlSource = new DynamicSqlSource(configuration, rootSqlNode); } else { // 静态 SQL 会将占位符 #{} 替换成 ? sqlSource = new RawSqlSource(configuration, rootSqlNode, parameterType); } return sqlSource; } // ...}

可以发现它会根据静态或动态 SQL 创建不同的 SqlSource 对象,那么我们接下来分别看一下两种不同 SQL 的解析过程。

以如下 SQL 为例:

select id, username, password, email, bio, favourite_section from author where id = #{id,jdbcType=INTEGER, javaType=int}

org.apache.ibatis.scripting.xmltags.XMLScriptBuilder.parseDynamicTags 方法会执行具体的 SQL 解析逻辑,它会执行如下逻辑:如果包含 ${} 占位符,会被标识为动态 SQL,对应的类型为 TextSqlNode,否则为 StaticTextSqlNode 对象,如下逻辑:

public class XMLScriptBuilder extends BaseBuilder { public SqlSource parseScriptNode { // 将 xml 文件中定义的 SQL 解析成不同的 SqlNode 对象,并都记录在 MixedSqlNode 对象中 MixedSqlNode rootSqlNode = parseDynamicTags(context); // ... } protected MixedSqlNode parseDynamicTags(XNode node) { List contents = new ArrayList; NodeList children = node.getNode.getChildNodes; for (int i = 0; i

其中 textSqlNode.isDynamic 步骤需要关注。TextSqlNode 对象中定义了 DynamicCheckerTokenParser 内部类,它实现了 TokenHandler 接口,在路径 org.apache.ibatis.parsing 下,根据包路径描述 “Parsing utils”,可知该接口是解析工具类通用接口,不过 DynamicCheckerTokenParser 并没有做什么需要特别关注的逻辑,只是用于标记 SQL 为静态 SQL 还是动态 SQL(isDynamic 字段):

private static class DynamicCheckerTokenParser implements TokenHandler { private boolean isDynamic; public DynamicCheckerTokenParser { // Prevent Synthetic Access } public boolean isDynamic { return isDynamic; } @Override public String handleToken(String content) { this.isDynamic = true; return null; }}

在 textSqlNode.isDynamic 方法中,创建了 DynamicCheckerTokenParser 对象和 GenericTokenParser 对象。

public class TextSqlNode implements SqlNode { // ... public boolean isDynamic { DynamicCheckerTokenParser checker = new DynamicCheckerTokenParser; GenericTokenParser parser = createParser(checker); parser.parse(text); return checker.isDynamic; } // ...}

GenericTokenParser 译为通用的 Token 解析器,同样也在 org.apache.ibatis.parsing 包路径下,它会完成判断是否为动态 SQL 的逻辑(仅判断是否包含 ${} 占位符),之后会经常看到它的身影,其中包含三个字段:

public class GenericTokenParser { // 要匹配的 占位符左边界信息 private final String openToken; // 要匹配的 占位符右边界信息 private final String closeToken; // 组合 TokenHandler 来实现“替换占位符”的功能,符合单一职责的原则 private final TokenHandler handler; // ... // 解析 SQL 操作 public String parse(String text) { // ... } }

继续回到 org.apache.ibatis.scripting.xmltags.XMLScriptBuilder.parseDynamicTags 方法,如果在 SQL 中匹配到 ${} 则 DynamicCheckerTokenParser 会将 isDynamic 标记为 true,当前 SQL 节点会被定义为 TextSqlNode 类型,否则为 StaticTextSqlNode 类型,因为在示例中定义的 SQL 是带有 #{} 占位符的 SQL,所以 SqlSource 会被解析成 RawSqlSource 类型,并在其中组合 StaticSqlSource 对象。

public class RawSqlSource implements SqlSource { private final SqlSource sqlSource; public RawSqlSource(Configuration configuration, String sql, Class parameterType) { SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration); Class clazz = parameterType == null ? Object.class : parameterType; // 静态 SQL 在此处被解析,包含 #{} 占位符的会被替换为 ? sqlSource = sqlSourceParser.parse(sql, clazz, new HashMap); } // ...}

在 RawSqlSource 的构造方法中,完成了替换占位符的操作,SqlSourceBuilder 对象需要重点关注,由其命名来看,它是 SqlSource 对象的“建造者(Builder)”,但是它并没有严格遵守建造者模式,不过是将复杂的构造对象的逻辑隐藏起来(信息隐藏):

public class SqlSourceBuilder extends BaseBuilder { public SqlSource parse(String originalSql, Class parameterType, Map additionalParameters) { // 用于将占位符替换为 ? 的 handler ParameterMappingTokenHandler handler = new ParameterMappingTokenHandler(configuration, parameterType, additionalParameters); GenericTokenParser parser = new GenericTokenParser("#{", "}", handler); String sql; // 配置是否去掉 SQL 中的空格 if (configuration.isShrinkWhitespacesInSql) { sql = parser.parse(removeExtraWhitespaces(originalSql)); } else { // 在这里将 #{} 占位符 替换为 ?,并由 ParameterMappingTokenHandler 解析参数映射信息 sql = parser.parse(originalSql); } // 最终参数映射信息会保存在 StaticSqlSource 中 return new StaticSqlSource(configuration, sql, handler.getParameterMappings); }}

ParameterMappingTokenHandler 是 SqlSourceBuilder 中的 内部类,它同样实现了 TokenHandler 接口,也是一个解析工具类,与上文提到过的 GenericTokenParser 解析器组合使用。它会将占位符中的信息解析为 ParameterMapping 信息,并将占位符替换为 ? (handleToken 方法):

private static class ParameterMappingTokenHandler extends BaseBuilder implements TokenHandler { // 参数映射信息 private final List parameterMappings = new ArrayList; private final Class parameterType; private final MetaObject metaParameters; public ParameterMappingTokenHandler(Configuration configuration, Class parameterType, Map additionalParameters) { super(configuration); this.parameterType = parameterType; this.metaParameters = configuration.newMetaObject(additionalParameters); } public List getParameterMappings { return parameterMappings; } @Override public String handleToken(String content) { parameterMappings.add(buildParameterMapping(content)); return "?"; } private ParameterMapping buildParameterMapping(String content) { // 解析表达式中的参数映射信息 Map propertiesMap = parseParameterMapping(content); // 参数字段 String property = propertiesMap.get("property"); // ... 处理 parameterType 配置 // ... 处理占位符参数中标记的 javaType, jdbcType 等配置 // ... 封装 TypeHandler }}

ParameterMappingTokenHandler#buildParameterMapping 方法会对参数映射信息进行封装,实际的占位符中表达式的解析是在 parseParameterMapping 中完成的,会继续调用到 ParameterExpression 的构造方法完成解析,ParameterExpression 继承了 HashMap,会将解析结果以键值对的形式保存:

public class ParameterExpression extends HashMap { private static final long serialVersionUID = -2417552199605158680L; public ParameterExpression(String expression) { parse(expression); } private void parse(String expression) { int p = skipWS(expression, 0); if (expression.charAt(p) == '(') { // org/apache/ibatis/builder/SqlSourceBuilder.java:140,expression 表达式内容暂不支持,忽略这段逻辑 expression(expression, p + 1); } else { // 只关注这里即可 property(expression, p); } } // ... }

只需关注 property 方法即可,它的作用是对占位符表达式中的 javaType, jdbcType, mode, numericScale, resultMap, typeHandler, jdbcTypeName 属性进行解析,其中常用常见的是 javaType,jdbcType,示例 SQL 会被解析为如下内容并保存下来:

{ "property": "id", "jdbcType": "INTEGER", "javaType": "int"}

大家可以看一下 ParameterExpression 的具体实现,该类的实现遵循了《代码整洁之道》中 每个方法只做一件事,且方法足够短小 的原则,并将方法自上而下排列,读这个类的代码就像读报纸一样,被依赖次数越多的方法越靠下,这也暗示 越靠近类下方的方法越通用

SQL 中占位符被替换为 ?,结果如下:

select id, username, password, email, bio, favourite_section from author where id = ?

最终会被保存在 StaticSqlSource 中的 sql 字段中,表示封装的是静态 SQL:

public class StaticSqlSource implements SqlSource { // 替换完成 #{} 占位符的 SQL 内容 private final String sql; // 已经解析完成的 ParameterMapping 对象 private final List parameterMappings; private final Configuration configuration; // ...}

现在 RawSqlSource 的构造方法已执行完毕,其内部组合的 SqlSource 对象为 StaticSqlSource,至此 MappedStatement 中 SqlSource 对象的解析便完毕了。

public class RawSqlSource implements SqlSource { // StaticSqlSource private final SqlSource sqlSource; // ...}

接下来我们看一个带有动态标签的 SQL 是如何解析的,以 org.apache.ibatis.session.SqlSessionTest#dynamicSqlParse 为例,它的 SQL 如下:

select id, username, password, email, bio, favourite_section from author id = #{id}

它会被分成两部分,分别为:

select id, username, password, email, bio, favourite_section from author id = #{id}

对于第一部分的处理与上述静态 SQL 解析的过程一致,不再赘述。第二部分是处理包含动态标签 和 的内容,它会执行到 XMLScriptBuilder#parseDynamicTags 方法的处理 ELEMENT_NODE 的逻辑中:

public class XMLScriptBuilder extends BaseBuilder { // ... protected MixedSqlNode parseDynamicTags(XNode node) { List contents = new ArrayList; NodeList children = node.getNode.getChildNodes; for (int i = 0; i in SQL statement."); } // 指定对应的处理方法 handler.handleNode(child, contents); isDynamic = true; } } return new MixedSqlNode(contents); }}

它会根据标签类型获取到对应的 NodeHandler,执行相应的处理方法。其中部分 Handler 会再调用到 XMLScriptBuilder#parseDynamicTags 方法,出现递归调用,不过具体的执行过程相对简单,但也需要自己 Debug 跟踪验证。

上述 SQL 被解析完成后,SqlNode 表示为:

再回到 XMLScriptBuilder#parseScriptNode 方法:

public class XMLScriptBuilder extends BaseBuilder { // ... public SqlSource parseScriptNode { // 将 xml 文件中定义的 SQL 解析成不同的 SqlNode 对象,并都记录在 MixedSqlNode 对象中 MixedSqlNode rootSqlNode = parseDynamicTags(context); SqlSource sqlSource; if (isDynamic) { // 包含动态标签的 SQL sqlSource = new DynamicSqlSource(configuration, rootSqlNode); } else { // ... } return sqlSource; }}

动态 SQL 会被标记为 isDynamic == true,最终会被解析成 DynamicSqlSource 对象,如下是它的构造方法:

public class DynamicSqlSource implements SqlSource { private final Configuration configuration; private final SqlNode rootSqlNode; public DynamicSqlSource(Configuration configuration, SqlNode rootSqlNode) { this.configuration = configuration; this.rootSqlNode = rootSqlNode; } // ...}

非常简单,仅仅是赋值操作,因为它是动态 SQL,会根据入参生成 SQL 信息。到这里,动态 SQL 的解析操作也完成了。下面我们来看一下创建 MappedStatement 使用到的 建造者模式

在 org.apache.ibatis.builder.xml.XMLStatementBuilder.parseStatementNode 方法执行 org.apache.ibatis.builder.MapperBuilderAssistant#addMappedStatement 逻辑创建 MappedStatement 对象时使用了 建造者模式

public void parseStatementNode { // 定义相关字段的逻辑... // 方法的入参非常多,之所以能这么写,放弃可复用性,那么该方法不会轻易发生变更 builderAssistant.addMappedStatement(id, sqlSource, statementType, sqlCommandType, fetchSize, timeout, parameterMap, parameterTypeClass, resultMap, resultTypeClass, resultSetTypeEnum, flushCache, useCache, resultOrdered, keyGenerator, keyProperty, keyColumn, databaseId, langDriver, resultSets, dirtySelect); }

实现该模式对应的类图关系如下:

MapperBuilderAssistant: 对应建造者模式中的 Director,其中定义了构建 MappedStatement 对象各个字段值的逻辑MappedStatement: 要构建的对象本身Builder: 是 MapperStatement 中的静态内部类,定义了链式编程赋值的方法,用于构建 MappedStatement 对象

建造者模式适用于构建字段较多的复杂对象的场景,这样便能将构造 MappedStatement 的复杂逻辑隐藏起来。定义内部类 MappedStatement.Builder 并支持链式编程,是使用建造者模式时常见写法。

至此,创建 MappedStatement 中的要点和 SqlSource 的介绍已经完毕了。

来源:京东云开发者

相关推荐