MyBatis源码3_运行分析_01_Sql语句的生成

472 阅读9分钟

MyBatis运行

生成可执行SQL

生成可执行的SQL,指的是可以交给JDBC执行的SQL语句。

在执行的时候,通过调用SqlSource#getBoundSql()生成的,接下来我们再分析一下:

生成SQL

通过上面MyBatis启动对Mapper.xml的解析,已经将xml转换为了java对象,并通过封装成了一个SqlSource对象,最后这个SqlSource对象跟着其它参数信息被封装成一个MappedStatement放入到Configuration中。

如我们可以通过session获取到Configuration对象,查看里面有的MappedStatement:

        SysConfigMapper mapper = getSqlSession().getMapper(SysConfigMapper.class);
        Collection<MappedStatement> mappedStatements = getSqlSession().getConfiguration().getMappedStatements();

2023-04-26-21-01-56-image.png

我们可以看到,当SqlSource的实例为StaticSqlSource的时候,就可以直接查看到sql内容,这里是RawSqlSource 是纯文本,不存在动态节点。

2023-04-26-21-06-30-image.png

而当SqlSource为DynamicSqlSource的时候,就无法看到一条完整的Sql语句了,而是由SqlNode实例组成的节点对象。

2023-04-26-21-06-38-image.png

因此想要获取到Sql内容,还需要经历一步将动态SqlNode拼接成Sql语句的过程。

这里顺便提一个我遇到过的面试题:

MyBatis的Sql语句的生成是每次执行的时候动态生成,还是一次生成后重复利用?

答:MyBatis的Sql是每次动态生成的,因为每次执行sql的时候条件都可能不一样因此无法做到SQL语句重复利用,即使重复也会产生很多冗余SQL综合考虑不如每次执行动态拼接生成。

继续问,那么MyBatis的动态拼接sql是如何拼接的,是每次都要读取加载mapper.xml来生成吗?

答:不是,MyBatis在启动的时候,就已经将扫描到的mapper.xml文件生成了一个个的MappedStatement,存放到MyBatis上下文对象Configuration实例中了,并且存放的内容也是MyBatis内部定义的数据结构SqlNode,当mybatis启动后mapper.xml将没有任何关系,并在动态拼接sql的时候也是调用MyBatis内部对象进行拼接,而且不是每次都要创建对象,效率还不错。

接下来具体分析一下动态SqlNode拼接过程。

在前面又说到,SqlSource接口有个方法getBoundSql(Object parameterObject) 该方法会根据传入的parameterObject参数对象,拼接sql语句。

public class DynamicSqlSource implements SqlSource {

  private final Configuration configuration; // 全局上下文对象

  // sql片段的根节点,也就是前面标签解析调用MixedSqlNode rootSqlNode = parseDynamicTags(context);返回的值
  private final SqlNode rootSqlNode; 

  public DynamicSqlSource(Configuration configuration, SqlNode rootSqlNode) {
    this.configuration = configuration;
    this.rootSqlNode = rootSqlNode;
  }

  @Override
  public BoundSql getBoundSql(Object parameterObject) {
    // 创建一个动态上下文
    DynamicContext context = new DynamicContext(configuration, parameterObject);
    // 从根节点开始,调用SqlNode的拼接sql方法,真正拼接预编译sql就在此处,每个SqlNode的apply方法就是拼接sql的逻辑
    rootSqlNode.apply(context);

    // 执行到这里,此时context中,就已经有了一条拼接完成的sql语句,但是还未做参数绑定,接下来会通过SqlSourceBuilder的parse方法

    SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
    Class<?> parameterType = parameterObject == null ? Object.class : parameterObject.getClass();
    // 将sql中的#{}占位符给替换,替换成?,并生成对应的ParameterMapping,也就是参数和占位符关系
    // 这里parse方法返回结果是,生成一个StaticSqlSource,因为sql拼接已经完成了,到这一步已经是一个完整sql语句了,所以使用StaticSqlSource
    SqlSource sqlSource = sqlSourceParser.parse(context.getSql(), parameterType, context.getBindings());

    // 最后通过getBoundSql得到BoundSql对象,该BoundSql具有完整的sql语句以及,参数映射关系和条件
    BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
    context.getBindings().forEach(boundSql::setAdditionalParameter);
    return boundSql;
  }

}

Sql的生成要分两种情况讨论:

  • 静态SqlSource,这种是解析完了xml中的sql节点后,是纯文本的sql节点,也就是说不包含任何动态标签如if、where等这种标签的sql节点。对于这种节点生成的SqlSource是RawSqlSource类型的,它不会涉及到sql拼接操作。
  • 动态SqlSource,这种是解析完了xml中的sql节点后,带有像if、where这种动态标签的sql节点。这种节点生成的SqlSource是DynamicSqlSource。这种DynamicSqlSource类型要获取BoundSql,需要先通过SqlNode根据传来的参数动态拼接成一个静态sql语句。

总的来说SqlSource里面主要干了三件事:

1、将xml编写的sql节点,拼接解析为sql语句。

2、删除#{}占位符,替换成?生成预编译sql,并生成占位符和参数表达式的关系,返回SaticSqlSource。

3、根据传参生成BoundSql对象。

接下来针对DynamicSqlSource着重分析一下这是三个步骤:SqlNode动态拼接sql;sql参数表达式转换为预编译sql语句,创建StaticSqlSource;构建BoundSql对象。

SqlNode动态拼接sql

前面有说道,在解析mapper.xml后会生成以MixedSqlNode为根节点的一系列SqlNode实现类,SqlNode是一个接口,它有多个实现类,分别对应了DDM标签的动态标签,如IfSqlNode、ForEacheSqlNode、ChooseSqlNode...等。每个SqlNode都有个apply方法,该方法实现了动态标签的功能,以及拼接sql语句的功能。

如IfSqlNode类的功能,其它的SqlNode节点挺复杂的如ForEachSqlNode这种,需要自己去看,一时半会儿讲不清楚:

public class IfSqlNode implements SqlNode {
  private final ExpressionEvaluator evaluator;
  private final String test;
  private final SqlNode contents;

  public IfSqlNode(SqlNode contents, String test) {
    this.test = test;
    this.contents = contents;
    this.evaluator = new ExpressionEvaluator();
  }

  @Override
  public boolean apply(DynamicContext context) {  
    // 判断<if test="exp">里面的test表达式是否为true,如果为true则执行<if>标签里面节点
    if (evaluator.evaluateBoolean(test, context.getBindings())) {
      contents.apply(context);
      return true;
    }
    return false;
  }

}

上面有说道,在xml中无论是如何写的sql语句,最后都会映射成一个个SqlNode的实现类,一般的静态文本都会生成TextSqlNode或者StaticSqlNode里面。

它们会调用,DynamicContext#appendSql来进行拼接sql:

public class StaticTextSqlNode implements SqlNode {
  private final String text;

  public StaticTextSqlNode(String text) {
    this.text = text;
  }

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

}

root节点为MixedSqlNode,采用深度优先遍历执行拼接。

public class MixedSqlNode implements SqlNode {
  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;
  }
}

SqlNode动态拼接sql;sql参数表达式转换为预编译sql语句,创建StaticSqlSource

经过上面的步骤,已经完成了去除动态标签(where、if、foreach...),拼接成了一条仅带有#{}占位符的sql语句。

    DynamicContext context = new DynamicContext(configuration, parameterObject);
    rootSqlNode.apply(context);

2023-04-27-21-31-31-image.png

接下来,将要通过SqlSourceBuilder将sql转为预编译sql,即将#{}占位符转为对应的?,并且维护好参数绑定关系。

    SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
    Class<?> parameterType = parameterObject == null ? Object.class : parameterObject.getClass();
    SqlSource sqlSource = sqlSourceParser.parse(context.getSql(), parameterType, context.getBindings());

主要是sqlSourceParser.parse()方法,进行解析转换,接下来看看里面的代码:

  public SqlSource parse(String originalSql, Class<?> parameterType, Map<String, Object> additionalParameters) {
    // 创建一个#{param}表达式的控制器,这个控制器主要是为了处理#{param}表达式大括号里面的内容
    ParameterMappingTokenHandler handler = new ParameterMappingTokenHandler(configuration, parameterType, additionalParameters);
    // 创建表达式解析器,专门用来解析占位符#{},该解析器将会把sql字符串中的#{}字符解析出括号中间的内容,交给上面创建的handler来处理
    GenericTokenParser parser = new GenericTokenParser("#{", "}", handler);
    String sql;
    // 调用parse执行解析,解析后的sql将不带有任何#{}字符串
    if (configuration.isShrinkWhitespacesInSql()) {// 是否删除多余空格字符串,是mybatis全局配置的isShrinkWhitespacesInSql
      sql = parser.parse(removeExtraWhitespaces(originalSql));
    } else {
      sql = parser.parse(originalSql);
    } 
    // 当执行完上面的转换后,这条sql理所当然成了一条纯静态sql,不再有动态标签和占位符了
    return new StaticSqlSource(configuration, sql, handler.getParameterMappings());
  }

2023-04-27-21-46-18-image.png

可能有人好奇,上面的解析只解析了#{}占位符,那么${}占位符是啥时候解析的呢?

在MyBatis中占位符,视为变量替换,在生成SqlNode的时候会将其生成为类型为TextSqlNode,对{}占位符,视为变量替换,在生成SqlNode的时候会将其生成为类型为TextSqlNode,对{}的解析,在第一步sql拼接就已经完成了,要不然你以为为啥拼接sql要让你传参数进去(当然传参不只是为了这个节点功能,还有if判断等)。

上面解释了从动态sql转为静态sql的整个过程,核心就在于字符串对#{}的解析,以及参数绑定。#{}解析是GenericTokenParser起作用,该类在多处有使用,如TextSqlNode对${}解析也用到了它,他就是一个字符串处理工具类,感兴趣的自己去看源码。

参数绑定:我们在写mybatis的sql的时候,我们可以对传来的参数名称多次使用,那么它们是如何绑定的呢?这个就是ParameterMappingTokenHandler所实现的功能了,最后我们看到返回的StaticSqlSource中第三个参数就是整个handler.getParameterMapping(),所以不难猜出,在ParameterMappingTokenHandler内部会进行参数维护,请看源码:

里面有个重要的类ParameterMapping,维护了sql的#{}占位符参数和预编译sql的?关系

public class ParameterMapping {

  private Configuration configuration;
  // 参数名称,也就是#{}括号里面的内容
  private String property;
  // 参数模式
  private ParameterMode mode;
  private Class<?> javaType = Object.class;
  // jdbc类型,如#{name,jdbcType=VARCHAR},则jdbcType对应了VARCHAR
  private JdbcType jdbcType;
  private Integer numericScale;
  private TypeHandler<?> typeHandler;
  private String resultMapId;
  private String jdbcTypeName;
  private String expression;  
}

ParameterMappingTokenHandler#handleToken

    // 参数绑定集合
    private final List<ParameterMapping> parameterMappings = new ArrayList<>();
    // 处理#{}括号里面的内容,这个content就是#{}里面的值,如#{name},则content值为name
    @Override
    public String handleToken(String content) {
      // 调用buildParameterMapping(content))生成ParameterMapping对象,ParameterMapping描述了当前占位符和参数之间关系
      parameterMappings.add(buildParameterMapping(content));
      // 将#{}括号里面的内容替换成?
      return "?";
    }

最后解析完,parameterMapping的索引就对应了预编译sql中的?,因为parameterMappings内部维护了ParameterMapping对象,因此在最后执行sql的时候就可以通过这个对象的属性值,从参数对象中取出相应的值,进行绑定执行。

如:select * from user where id=?; parameterMappings[{property:id....}]

到这里就完成sql转换和参数绑定映射关系维护

构建BoundSql对象

最后一步构建BoundSql,BoundSql就是一个存储解析完成的sql和对应参数相关信息的一个对象,没有复杂的逻辑:

public class BoundSql {
  // 预编译sql
  private final String sql;
  // 参数映射关系,通过上一步解析出来的
  private final List<ParameterMapping> parameterMappings;
  // 调用mapper接口方法传来的参数
  private final Object parameterObject;
  // 附加参数
  private final Map<String, Object> additionalParameters;
  // 元数据
  private final MetaObject metaParameters;  
}
// 从上面第二步的解析已经知道了sqlSource类型为StaticSqlSource
BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
context.getBindings().forEach(boundSql::setAdditionalParameter);
// StaticSqlSource#getBoundSql,就是创建了一个BoundSql对象
@Override
public BoundSql getBoundSql(Object parameterObject) {
  return new BoundSql(configuration, sql, parameterMappings, parameterObject);
}

总结

MyBatis从mapper.xml解析到获取可执行sql的步骤:

总流程分析

  1. 第一步,通过LanguageDriver确定由什么语言去解析Sql语句,生成SqlSource
  2. 第二步,默认采用XMLLanguageDriver解析,在XMLLanguageDriver中将解析任务交给了XMLScriptBuilder来处理
  3. 第三步,XMLScriptBuilder中先是将xml中的sql片段所有信息转换成了对应SqlNode对象
  4. 第四步,在SqlSource中获取BoundSql,调用getBoundSql()方法的时候,将调用具体的SqlNode对象转换成sql语句,以及参数绑定,从而得到一个可以交由JDBC执行的sql语句