MyBatis 的秘密(八)动态SQL

动态SQL

说到动态SQL,就不得不提Script,Java作为一个静态语音,代码需要先编译,然后再运行,虽然带来了效率,但是却损失了灵活性。

Spring为此还专门提供了一套SpEL用来封装Java脚本语言API


MyBatis中,也支持动态SQL,想要将简单的String字符串编译成能运行的代码,需要其他的库的支持,MyBatis内部使用的是OGNL库。

OgnlCache中,是MyBatisOGNL的简单封装:

public static Object getValue(String expression, Object root) {
    try {
        Map context = Ognl.createDefaultContext(root, MEMBER_ACCESS, CLASS_RESOLVER, null);
        return Ognl.getValue(parseExpression(expression), context, root);
    } catch (OgnlException e) {
        throw new BuilderException("Error evaluating expression '" + expression + "'. Cause: " + e, e);
    }
}

主要便是增加了一层缓存。


有了上面的基础,我们就可以通过需求,来了解实现了:

MyBatis中,动态SQL标签有如下几个:

  • if :通过条件判断执行SQL
  • choose :通过switch选择一条执行SQL 一般和when / otherwise一起使用
  • trim : 简单加工SQL,比如去除头尾的逗号等,同类的还有where / set
  • foreach : 遍历容器,将遍历的结果拼接成SQL
  • bind : 通过OGNL表达式获取指定的值,并绑定到环境变量中

简单的使用方式如下:

<select id="findActiveBlogWithTitleLike"
     resultType="Blog">
  SELECT * FROM BLOG
  WHERE state = ‘ACTIVE’
  <if test="title != null">
    AND title like #{title}
  </if>
</select>

可以看到,动态SQL的关键就是获取title的值,然后执行test对应的表达式,最后根据结果拼接SQL

最后也是比较重要的一点就是,MyBatis的动态SQL标签是可以嵌套使用的:

比如:

<update id="update" parameterType="User">
   UPDATE users
   <trim prefix="SET" prefixOverrides=",">
       <if test="name != null and name != ''">
           name = #{name}
       </if>
       <if test="age != null and age != ''">
           , age = #{age}
       </if>
       <if test="birthday != null and birthday != ''">
           , birthday = #{birthday}
       </if>
   </trim>
   <where> 1=1
     <if test="id != null">
       and id = ${id}
     </if>
   </where>
</update>

这样的结构,就像是一颗树,需要层层遍历处理。


组合模式

前面说到了MyBatis处理动态SQL的需求,需要处理嵌套的标签。

而这个,恰好符合组合模式的解决场景。

MyBatis中,处理动态SQL的关键类如下:

  • SqlNode : 用来表示动态标签的相关信息
  • NodeHandler : 用来处理SqlNode其他信息的类
  • DynamicContext : 用来保存处理整个标签过程中,解析出来的信息,主要元素为StringBuilder
  • SqlSource : 用来表示XMLSQL的信息,MyBatis中,动态SQL最终都会通过SqlSource表示

SqlNode接口的定义如下:

public interface SqlNode {
  //处理目前的信息,并将处理完毕的信息追加到DynamicContext 中  
  boolean apply(DynamicContext context);
}

接下来从MyBaits创建以及使用SqlSource上来分析动态SQL的使用:

创建SqlSource的代码如下:

XMLScriptBuilder#parseScriptNode()

public SqlSource parseScriptNode() {
    //创建组合模式中的根节点
    MixedSqlNode rootSqlNode = parseDynamicTags(context);
    SqlSource sqlSource;
    //如果发现是动态节点,则创建DynamicSqlSource
    //反之创建RawSqlSource
    if (isDynamic) {
        sqlSource = new DynamicSqlSource(configuration, rootSqlNode);
    } else {
        sqlSource = new RawSqlSource(configuration, rootSqlNode, parameterType);
    }
    return sqlSource;
}

接着看parseDynamicTags()

  protected MixedSqlNode parseDynamicTags(XNode node) {
    //使用list保存所有sqlNode  
    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));
      //如果节点是Text节点,则使用TextSqlNode处理
      if (child.getNode().getNodeType() == Node.CDATA_SECTION_NODE || child.getNode().getNodeType() == Node.TEXT_NODE) {
        String data = child.getStringBody("");
        TextSqlNode textSqlNode = new TextSqlNode(data);
        //包含${},则需要额外处理
        if (textSqlNode.isDynamic()) {
          contents.add(textSqlNode);
          isDynamic = true;
        } else {
          contents.add(new StaticTextSqlNode(data));
        }
      }
      //如果是一个节点
      else if (child.getNode().getNodeType() == Node.ELEMENT_NODE) { 
        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);
  }

TextSqlNode作用之一便是检测SQL中是否包含${},如果包含,则判断为Dynamic


TextSqlNode的作用主要和#{xxx}类似,但是实现方式不同,#{xxx}底层是通过JDBC#ParperedStatementsetXXX方法设置参数,具有防止SQL注入的功能,而TextSqlNode则是直接替换的String,不会做任何的SQL处理,因此一般不建议使用。


接下来再看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) {
    //遍历调用apply方法  
    contents.forEach(node -> node.apply(context));
    return true;
  }
}

可以看见,非常简单,就是用来遍历所有子节点,分别调用apply()方法。

接下来我们看看其他标签的使用:

IfSqlNode

首先看ifSqlNode的创建:

IfSqlHandler#handleNode()

@Override
public void handleNode(XNode nodeToHandle, List<SqlNode> targetContents) {
    //加载子节点信息
    MixedSqlNode mixedSqlNode = parseDynamicTags(nodeToHandle);
    //获取Test表达式信息
    String test = nodeToHandle.getStringAttribute("test");
    //将信息传入`ifSqlNode`
    IfSqlNode ifSqlNode = new IfSqlNode(mixedSqlNode, test);
    targetContents.add(ifSqlNode);
}

可以看到,这里IfNode也充当了一个根节点,里面包含了其子节点信息。

这里可以大概猜想处理,IfSqlNode会通过OGNL执行test的内容,如果true,则执行后面的SqlNode,否则跳过

IfSqlNode#apply()

  @Override
  public boolean apply(DynamicContext context) {
    //通过OGNL判断test的值  
    if (evaluator.evaluateBoolean(test, context.getBindings())) {
      //如果为`true`则遍历子节点执行
      contents.apply(context);
      return true;
    }
    //否则跳过  
    return false;
  }

可以看到和前面的推理相符合

ChooseNode

ChooseHandler#handleNode()

@Override
public void handleNode(XNode nodeToHandle, List<SqlNode> targetContents) {
    List<SqlNode> whenSqlNodes = new ArrayList<>();
    List<SqlNode> otherwiseSqlNodes = new ArrayList<>();
    //遍历子节点,生成对应的SqlNode 将其保存在各个对应的容器中
    //whenSqlNode 的处理和IfNode的处理相同
    handleWhenOtherwiseNodes(nodeToHandle, whenSqlNodes, otherwiseSqlNodes);
    //验证otherwise的数量的合法性,只能有一个otherwise节点
    SqlNode defaultSqlNode = getDefaultSqlNode(otherwiseSqlNodes);
    //生成对应的ChooseSqlNode
    ChooseSqlNode chooseSqlNode = new ChooseSqlNode(whenSqlNodes, defaultSqlNode);
    targetContents.add(chooseSqlNode);
}

这里就可以猜想到ChooseNodeNode的处理的,应该是遍历所有的ifNode,然后当遇到符合条件的,边处理后续的Node,否则执行otherwise

ChooseSqlNode#apply()

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

TrimNode

TrimeNode是对SQL语句进行加工。

其包含3个属性:

  • prefix : 需要添加的前缀
  • suffix : 需要添加的尾缀
  • prefixOverrides : 当SQL 是以此标志开头的时候,需要移除的开头的内容
  • suffixOverrides : 当SQL 是以此标志结尾的时候,需要移除的结尾的内容

现在举个例子:

<select id="findActiveBlogLike"
     resultType="Blog">
  SELECT * FROM BLOG
    <trim prefix="WHERE" prefixOverrides="AND |OR ">
        <if test="state != null">
            state = #{state}
        </if>
        <if test="title != null">
            AND title like #{title}
        </if>
    </trim>
</select>    

可以看到,trim会自动为SQL 增加Where前缀,同时当statenull的时候,SQL会以AND开头,此时trim标签便会自动将AND删除。

同理,SET可能会遇到,结尾,只需要使用suffixOverrides 删除结尾即可,这里不再叙述。


接下来查看Trim的源码:

TrimHandler#handleNode()

@Override
public void handleNode(XNode nodeToHandle, List<SqlNode> targetContents) {
    //获取子节点
    MixedSqlNode mixedSqlNode = parseDynamicTags(nodeToHandle);
    //获取前缀
    String prefix = nodeToHandle.getStringAttribute("prefix");
    //获取前缀需要删除的内容
    String prefixOverrides = nodeToHandle.getStringAttribute("prefixOverrides");
    //获取尾缀
    String suffix = nodeToHandle.getStringAttribute("suffix");
    //获取尾缀需要删除的内容
    String suffixOverrides = nodeToHandle.getStringAttribute("suffixOverrides");
    //创建`TrimSqlNode`
    TrimSqlNode trim = new TrimSqlNode(configuration, mixedSqlNode, prefix, prefixOverrides, suffix, suffixOverrides);
    targetContents.add(trim);
}

这里可以看到,没有其他的处理,只是获取了属性然后初始化

TrimSqlNode#apply()

@Override
public boolean apply(DynamicContext context) {
    //创建FilteredDynamicContext对象
    FilteredDynamicContext filteredDynamicContext = new FilteredDynamicContext(context);
    //获取子元素的处理结果
    boolean result = contents.apply(filteredDynamicContext);
    //整体拼接SQL
    filteredDynamicContext.applyAll();
    return result;
}

这里出现了一个新的对象:FilteredDynamicContext,FilteredDynamicContext继承自DynamicContext,其相对于DynamicContext仅仅多了一个新的方法:applyAll(),

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

其中,applyPrefix()方法会检查SQL是否startWith()需要删除的元素,如果有,则删除。

    private void applyPrefix(StringBuilder sql, String trimmedUppercaseSql) {
      if (!prefixApplied) {
        prefixApplied = true;
        if (prefixesToOverride != null) {
          for (String toRemove : prefixesToOverride) {
            //如果SQL以toRemove开头,则删除
            if (trimmedUppercaseSql.startsWith(toRemove)) {
              sql.delete(0, toRemove.trim().length());
              break;
            }
          }
        }
        if (prefix != null) {
          sql.insert(0, " ");
          sql.insert(0, prefix);
        }
      }
    }

ForEachNode

foreach节点的元素很多:

  • item: 遍历的时候所获取的元素的具体的值,类似for(String item:list )中的item,对于Map,item对应为value

  • index : 遍历的时候所遍历的索引,类似for(int i=0;i<10;i++) 中的i,对于Mapindex对应为key

  • collection : 需要遍历的集合的参数名字,如果指定了@Param,则名字为@Param指定的名字,否则如果只有一个参数,且这个参数是集合的话,需要使用MyBatis包装的名字:

    • 对于Collection : 名字为collection
    • 对于List : 名字为list
    • 对于数组:名字为array

    相关代码如下:

    private Object wrapCollection(final Object object) {
      if (object instanceof Collection) {
        StrictMap<Object> map = new StrictMap<>();
        map.put("collection", object);
        if (object instanceof List) {
          map.put("list", object);
        }
        return map;
      } else if (object != null && object.getClass().isArray()) {
        StrictMap<Object> map = new StrictMap<>();
        map.put("array", object);
        return map;
      }
      return object;
    }
    
  • open : 类似TrimNode中的prefix

  • close : 类似TrimNode中的suffix

  • separator : 每个SQL 的分割符

使用方式如下:

<select id="selectPostIn" resultType="domain.blog.Post">
  SELECT *
  FROM POST P
  WHERE ID in
  <foreach item="item" index="index" collection="list"
      open="(" separator="," close=")">
        #{item}
  </foreach>
</select>

以上元素没有默认值,当没有设置的时候,MyBatis便不会设置相关的值,对于openclose,我们一般都会自己加上括号,所以有时候可以不设置。


接下来我们查看MyBatisforeach的源码:

ForEachNode的初始化代码没什么好看的,就是简单的获取相关的属性,然后初始化。我们直接看其apply()方法。

public boolean apply(DynamicContext context) {
    //准备添加绑定
    Map<String, Object> bindings = context.getBindings();
    final Iterable<?> iterable = evaluator.evaluateIterable(collectionExpression, bindings);
    if (!iterable.iterator().hasNext()) {
        return true;
    }
    boolean first = true;
    //追加Open符号
    applyOpen(context);
    //记录索引,用来赋值给`index`
    int i = 0;
    //调用`OGNL`的迭代器
    for (Object o : iterable) {
            //PrefixedContext继承自DynamicContext,主要是增加了分隔符
            context = new PrefixedContext(context, "");
        } else {
            context = new PrefixedContext(context, separator);
        }
        int uniqueNumber = context.getUniqueNumber();
        // Issue #709
        //对于Map key会绑定到index , value会绑定到item上
        if (o instanceof Map.Entry) {
            @SuppressWarnings("unchecked")
            Map.Entry<Object, Object> mapEntry = (Map.Entry<Object, Object>) o;
            applyIndex(context, mapEntry.getKey(), uniqueNumber);
            applyItem(context, mapEntry.getValue(), uniqueNumber);
        } else {
            //实时绑定i到index上
            applyIndex(context, i, uniqueNumber);
            //实时绑定具体的值到item上
            applyItem(context, o, uniqueNumber);
        }
        //生成对应的占位符,并绑定相关的值#{__frch_item_1}等
        contents.apply(new FilteredDynamicContext(configuration, context, index, item, uniqueNumber));
        if (first) {
            first = !((PrefixedContext) context).isPrefixApplied();
        }
        context = oldContext;
        i++;
    }
    //追加结尾符
    applyClose(context);
    context.getBindings().remove(item);
    context.getBindings().remove(index);
    return true;
}

BindNode

bind节点可以方便的运行OGNL表达式,并将结果绑定到指定的变量。

使用方法如下:

<select id="selectBlogsLike" resultType="Blog">
    <bind name="pattern" value="'%' + _parameter.getTitle() + '%'" />
    SELECT * FROM BLOG
    WHERE title LIKE #{pattern}
</select>

一般可以内置使用的元素为_parameter表示现在的参数,以及_databaseId,表示现在的database id

对于BindNode,对应的是VarDeclSqlNode,具体的代码这里不再细看,大概就是使用OGNL获取具体的值,比较简单。


对于动态SQL的节点对应的类,我们就分析完了,可以看到SqlNode完美的应用了组合模式,每个SqlNode都保存了其子节点下面的节点,执行下来便像是一颗树的递归。

当然,SqlNode的使用仅仅是动态SQL的一部分,但是它确实动态SQL的核心部分。