Mybatis动态sql实现分析

251 阅读8分钟

Mybatis动态sql实现分析

例子

  1. Mybatis的配置文件

    <?xml version="1.0" encoding="UTF-8" ?>
    <!DOCTYPE configuration
      PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
      "file:D:\ideaProject\mybatis-3-master\src\main\java\org\apache\ibatis\builder\xml\mybatis-3-config.dtd">
    <configuration>
    
      <settings>
        <setting name="cacheEnabled" value="true"/>
        <setting name="lazyLoadingEnabled" value="false"/>
        <setting name="aggressiveLazyLoading" value="false"/>
        <setting name="multipleResultSetsEnabled" value="true"/>
        <setting name="mapUnderscoreToCamelCase" value="true"/>
        <setting name="useColumnLabel" value="true"/>
        <setting name="useGeneratedKeys" value="true"/>
        <setting name="autoMappingBehavior" value="PARTIAL"/>
        <setting name="defaultExecutorType" value="REUSE"/>
        <setting name="defaultStatementTimeout" value="60"/>
        <setting name="logImpl" value="STDOUT_LOGGING"/>
      </settings>
    
    
      <environments default="develop">
        <environment id="develop">
          <transactionManager type="JDBC"/>
          <!-- 配置数据库连接信息 -->
          <dataSource type="POOLED">
            <property name="driver" value="com.mysql.jdbc.Driver"/>
            <property name="url" value="jdbc:mysql://"/>
            <property name="username" value="root"/>
            <property name="password" value="123456"/>
          </dataSource>
        </environment>
      </environments>
    
      <mappers>
        <mapper resource="mapper/StudentMapper.xml"/>
      </mappers>
    </configuration>
    
  2. Mapper

    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
    
    <mapper namespace="com.lc.simple.StudentMapper">
    
      <resultMap id="studentResultMap" type="com.lc.simple.Student">
        <result property="name" column="name"/>
        <result property="age" column="age"/>
        <result property="id" column="id"/>
      </resultMap>
    
    
      <sql id="test">
        select * from t_student
      </sql>
    
      <select id="getStudentForTest" resultType="com.lc.simple.Student">
        <include refid="test"/>
        <where>
          <if test="name != null">
            name = #{name}
          </if>
          <if test="age != null">
            and age = #{age}
          </if>
        </where>
      </select>
       <!-- 用来测试动态sql和静态sql的,之后会有说明 -->  
      <select id="listAllStudent" resultMap="studentResultMap">
        <include refid="test"/> WHERE ${id}
      </select>
    </mapper>
    
    
  3. 实体类

    1. mapper

      public interface StudentMapper {
        List<Student> listAllStudent(String id);
      
        Student getStudentForTest(StudentQuery studentQuery);
      }
      
    2. 实体对象

      package com.lc.simple;
      
      public class Student {
        private Integer id;
        private String name;
        private Integer age;
      
        public Integer getId() {
          return id;
        }
      
        public void setId(Integer id) {
          this.id = id;
        }
      
        public String getName() {
          return name;
        }
      
        public void setName(String name) {
          this.name = name;
        }
      
        public Integer getAge() {
          return age;
        }
      
        public void setAge(Integer age) {
          this.age = age;
        }
      
        @Override
        public String toString() {
          return "Student{" +
            "id=" + id +
            ", name='" + name + '\'' +
            ", age=" + age +
            '}';
        }
      }
      
      
  4. 测试

    public class MainTest {
      public static void main(String[] args) throws IOException {
        SqlSessionFactoryBuilder sqlSessionFactoryBuilder = new SqlSessionFactoryBuilder();
        SqlSessionFactory sqlSessionFactory = sqlSessionFactoryBuilder.build(Resources.getResourceAsStream("mybatis.xml"));
        SqlSession sqlSession = sqlSessionFactory.openSession();
        StudentMapper mapper = sqlSession.getMapper(StudentMapper.class);
        List<Student> students = mapper.listAllStudent("1 = 1");
    
        StudentQuery studentQuery = new StudentQuery();
        studentQuery.setAge(123);
        studentQuery.setName("测试");
        Student studentForTest = mapper.getStudentForTest(studentQuery);
        students.forEach(System.out::println);
      }
    }
    

主要思想

  1. 在扫描Mybatis的配置文件的时候,获取到Mapper的信息,加载Mapper,解析Mapper,在解析Mapper的时候针对mapper做操作(本文旨在分析动态sql的实现,所以,Mapper里面的resultMapcache-ref,cache,parameterMap在这里就不涉及了,相关的代码在XMLMapperBuilder#configurationElement(XNode)方法里面)。

image-20220212110805412.png

下面会主要分析sqlselect,其余的insert|update|delete本质都差不多

  1. 在解析Mapper的时候,会通过XMLMapperBuilder来帮助解析,他继承于BaseBuilder,在Mybatis中,BaseBuilder是一个抽象类,大多数的Builder都继承于他,他主要是有三个基本的属性

image-20220212113823047.png

回来再看,XMLMapperBuilder,作为一个Mapper的构建者来说,因为Mapper文件是xml文件,在他里面就需要通过XPathParser来解析xml文件,还得保留一个Mapper文件的路径,还有一个MapperBuilderAssistant,他是XMLMapperBuilder的属性,他是帮助XMLMapperBuilder来构建的,在他里面主要判断操作和判断nameSpace和cache还有一些对象的创建。

image-20220212114543807.png

  1. 上面说的XMLMapperBuilder是用来构建Mapper文件的,但是对于Select这种的,有专门对应的XMLStatementBuilder来构建。对应的代码在XMLStatementBuilder#parseStatementNode()中,在这个方法里面会解析Select里面的东西。

    • 主要是看当前的databaseId如果设置了,是否符合。
    • 是否要刷新缓存和使用缓存,Select操作就不需要刷新缓存但是会使用缓存,这里说的是二级缓存。
    • 处理sql语句中的include标签
    • 确定参数类型,返回类型,ResultMap,等等。
    • 解析<selectKey>标签。
    • 确定KeyGenerator
    • 处理sql
    • 将上述的这些封装为MappedStatement添加到Configuration里面去。

加载mapper时候,解析动态sql

动态sql的处理是在XMLStatementBuilder中进行的,主要就是下面的两行代码。先创建LanguageDriver在通过他来创建SqlSource,如果在标签中不指定lang属性,默认的为XMLLanguageDriver

image-20220212134612875.png

要记住这里是还是解析Mapper文件,在解析加载Mapper文件的时候,确定动态还是静态的SqlSource,如果是动态,不会组装sql,等真正运行的时候在通过参数来组装最后的sql,如果是静态的sql,在创建SqlSource的时候就确定好了, 在运行的时候就只是填充值就好了。在构建sql的时候也有对应的XMLScriptBuilder,具体的代码在XMLScriptBuilder#parseScriptNode()

问题

  1. 动态sql和非动态sql怎么区分?

    如果有mybatis规定的那些标签,或者语句中包含${}的,就是动态的sql。注意#{}的不是动态sql。

    代码的实现在TextSqlNode#isDynamic()中。因为本质都是Xml文件,就可以解析xml文件,或者xml文件中的各个节点,对应的就是各个标签,文本也是一个节点,对于非文本的标签(这可以通过xml中规定的节点的类型来判断),那肯定就是一个动态,文本标签里面的内容如果有的话,就需要获取文本的内容来判断是否有{}的话,就需要获取文本的内容来判断是否有{}。这对于之后的我们的学习也是有帮助的,这里介绍一下Mybatis是怎么做的。有两个类,解析器和处理器,解析器就是专门来解析文本中的规定字符串的,对应的就是,处理器来处理{},处理器来处理{a}里面的a,也就是解析器解析到的值。对应的是GenericTokenParserTokenHandler。要注意TokenHandler的填充值也是他来做的,对应的是BindingTokenParser,都知道,{}的填充值也是他来做的,对应的是`BindingTokenParser`,都知道,{}会有sql注入的问题的,就是通过sql拼接的方法来实现的。

    解析操作不难,指定开头字符串,和结尾字符串,剩下的就是在文本中找到开头的字符串,从0开始截取到当前位置,在找到末尾,同样截取,就可以获取到了。

下面来具体说说动态sql的具体解析操作

动态sql的具体解析操作

上面说了,在解析sql的时候也会有对应的builder,XMLScriptBuilder就是用来做这个操作的。先看看他长什么样子。

image-20220212141518470.png

上面的红框是他的内部类,下面的是属性,对于sql文件来说,在Mybatis中,sql是由每一部分来组装起来的,一个逆向的语法树,解析sql的时候,比如说,碰到了where标签,对应的就是where节点,比如遇到了一个什么都标签都没有写的sql,就是一个文本节点,Mybatis用SqlNode来表示节点,在解析的时候不同的标签有不同节点类型。

image-20220212142043204.png

此外,这只是表示,不同的节点得不同的处理方式吧,他叫做NodeHandler.

image-20220212143450714.png

得重点说一下MixedSqlNode,他有一个SqlNode的集合,在调用的时候会循环调用SqlNode集合,这样做是因为要支持标签的嵌套,比如说在where标签里面有if标签,if标签里面有forEach标签。forEach里面有正常的sql,正常的sql就是StaticTextSqlNodeforEach里面的就是MixedSqlNode,同样的对于where标签也是MixedSqlNode。建议重点看XMLScriptBuilder#parseDynamicTags(XNode)结合对应的NodeHandler一块看,便于理解。下面举个例子来分析分析,分析的例子是一开始举得例子中的getStudentForTest(StudentQuery)方法。

举例子分析

1.image-20220212144719954.png

一上来,发现是文本节点,并且文本里面的内容是select * from t_student没有${},将他封装为StaticTextSqlNode,此外还扽注意,这个方法最后的返回值都是MixedSqlNode

  1. image-20220212144942524.png

    到了下一次的循环,通过节点类型判断,发现不是文本节点,获取对应的Handle来处理,可以发现这里对应的是org.apache.ibatis.scripting.xmltags.XMLScriptBuilder$WhereHandler,注意传递给他的contents是一个sqlNode的列表。下面来看看org.apache.ibatis.scripting.xmltags.XMLScriptBuilder$WhereHandler是怎么处理的。

image-20220212145358179.png

他上来先调用parseDynamicTags,由掉回去了。因为在where里面我写了if,在走一遍上面的流程,就到了org.apache.ibatis.scripting.xmltags.XMLScriptBuilder$IfHandler

image-20220212145529673.png

在他里面也是先调用parseDynamicTags来处理if标签快里面的东西,对于普通的文本就是TextSqlNode

来看看if里面操作了什么事情,拿到了if标签里面的test,和之前的MixedSqlNode一块封装为IfSqlNode,添加到传递进来的List<SqlNode>里面去。

对于WhereHandler也是。同样的操作。

到这里,加载mapper文件,解析动态sql的操作已经分析完了


运行的时候,通过参数组装最终的sql

上面只是准备好了语法树,在运行的时候对于动态sql来说,需要判断不同的条件来组装不同的sql。所以,真正的sql是在运行的时候组装好的。

上面说了,sql的表示是SqlSource,通过调用他的BoundSql getBoundSql(Object)创建真正的sql,并且将参数的绑定等等信息封装为BoundSql对象。并且一个Mapper对应的是MapperStatement,所以,调用SqlSource#getBoundSql(Object)的地方一定是MapperStatement。这个内容在之前的Mybatis中说过。对应的代码在MappedStatement#getBoundSql(Object)里面。这里主要说的是DynamicSqlSource

主要步骤

  1. 通过Configuration传递进来的Object(参数)封装为DynamicContext,此外,在他里面还有StringJoiner用来拼接sql,ContextMap用来表示参数。

  2. 组装动态sql。

  3. 通过SqlSourceBuilder将#{}替换为?,替换原来的SqlSource,并且创建ParameterMapping用来表示参数之间的映射关系。

    这里的替换是通过GenericTokenParserTokenHandler的实现类(ParameterMappingTokenHandler)来做的。

  4. 重新调用SqlSource#getBoundSql(Object)方法。

下面结合例子来看看,例子还是一开头说的getStudentForTest(StudentQuery)方法。

举例子分析

image-20220212155420243.png

组装之后的就是这个样子,#{}还是存在的,下面就是通过SqlSourceBuilder来替换掉他,并且封装参数。重点看ParameterMappingTokenHandler里面的handleToken方法。我们知道,在原始的写sql的时候是由?的,比如select * from t_student where id = ?,之后通过jdbc来设置参数,执行,这里干的就是这个事情,将#{}替换为?,并且按需封装参数。

image-20220212155857430.png

从这就可以看到,已经处理完了,参数也搞好了。

到此,运行时通过参数来组装sql已经完成了。


到这里,Mybatis的动态sql的实现结束了。下面分析分析不同的SqlNode的实现逻辑。

SqlNode的不同实现

ChooseSqlNode

和正常的switchCase差不多,没有匹配到,就走default

@Override
  public boolean apply(DynamicContext context) {
    for (SqlNode sqlNode : ifSqlNodes) {
      if (sqlNode.apply(context)) {
        return true;
      }
    }
    if (defaultSqlNode != null) {
      defaultSqlNode.apply(context);
      return true;
    }
    return false;
  }

VarDeclSqlNode

他是bind对应的SqlNode,bind标签会将value对应的属性值绑定在name对应的字段中,从而用在sql中,这里的expression就是value,从Ognl中获取值,放在DynamicContext中。

  @Override
  public boolean apply(DynamicContext context) {
    final Object value = OgnlCache.getValue(expression, context.getBindings());
    context.bind(name, value);
    return true;
  }

StaticTextSqlNode

最简单,最普通的一种了,理论上来说,这是真正存放sql的节点了,别的节点基本都是包装节点。

  @Override
  public boolean apply(DynamicContext context) {
    context.appendSql(text);
    return true;
  }

IfSqlNode

先通过参数和条件来计算条件是否满足,满足在操作,否则不

  public boolean apply(DynamicContext context) {
    if (evaluator.evaluateBoolean(test, context.getBindings())) {
      contents.apply(context);
      return true;
    }
    return false;
  }

TrimSqlNode

这是这里面比较复杂的一个了,创建FilteredDynamicContext来做代理,一上来还是正常的调用,在组装完了之后,会调用FilteredDynamicContext#applyAll()方法,在这个方法里面,会剔除指定的前缀和后缀,增加指定的前缀和后缀。剔除操作是通过字符串截取来做的。

  @Override
  public boolean apply(DynamicContext context) {
    FilteredDynamicContext filteredDynamicContext = new FilteredDynamicContext(context);
    boolean result = contents.apply(filteredDynamicContext);
    filteredDynamicContext.applyAll();
    return result;
  }

public void applyAll() {
      sqlBuffer = new StringBuilder(sqlBuffer.toString().trim());
      String trimmedUppercaseSql = sqlBuffer.toString().toUpperCase(Locale.ENGLISH);
      if (trimmedUppercaseSql.length() > 0) {
        applyPrefix(sqlBuffer, trimmedUppercaseSql);
        applySuffix(sqlBuffer, trimmedUppercaseSql);
      }
      delegate.appendSql(sqlBuffer.toString());
    }

MixedSqlNode

很简单,之前说过了,一个混合的SqlNode

  @Override
  public boolean apply(DynamicContext context) {
    contents.forEach(node -> node.apply(context));
    return true;
  }

ForEachSqlNode

 @Override
  public boolean apply(DynamicContext context) {
      //参数
    Map<String, Object> bindings = context.getBindings();
      
      // 通过collection去参数里面获取值
    final Iterable<?> iterable = evaluator.evaluateIterable(collectionExpression, bindings,
      Optional.ofNullable(nullable).orElseGet(configuration::isNullableOnForEach));
    if (iterable == null || !iterable.iterator().hasNext()) {
      return true;
    }
    boolean first = true;
     // 在sql中添加open属性的值
    applyOpen(context);
    int i = 0;
      // 开始遍历
    for (Object o : iterable) {
      DynamicContext oldContext = context;
        // 会用PrefixedContext来替换原来的DynamicContext,并且如果没有指定separator,默认的是""
      if (first || separator == null) {
        context = new PrefixedContext(context, "");
      } else {
        context = new PrefixedContext(context, separator);
      }
      int uniqueNumber = context.getUniqueNumber();
      // Issue #709
      if (o instanceof Map.Entry) {
          // 绑定值,这里的绑定值是直接放在DynamicContext中就好了,这里的key就是指定的item,value就是值,这里有一个问题,如果item一样话
          // 做为map来说,会覆盖掉,所以,这里的key是 "__frch_"前缀+item + 循环的下标值
        @SuppressWarnings("unchecked")
        Map.Entry<Object, Object> mapEntry = (Map.Entry<Object, Object>) o;
        applyIndex(context, mapEntry.getKey(), uniqueNumber);
        applyItem(context, mapEntry.getValue(), uniqueNumber);
      } else {
        applyIndex(context, i, uniqueNumber);
        applyItem(context, o, uniqueNumber);
      }
        // 正常的调用,这里一上来不调用是因为可能forEach中有别的动态标签
      contents.apply(new FilteredDynamicContext(configuration, context, index, item, uniqueNumber));
      if (first) {
        first = !((PrefixedContext) context).isPrefixApplied();
      }
      context = oldContext;
      i++;
    }
      // 应用后缀
    applyClose(context);
      // 移除之前的item,要注意__frch_开头可没有移除掉
    context.getBindings().remove(item);
    context.getBindings().remove(index);
    return true;
  }

TextSqlNode

对应的是sql中的,会直接将sql中的{},会直接将sql中的{}替换为传递的值

  @Override
  public boolean apply(DynamicContext context) {
    GenericTokenParser parser = createParser(new BindingTokenParser(context, injectionFilter));
    context.appendSql(parser.parse(text));
    return true;
  }
// 解析
private GenericTokenParser createParser(TokenHandler handler) {
    return new GenericTokenParser("${", "}", handler);
  }
// 处理
  private static class BindingTokenParser implements TokenHandler {

    private DynamicContext context;
    private Pattern injectionFilter;

    public BindingTokenParser(DynamicContext context, Pattern injectionFilter) {
      this.context = context;
      this.injectionFilter = injectionFilter;
    }

    @Override
    public String handleToken(String content) {
      Object parameter = context.getBindings().get("_parameter");
      if (parameter == null) {
        context.getBindings().put("value", null);
      } else if (SimpleTypeRegistry.isSimpleType(parameter.getClass())) {
        context.getBindings().put("value", parameter);
      }
        //获取值
      Object value = OgnlCache.getValue(content, context.getBindings());
      String srtValue = value == null ? "" : String.valueOf(value); // issue #274 return "" instead of "null"
      checkInjection(srtValue);
        //直接替换
      return srtValue;
    }
  }

补充说明

SqlNode这样套娃能干啥?为啥不出错。

  1. sqlNode这样套娃是为了多个标签互相嵌套使用,其实真正的存放sql的就是 StaticTextSqlNode。其余的都是基于StaticTextSqlNode来做一些处理,比如if,forEach等等, 所以,他这样一直调用apply才不会有问题。还有这样写很分明,节点是节点,节点的处理是节点的处理,并且将解析和真正的组装分开,在解析阶段围绕着XMLScriptBuilder#parseDynamicTags(XNode)来做处理解析组装MixedSqlNode。在运行节点通过根SqlNode调用他的apply方法来组装sql。sql是放在DynamicContext里面的。

到此,结束了。

关于博客这件事,我是把它当做我的笔记,里面有很多的内容反映了我思考的过程,因为思维有限,不免有些内容有出入,如果有问题,欢迎指出。一同探讨。谢谢