作者:京东保险 王奕龙
本节我们来介绍 org.apache.ibatis.mapping.MappedStatement
(映射SQL语句声明的类),它是 MyBatis 框架中的一个核心类,也是向后不断学习 Mybatis 源码的基础。在这部分源码中,最值得关注的设计原则是“信息隐藏”,它是在《软件设计哲学》中提到的一个观点,简单来说就是将把外部类不需要了解的信息隐藏在类内部,遵循最小知识原则,在与 MappedStatement
相关的类中,定义了很多内部类和内部接口,它们只在某些类内访问。此外,MappedStatement
的创建完美地遵循了 建造者模式,这也是学习该模式很好的实例。这部分源码较多,在看这部分源码时,也需要留意一下方法的长度,尝试着理解在《代码整洁之道》中强调的“每个方法只做一件事”的原则,考虑书中提到的按照方法行数拆分多个小方法的原则到底合适不合适,并关注方法的编排顺序,它是胡乱的安排方法的位置还是有一定规律?接下来,我们步入正文。
MappedStatement
负责存储和管理映射 SQL 语句的详细信息,每个 MappedStatement
对象对应 XML 映射文件中一个 <select>
, <insert>
, <update>
, 或 <delete>
标签,其中重要字段内容信息如下:
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<ResultMap> 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 语句(包含${}
占位符和<if>
,<choose>
,<when>
,<otherwise>
,<foreach>
等动态标签)RawSqlSource
: 这个实现类用于解析包含#{}
占位符的 SQL 语句,其内部实现依然是StaticSqlSource
ProviderSqlSource
: 用于处理使用@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<String, NodeHandler> 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<SqlNode> 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<SqlNode> contents;
public MixedSqlNode(List<SqlNode> 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 的解析
以如下 SQL 为例:
<select id="selectAuthor" parameterMap="selectAuthor" resultMap="selectAuthor">
select id, username, password, email, bio, favourite_section
from author where id = #{id,jdbcType=INTEGER, javaType=int}
</select>
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<SqlNode> contents = new ArrayList<>();
NodeList children = node.getNode().getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
XNode child = node.newXNode(children.item(i));
if (child.getNode().getNodeType() == Node.CDATA_SECTION_NODE || child.getNode().getNodeType() == Node.TEXT_NODE) {
String data = child.getStringBody("");
TextSqlNode textSqlNode = new TextSqlNode(data);
// 包含 ${} 占位符的会被判断为动态 SQL,并被记录为 TextSqlNode
if (textSqlNode.isDynamic()) {
contents.add(textSqlNode);
isDynamic = true;
} else {
contents.add(new StaticTextSqlNode(data));
}
} else if (child.getNode().getNodeType() == Node.ELEMENT_NODE) {
// ...
}
}
return new MixedSqlNode(contents);
}
}
其中 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<String, Object> 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<ParameterMapping> parameterMappings = new ArrayList<>();
private final Class<?> parameterType;
private final MetaObject metaParameters;
public ParameterMappingTokenHandler(Configuration configuration, Class<?> parameterType,
Map<String, Object> additionalParameters) {
super(configuration);
this.parameterType = parameterType;
this.metaParameters = configuration.newMetaObject(additionalParameters);
}
public List<ParameterMapping> getParameterMappings() {
return parameterMappings;
}
@Override
public String handleToken(String content) {
parameterMappings.add(buildParameterMapping(content));
return "?";
}
private ParameterMapping buildParameterMapping(String content) {
// 解析表达式中的参数映射信息
Map<String, String> propertiesMap = parseParameterMapping(content);
// 参数字段
String property = propertiesMap.get("property");
// ... 处理 parameterType 配置
// ... 处理占位符参数中标记的 javaType, jdbcType 等配置
// ... 封装 TypeHandler
}
}
ParameterMappingTokenHandler#buildParameterMapping
方法会对参数映射信息进行封装,实际的占位符中表达式的解析是在 parseParameterMapping
中完成的,会继续调用到 ParameterExpression
的构造方法完成解析,ParameterExpression
继承了 HashMap
,会将解析结果以键值对的形式保存:
public class ParameterExpression extends HashMap<String, String> {
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<ParameterMapping> parameterMappings;
private final Configuration configuration;
// ...
}
现在 RawSqlSource
的构造方法已执行完毕,其内部组合的 SqlSource
对象为 StaticSqlSource
,至此 MappedStatement
中 SqlSource
对象的解析便完毕了。
public class RawSqlSource implements SqlSource {
// StaticSqlSource
private final SqlSource sqlSource;
// ...
}
动态 SQL 的解析
接下来我们看一个带有动态标签的 SQL 是如何解析的,以 org.apache.ibatis.session.SqlSessionTest#dynamicSqlParse
为例,它的 SQL 如下:
<select id="selectAuthor" parameterMap="selectAuthor" resultMap="selectAuthor">
select id, username, password, email, bio, favourite_section
from author
<where>
<if test="id != null">
id = #{id}
</if>
</where>
</select>
它会被分成两部分,分别为:
<select id="selectAuthor" parameterMap="selectAuthor" resultMap="selectAuthor">
select id, username, password, email, bio, favourite_section
from author
</select>
<where>
<if test="id != null">
id = #{id}
</if>
</where>
对于第一部分的处理与上述静态 SQL 解析的过程一致,不再赘述。第二部分是处理包含动态标签 <where>
和 <if>
的内容,它会执行到 XMLScriptBuilder#parseDynamicTags
方法的处理 ELEMENT_NODE
的逻辑中:
public class XMLScriptBuilder extends BaseBuilder {
// ...
protected MixedSqlNode parseDynamicTags(XNode node) {
List<SqlNode> contents = new ArrayList<>();
NodeList children = node.getNode().getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
XNode child = node.newXNode(children.item(i));
if (child.getNode().getNodeType() == Node.CDATA_SECTION_NODE || child.getNode().getNodeType() == Node.TEXT_NODE) {
// ...
} else if (child.getNode().getNodeType() == Node.ELEMENT_NODE) {
// 根据 node 节点名称选择对应的 Handler
String nodeName = child.getNode().getNodeName();
NodeHandler handler = nodeHandlerMap.get(nodeName);
if (handler == null) {
throw new BuilderException("Unknown element <" + nodeName + "> 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
的介绍已经完毕了。