mybatis源码分析:阅读动态SQL实现原理笔记

Scroll Down

1、动态SQL的使用

例如

<select id="getUserById"
resultType="com.matrix.dao.User"
resultMap="user"
flushCache="false"
useCache="true"
timeout="10000"
fetchSize="256"
statementType="PREPARED"
resultSetType="FORWARD_ONLY">
    SELECT <include refid="userAllColumn"/> FROM user
    <where>
        <if test="id != null">
            AND id = #{id}
        </if>
    </where>
</select>

其他例子这里不多介绍了。

2、SqlSource与BoundSql详解

Mybatis中的SqlSource用于描述SQL资源。Mybatis可以通过两种方式配置SQL信息,一种是通过@Select、@Insert、@Delete、@Update或者@SelectProvider、@InsertProvider、@DeleteProvider、@UpdateProvider等注解;另一种是通过XML配置文件。SqlSource就代表Java注解或者XML文件配置的SQL资源。

public interface SqlSource {

  BoundSql getBoundSql(Object parameterObject);

}

getBoundSql()方法:该方法返回一个BoundSql实例,BoundSql是对SQL语句及参数信息的封装,它是SqlSource解析后的结果。SqlSource接口有4个不同的实现,分别为StaticSqlSource、DynamicSqlSource、RawSqlSource和ProviderSqlSource。
image.png
这四种SqlSource实现类的作用如下:
(1)、ProviderSqlSource:用于描述通过@Select、@SelectProvider等注解配置的SQL资源信息。
(2)、DynamicSqlSource:用于描述Mapper XML文件中配置的SQL资源信息,这些SQL通常包含动态SQL配置或者${}参数占位符,需要在Mapper调用时才能确定具体的SQL语句。
(3)、RawSqlSource:用于描述Mapper XML文件中配置的SQL资源信息,与DynamicSqlSource不同的是,这些SQL语句在解析XML配置的时候就能确定,即不包含SQL相关配置。
(4)、StaticSqlSource:用于描述ProviderSqlSource、DynamicSqlSource及RawSqlSource解析后得到的静态SQL资源。

3、LanguageDriver详解

SQL配置信息到SqlSource对象的转换是由LanguageDriver组件来完成的。

public interface LanguageDriver {

  /**
   * Creates a {@link ParameterHandler} that passes the actual parameters to the the JDBC statement.
   * 
   * @param mappedStatement The mapped statement that is being executed
   * @param parameterObject The input parameter object (can be null) 
   * @param boundSql The resulting SQL once the dynamic language has been executed.
   * @return
   * @author Frank D. Martinez [mnesarco]
   * @see DefaultParameterHandler
   */
  ParameterHandler createParameterHandler(MappedStatement mappedStatement, Object parameterObject, BoundSql boundSql);

  /**
   * Creates an {@link SqlSource} that will hold the statement read from a mapper xml file. 
   * It is called during startup, when the mapped statement is read from a class or an xml file.
   * 
   * @param configuration The MyBatis configuration
   * @param script XNode parsed from a XML file
   * @param parameterType input parameter type got from a mapper method or specified in the parameterType xml attribute. Can be null.
   * @return
   */
  SqlSource createSqlSource(Configuration configuration, XNode script, Class<?> parameterType);

  /**
   * Creates an {@link SqlSource} that will hold the statement read from an annotation.
   * It is called during startup, when the mapped statement is read from a class or an xml file.
   * 
   * @param configuration The MyBatis configuration
   * @param script The content of the annotation
   * @param parameterType input parameter type got from a mapper method or specified in the parameterType xml attribute. Can be null.
   * @return 
   */
  SqlSource createSqlSource(Configuration configuration, String script, Class<?> parameterType);

}

LanguageDriver接口中一共有3个方法,其中createParameterHandler()方法用于创建ParameterHandler对象,另外还有两个重载的createSqlSource()方法,这两个重载的方法用于创建SqlSource对象。
Mybatis中为LanguageDriver接口提供了两个实现类,分别为XMLLanguageDriver和RawLanguageDriver。XMLLanguageDriver为XML语言驱动,为Mybatis提供了通过XML标签结合OGNL表达式语法实现动态SQL的功能。而RawLanguageDriver表示仅支持静态SQL配置,不支持动态SQL功能。
XMLLanguageDriver的createSqlSource()方法如下所示:

@Override
public SqlSource createSqlSource(Configuration configuration, XNode script, Class<?> parameterType) {
    // 该方法用于解析XML文件中配置SQL信息
    // 创建XMLScriptBuilder对象
    XMLScriptBuilder builder = new XMLScriptBuilder(configuration, script, parameterType);
    // 调用XMLScriptBuilder对象的parseScriptNode()方法啊解析SQL资源
    return builder.parseScriptNode();
}

@Override
public SqlSource createSqlSource(Configuration configuration, String script, Class<?> parameterType) {
    // issue #3
    // 该方法用于解析Java注解中配置的SQL信息
    // 若字符串以script标签开头,则以XML方式解析
    if (script.startsWith("<script>")) {
      XPathParser parser = new XPathParser(script, false, configuration.getVariables(), new XMLMapperEntityResolver());
      return createSqlSource(configuration, parser.evalNode("/script"), parameterType);
    } else {
      // issue #127
      // 解析SQL配置中的全局变量
      script = PropertyParser.parse(script, configuration.getVariables());
      TextSqlNode textSqlNode = new TextSqlNode(script);
      // 如果SQL中仍包含${}参数占位符,则返回DynamicSqlSource实例,否则返回RawSqlSource
      if (textSqlNode.isDynamic()) {
        return new DynamicSqlSource(configuration, textSqlNode);
      } else {
        return new RawSqlSource(configuration, script, parameterType);
      }
    }
}

从XMLLanguageDriver类的createSqlSource()方法实现来看,除了可以通过XML配置文件结合OGNL表达式配置动态SQL外,还可以通过Java注解的方式配置,只需要注解中的内容加上script标签。

4、SqlNode详解

SqlNode用于描述Mapper SQL配置中的SQL节点,它是Mybatis框架实现动态SQL的基石。接口内容如下:

public interface SqlNode {
  boolean apply(DynamicContext context);
}

apply()方法:该方法用于解析SQL节点,根据参数信息生成静态SQL内容。apply()方法需要接收一个DynamicContext对象作为参数,DynamicContext对象中封装了Mapper调用时传入的参数信息及Mybatis内置的_parameter和_databaseId参数。
在使用动态SQL时,可以使用if、where、trim等标签,这些标签都对应一种具体的SqlNode实现类。
image.png
这些SqlNode实现类的作用如下:
(1)、IfSqlNode:用于描述动态SQL中if标签的内容,XMLLanguageDriver在解析Mapper SQL配置生成SqlSource时,会对动态SQL中if标签转换为IfSqlNode对象。
(2)、ChooseSqlNode:用于描述动态SQL配置中的choose标签内容,Mapper解析时会把choose标签内容转换为ChooseSqlNode对象。
(3)、ForEachSqlNode:用于描述动态SQL配置中的foreach标签,foreach标签配置信息在Mapper解析时会转换为ForEachSqlNode对象。
(4)、MixedSqlNode;用于描述一组SqlNode对象,通常一个Mapper配置是由多个SqlNode对象组成的,这些SqlNode对象通过MixedSqlNode进行关联,组成一个完整的动态SQL配置。
(5)、SetSqlNode:用于描述动态SQL配置中的set标签,Mapper解析时会把set标签配置信息转换为SetSqlNode对象。
(6)、WhereSqlNode:用于描述动态SQL配置中的where标签,动态SQL解析时,会把where标签内容转换为WhereSqlNode对象。
(7)、TrimSqlNode:用于描述动态SQL中的trim标签,动态解析SQL时,会把trim标签内容转化为TrimSqlNode对象。
(8)、StaticTextSqlNode:用于描述动态SQL中的静态文本内容。
(9)、TextSqlNode:该类与StaticTextSqlNode类不同的是,当静态文本中包含${}占位符时,说明${}需要在Mapper调用时将${}替换为具体的参数值。因此使用TextSqlNode类来描述。
(10)、VarDeclSqlNode:用于描述动态SQL中的bind标签,动态解析SQL时,会把bind标签配置信息转换为VarDeclSqlNode。
举个例子:

Reader reader = Resources.getResourceAsReader("mybatis-config.xml");
SqlSessionFactory factory = new SqlSessionFactoryBuilder().build(reader);
SqlSession sqlSession = factory.openSession();
SqlNode staticNode = new StaticTextSqlNode("select * from user where");
SqlNode ifNode1 = new IfSqlNode(new StaticTextSqlNode(" id = #{id}"), "id != null");
SqlNode ifNode2 = new IfSqlNode(new StaticTextSqlNode(" AND name = #{name}"), "name != null");
SqlNode ifNode3 = new IfSqlNode(new StaticTextSqlNode(" AND phone = #{phone}"), "phone != null");
SqlNode mixedSqlNode = new MixedSqlNode(Arrays.asList(staticNode, ifNode1, ifNode2, ifNode3));
Map<String, Object> paramMap = new HashMap<>();
paramMap.put("id", "1");
paramMap.put("name", "gaoming");
paramMap.put("phone", "111");
DynamicContext context = new DynamicContext(sqlSession.getConfiguration(), paramMap);
mixedSqlNode.apply(context);
System.out.println(context.getSql());

SQL如下:

select * from user where  id = #{id}  AND name = #{name}  AND phone = #{phone}

了解下SqlNode解析生成SQL语句的过程。首先来看MixedSqlNode的实现,代码如下:

@Override
public boolean apply(DynamicContext context) {
    for (SqlNode sqlNode : contents) {
      sqlNode.apply(context);
    }
    return true;
}

通过一个List对象维护所有的SqlNode对象,apply()方法对所有的SqlNode对象进行遍历,以当前DynamicContext对象作为参数,调用所有的SqlNode对象的apply()方法。
StaticTextSqlNode类的实现如下:

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

IfSqlNode的实现如下:

public class IfSqlNode implements SqlNode {
  // evaluator属性用于解析OGNL表达式
  private final ExpressionEvaluator evaluator;
  // 保存if标签test属性内容
  private final String test;
  // if标签内的SQL内容
  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) {
    // 如果OGNL表达式值为true,则调用if标签内容对应的SqlNode的apply()方法
    if (evaluator.evaluateBoolean(test, context.getBindings())) {
      contents.apply(context);
      return true;
    }
    return false;
  }

}

5、动态SQL解析过程

Mybatis动态SQL相关的一些组件。其中SqlSource用于描述通过XML文件或者Java注解配置的SQL资源信息;SqlNode用于描述动态SQL中if、where等标签信息;LanguageDriver用于对Mapper SQL配置进行解析,将SQL配置转换为SqlSource对象。

@Override
public SqlSource createSqlSource(Configuration configuration, XNode script, Class<?> parameterType) {
    // 该方法用于解析XML文件中配置的SQL信息
    // 创建XMLScriptBuilder对象
    XMLScriptBuilder builder = new XMLScriptBuilder(configuration, script, parameterType);
    // 调用XMLScriptBuilder对象parseScriptNode()方法解析SQL资源
    return builder.parseScriptNode();
}

XMLScriptBuilder类的parseScriptNode()方法的代码如下:

public SqlSource parseScriptNode() {
    // 调用parseDynamicTags()方法将SQL配置转化为SqlNode对象
    MixedSqlNode rootSqlNode = parseDynamicTags(context);
    SqlSource sqlSource = null;
    // 判断Mapper SQL配置中是否包含动态SQL元素,如果是,就创建DynamicSqlSource对象,否则就创建RawSqlSource对象
    if (isDynamic) {
      sqlSource = new DynamicSqlSource(configuration, rootSqlNode);
    } else {
      sqlSource = new RawSqlSource(configuration, rootSqlNode, parameterType);
    }
    return sqlSource;
}

需要注意的是,Mybatis中判断SQL配置是否属于动态SQL的标准是SQL配置是否包含if、where、trim等元素或者${}参数占位符。
XMLScriptBuilder类的parseDynamicTags()方法实现,代码如下:

protected MixedSqlNode parseDynamicTags(XNode node) {
    List<SqlNode> contents = new ArrayList<SqlNode>();
    NodeList children = node.getNode().getChildNodes();
    // 对XML子元素进行遍历
    for (int i = 0; i < children.getLength(); i++) {
      XNode child = node.newXNode(children.item(i));
      // 如果子元素为SQL文本内容,则使用TextSqlNode描述该节点
      if (child.getNode().getNodeType() == Node.CDATA_SECTION_NODE || child.getNode().getNodeType() == Node.TEXT_NODE) {
        String data = child.getStringBody("");
        TextSqlNode textSqlNode = new TextSqlNode(data);
        // 若SQL文本中包含${}参数占位符,则为动态SQL
        if (textSqlNode.isDynamic()) {
          contents.add(textSqlNode);
          isDynamic = true;
        } else {
          // 如果SQL文本中不包含${}参数占位符,则不是动态SQL
          contents.add(new StaticTextSqlNode(data));
        }
      } else if (child.getNode().getNodeType() == Node.ELEMENT_NODE) { // issue #628
        // 如果子元素为if、where等标签,则使用对应的NodeHandler处理
        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);
}

XMLScriptBuilder类中定义了一个私有的NodeHandler接口提供的8个实现类,每个类用于处理对应的动态SQL标签。接口定义如下:

private interface NodeHandler {
    void handleNode(XNode nodeToHandle, List<SqlNode> targetContents);
}

handleNode()方法接收一个动态SQL标签对应的XNode对象和一个存放SqlNode对象的List对象,handleNode()方法中对XML标签进行解析后,把生成的SqlNode对象添加到List对象中。
参考下IfHandler类的实现

private class IfHandler implements NodeHandler {
    public IfHandler() {
      // Prevent Synthetic Access
    }
    
    @Override
    public void handleNode(XNode nodeToHandle, List<SqlNode> targetContents) {
      // 继续调用parseDynamicTags方法解析if标签中的子节点
      MixedSqlNode mixedSqlNode = parseDynamicTags(nodeToHandle);
      // 获取if标签test属性
      String test = nodeToHandle.getStringAttribute("test");
      // 创建IfSqlNode对象
      IfSqlNode ifSqlNode = new IfSqlNode(mixedSqlNode, test);
      // 将IfSqlNode对象添加到List中
      targetContents.add(ifSqlNode);
    }
}

需要注意的是,XMLScriptBuilder类的构造方法中,会调用initNodeHandlerMap()方法将所有的NodeHandler的实例注册到Map中。

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());
}

需要解析动态SQL标签时,只需要根据标签名获取对应的NodeHandler对象进行处理即可,而不用每次都创建对应的NodeHandler实例,这也是享元思想的应用。
动态SQL标签解析完成后,将解析后生成的SqlNode对象封装在SqlSource对象中。Mybatis中的MappedStatement用于描述Mapper中的SQL配置,SqlSource创建完毕后,最终会存放在MappedStatement对象的sqlSource属性中,Executor组件操作数据库时,会调用MappedStatement对象的getBoundSql()方法获取BoundSql对象。

public BoundSql getBoundSql(Object parameterObject) {
    // 调用SqlSource对象的getBoundSql()方法获取BoundSql对象
    BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
    List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
    if (parameterMappings == null || parameterMappings.isEmpty()) {
      boundSql = new BoundSql(configuration, boundSql.getSql(), parameterMap.getParameterMappings(), parameterObject);
    }
    
    // check for nested result maps in parameter mappings (issue #30)
    for (ParameterMapping pm : boundSql.getParameterMappings()) {
      String rmId = pm.getResultMapId();
      if (rmId != null) {
        ResultMap rm = configuration.getResultMap(rmId);
        if (rm != null) {
          hasNestedResultMaps |= rm.hasNestedResultMaps();
        }
      }
    }
    
    return boundSql;
}

SqlSource对象的getBoundSql()方法,这个方法就完成了SqlNode对象解析成SQL语句的过程。
DynamicSqlSource的getBoundSql方法如下:

@Override
public BoundSql getBoundSql(Object parameterObject) {
    DynamicContext context = new DynamicContext(configuration, parameterObject);
    rootSqlNode.apply(context);
    SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
    Class<?> parameterType = parameterObject == null ? Object.class : parameterObject.getClass();
    SqlSource sqlSource = sqlSourceParser.parse(context.getSql(), parameterType, context.getBindings());
    BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
    for (Map.Entry<String, Object> entry : context.getBindings().entrySet()) {
      boundSql.setAdditionalParameter(entry.getKey(), entry.getValue());
    }
    return boundSql;
}

parse方法如下所示:

public SqlSource parse(String originalSql, Class<?> parameterType, Map<String, Object> additionalParameters) {
    // ParameterMappingTokenHandler为Mybatis参数映射器,用于处理SQL中的#{}参数占位符
    ParameterMappingTokenHandler handler = new ParameterMappingTokenHandler(configuration, parameterType, additionalParameters);
    // Token解析器,用于解析#{}参数
    GenericTokenParser parser = new GenericTokenParser("#{", "}", handler);
    String sql = parser.parse(originalSql);
    return new StaticSqlSource(configuration, sql, handler.getParameterMappings());
}

6、从源码角度分析#{}和${}的区别

首先来看下${}参数占位符的解析过程。当动态SQL配置中存在${}占位符时,Mybatis会使用TextSQLNode对象描述对应的SQL节点,在调用TextSqlNode对象的apply()方法时会完成动态SQL解析。也就是说,${}参数占位符的解析是在TextSqlNode类的apply()方法中完成的。

@Override
public boolean apply(DynamicContext context) {
    // 通过GenericTokenParser对象解析${}参数占位符,使用BindingTokenParser对象处理参数占位符内容
    GenericTokenParser parser = createParser(new BindingTokenParser(context, injectionFilter));
    context.appendSql(parser.parse(text));
    return true;
}

createParser方法如下:

private GenericTokenParser createParser(TokenHandler handler) {
    return new GenericTokenParser("${", "}", handler);
}

parse方法如下

public String parse(String text) {
    if (text == null || text.isEmpty()) {
      return "";
    }
    // search open token
    int start = text.indexOf(openToken, 0);
    if (start == -1) {
      return text;
    }
    char[] src = text.toCharArray();
    int offset = 0;
    final StringBuilder builder = new StringBuilder();
    StringBuilder expression = null;
    while (start > -1) {
      if (start > 0 && src[start - 1] == '\\') {
        // this open token is escaped. remove the backslash and continue.
        builder.append(src, offset, start - offset - 1).append(openToken);
        offset = start + openToken.length();
      } else {
        // found open token. let's search close token.
        if (expression == null) {
          expression = new StringBuilder();
        } else {
          expression.setLength(0);
        }
        builder.append(src, offset, start - offset);
        offset = start + openToken.length();
        int end = text.indexOf(closeToken, offset);
        while (end > -1) {
          if (end > offset && src[end - 1] == '\\') {
            // this close token is escaped. remove the backslash and continue.
            expression.append(src, offset, end - offset - 1).append(closeToken);
            offset = end + closeToken.length();
            end = text.indexOf(closeToken, offset);
          } else {
            expression.append(src, offset, end - offset);
            offset = end + closeToken.length();
            break;
          }
        }
        if (end == -1) {
          // close token was not found.
          builder.append(src, start, src.length - start);
          offset = src.length;
        } else {
          // 调用TokenHandler的handleToken方法替换参数占位符
          builder.append(handler.handleToken(expression.toString()));
          offset = end + closeToken.length();
        }
      }
      start = text.indexOf(openToken, offset);
    }
    if (offset < src.length) {
      builder.append(src, offset, src.length - offset);
    }
    return builder.toString();
}

handleToken方法如下:

@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;
}

总结下#{}和${}参数占位符的区别,使用#{}参数占位符时,占位符内容会被替换成?,然后通过PreparedStatement对象的setXxx()方法为参数占位符设置值;而${}参数占位符会被直接替换为参数值。使用#{}参数占位符能够有效避免SQL注入问题,所以可以优先使用#{}占位符,当#{}占位符无法满足要求时,才考虑使用${}参数占位符。