深入 MyBatis 的秘密 动态SQL

需要了解更多可以参考这篇文章https://www.jianshu.com/p/af52bdb8106b
动态SQL
说到动态SQL,就不得不提Script,Java作为一个静态语音,代码需要先编译,然后再运行,虽然带来了效率,但是却损失了灵活性。
Spring为此还专门提供了一套SpEL用来封装Java脚本语言API
在MyBatis中,也支持动态SQL,想要将简单的String字符串编译成能运行的代码,需要其他的库的支持,MyBatis内部使用的是OGNL库。
在OgnlCache中,是MyBatis对OGNL的简单封装:

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 : 用来表示XML中SQL的信息,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#ParperedStatement的setXXX方法设置参数,具有防止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);
}

这里就可以猜想到ChooseNode对Node的处理的,应该是遍历所有的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前缀,同时当state为null的时候,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,对于Map,index对应为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便不会设置相关的值,对于open或close,我们一般都会自己加上括号,所以有时候可以不设置。

接下来我们查看MyBatis的foreach的源码:

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的核心部分。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容