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

大家好,我是 方圆 。本节我们来介绍 org.apache.ibatis.mapping.MappedStatement(映射SQL语句声明的类),它是 MyBatis 框架中的一个核心类,也是向后不断学习 Mybatis 源码的基础。它负责存储和管理映射 SQL 语句的详细信息,每个 MappedStatement 对象对应 XML 映射文件中一个 <select>, <insert>, <update>, 或 <delete> 标签,其中重要字段内容信息如下:

java 复制代码
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 对象的方法:

java 复制代码
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语句(org.apache.ibatis.mapping.SqlSource#getBoundSql 方法)。接下来我们重点看下这个接口:

java 复制代码
public interface SqlSource {

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

}

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

  1. StaticSqlSource: 用于处理静态 SQL 语句
  2. DynamicSqlSource : 用于处理动态 SQL 语句(包含 ${} 占位符和 <if>, <choose>, <when>, <otherwise>, <foreach> 等动态标签)
  3. RawSqlSource : 这个实现类用于解析包含 #{} 占位符的 SQL 语句,其内部实现依然是 StaticSqlSource
  4. ProviderSqlSource : 用于处理使用 @Provider 注解的 SQL

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

java 复制代码
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 对象:

java 复制代码
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 {
        // ...
    }
    
    // ...
}

NodeHandler#handleNode 方法的第 2 个入参为 SQL 的节点信息,我们需要了解下 SqlNode 接口:

java 复制代码
public interface SqlNode {

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

它的实现类如下所示,我们拿其中重点的实现进行介绍:

  • StaticTextSqlNode 用于处理不包含占位符的纯文本节点
  • TextSqlNode 用于处理包含 ${} 占位符的文本节点,它会 直接将占位符中的内容拼接到 SQL 上 ,所以它能实现 动态 SQL ,与处理 #{} 占位符不同,#{} 占位符在处理字符串类型时,会添加上 ' 单引号,避免 SQL 注入问题,注意它们的区别,后续针对动态 SQL 问题还会讲解
  • MixedSqlNode 比较特殊,因为 SQL 被解析完毕后,会有多个节点片段,这个对象是将所有的节点(SqlNode)保存起来,通过遍历来触发将 SQL 应用到上下文的 apply 方法:
java 复制代码
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 进行解析:

java 复制代码
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 为例:

xml 复制代码
<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 对象,如下逻辑:

java 复制代码
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 字段):

java 复制代码
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 对象。

java 复制代码
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 的逻辑(仅判断是否包含 ${} 占位符),之后会经常看到它的身影,其中包含三个字段:

java 复制代码
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 对象。

java 复制代码
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)",但是它并没有严格遵守建造者模式,不过是将复杂的构造对象的逻辑隐藏起来(信息隐藏):

java 复制代码
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());
    }

}

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

java 复制代码
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,会将解析结果以键值对的形式保存:

java 复制代码
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 会被解析为如下内容并保存下来:

json 复制代码
{
  "property": "id",
  "jdbcType": "INTEGER",
  "javaType": "int"
}

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

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

sql 复制代码
select id, username, password, email, bio, favourite_section from author where id = ?

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

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

    // ...
}

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

java 复制代码
public class RawSqlSource implements SqlSource {
    // StaticSqlSource
    private final SqlSource sqlSource;
    
    // ...
}

动态 SQL 的解析

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

xml 复制代码
    <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>

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

xml 复制代码
    <select id="selectAuthor" parameterMap="selectAuthor" resultMap="selectAuthor">
        select id, username, password, email, bio, favourite_section
        from author
    </select>
xml 复制代码
    <where>
        <if test="id != null">
            id = #{id}
        </if>
    </where>

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

java 复制代码
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 方法:

java 复制代码
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 对象,如下是它的构造方法:

java 复制代码
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 创建的 RawSqlSource,并不会生成完成的 SQL,而是在用户调用时根据入参实时变换调整。到这里,动态 SQL 的解析操作也完成了。下面我们来看一下创建 MappedStatement 使用到的 建造者模式

建造者模式

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

java 复制代码
    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 的介绍已经完毕了,有了这些知识接下来便能去探索 SQL 执行的流程了。

相关推荐
王中阳Go13 分钟前
又遇百度,能否 hold 住?
后端·go
忘却的纪念44 分钟前
基于SpringBoot的嗨玩旅游网站:一站式旅游信息服务平台的设计与实现
java·开发语言·spring boot·后端·毕业设计·旅游
{⌐■_■}1 小时前
【go语言】reflect包与类型推断
开发语言·后端·golang
一见你就笑i2 小时前
基于Spring Cloud自定义注解 - 登录日志
后端·spring·spring cloud
超爱吃士力架3 小时前
包装器模式
java·后端·设计模式
A阳俊yi3 小时前
spring实例化对象的几种方式(使用XML配置文件)
java·后端·spring
冷冻工厂4 小时前
R中单细胞RNA-seq分析教程 (5)
后端
Uncommon.4 小时前
Spring IOC如何解决循环依赖的问题?
java·后端·spring
Nijika...4 小时前
RabbitMQ三种交换机的基本使用方法
java·后端·spring·rabbitmq