大家好,我是半夏之沫 😁😁 一名金融科技領域的JAVA系統研發😊😊
我希望將自己工作和學習中的經驗以最樸實,最嚴謹的方式分享給大家,共同進步👉💓👈
👉👉👉👉👉👉👉👉💓寫作不易,期待大家的關注和點贊💓👈👈👈👈👈👈👈👈
👉👉👉👉👉👉👉👉💓關注微信公眾號【技術探界】 💓👈👈👈👈👈👈👈👈
前言
Mybatis提供了強大的動態SQL語句生成功能,以應對複雜的業務場景,本篇文章將結合Mybatis解析SQL語句的過程對Mybatis中對<if>,<where>,<foreach>等動態SQL標籤的支持進行分析。
正文
一. XML文檔中的節點概念
在分析Mybatis如何支持SQL語句之前,本小節先分析XML文檔中的節點概念。XML文檔中的每個成分都是一個節點,DOM對XML節點的規定如下所示。
- 整個文檔是一個文檔節點;
- 每個XML標籤是一個元素節點;
- 包含在元素節點中的文本是文本節點。
以一個XML文檔進行説明,如下所示。
<provinces>
<province name="四川">
<capital>成都</capital>
</province>
<province name="湖北">
<capital>武漢</capital>
</province>
</provinces>
如上所示,整個XML文檔是一個文檔節點,這個文檔節點有一個子節點,就是<provinces>元素節點,<provinces>元素節點有五個子節點,分別是:文本節點,<province>元素節點,文本節點,<province>元素節點和文本節點,注意,在<provinces>元素節點的子節點中的文本節點的文本值均是\n,表示換行符。同樣,<province>元素節點有三個子節點,分別是:文本節點,<capital>元素節點和文本節點,這裏的文本節點的文本值也是\n,然後<capital>元素節點只有一個子節點,為一個文本節點。節點的子節點之間互為兄弟節點,例如<provinces>元素的五個子節點之間互為兄弟節點,name為“四川”的<province>元素節點的上一個兄弟節點為文本節點,下一個兄弟節點也為文本節點。
二. Mybatis支持動態SQL源碼分析
在Mybatis源碼-加載映射文件與動態代理中已經知道,在XMLStatementBuilder的parseStatementNode()方法中,會解析映射文件中的<select>,<insert>,<update>和<delete>標籤(後續統一稱為CURD標籤),並生成MappedStatement然後緩存到Configuration中。CURD標籤的解析由XMLLanguageDriver完成,每個標籤解析之後會生成一個SqlSource,可以理解為SQL語句,本小節將對XMLLanguageDriver如何完成CURD標籤的解析進行討論。
XMLLanguageDriver創建SqlSource的createSqlSource()方法如下所示。
public SqlSource createSqlSource(Configuration configuration,
XNode script, Class<?> parameterType) {
XMLScriptBuilder builder = new XMLScriptBuilder(
configuration, script, parameterType);
return builder.parseScriptNode();
}
如上所示,createSqlSource()方法的入參中,XNode就是CURD標籤對應的節點,在createSqlSource()方法中先是創建了一個XMLScriptBuilder,然後通過XMLScriptBuilder來生成SqlSource。先看一下XMLScriptBuilder的構造方法,如下所示。
public XMLScriptBuilder(Configuration configuration, XNode context,
Class<?> parameterType) {
super(configuration);
this.context = context;
this.parameterType = parameterType;
initNodeHandlerMap();
}
在XMLScriptBuilder的構造方法中,主要是將CURD標籤對應的節點緩存起來,然後初始化nodeHandlerMap,nodeHandlerMap中存放着處理Mybatis提供的支持動態SQL的標籤的處理器,initNodeHandlerMap()方法如下所示。
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());
}
現在分析XMLScriptBuilder的parseScriptNode()方法,該方法會創建SqlSource,如下所示。
public SqlSource parseScriptNode() {
// 解析動態標籤
MixedSqlNode rootSqlNode = parseDynamicTags(context);
SqlSource sqlSource;
if (isDynamic) {
// 創建DynamicSqlSource並返回
sqlSource = new DynamicSqlSource(configuration, rootSqlNode);
} else {
// 創建RawSqlSource並返回
sqlSource = new RawSqlSource(configuration, rootSqlNode, parameterType);
}
return sqlSource;
}
在XMLScriptBuilder的parseScriptNode()方法中,會根據XMLScriptBuilder中的isDynamic屬性判斷是創建DynamicSqlSource還是RawSqlSource,在這裏暫時不分析DynamicSqlSource與RawSqlSource的區別,但是可以推測在parseDynamicTags()方法中會改變isDynamic屬性的值,即在parseDynamicTags()方法中會根據CURD標籤的節點生成一個MixedSqlNode,同時還會改變isDynamic屬性的值以指示當前CURD標籤中的SQL語句是否是動態的。MixedSqlNode是什麼,isDynamic屬性值在什麼情況下會變為true,帶着這些疑問,繼續看parseDynamicTags()方法,如下所示。
protected MixedSqlNode parseDynamicTags(XNode node) {
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));
if (child.getNode().getNodeType() == Node.CDATA_SECTION_NODE
|| child.getNode().getNodeType() == Node.TEXT_NODE) {
// 子節點為文本節點
String data = child.getStringBody("");
// 基於文本節點的值並創建TextSqlNode
TextSqlNode textSqlNode = new TextSqlNode(data);
// isDynamic()方法可以判斷文本節點值是否有${}佔位符
if (textSqlNode.isDynamic()) {
// 文本節點值有${}佔位符
// 添加TextSqlNode到集合中
contents.add(textSqlNode);
// 設置isDynamic為true
isDynamic = true;
} else {
// 文本節點值沒有佔位符
// 創建StaticTextSqlNode並添加到集合中
contents.add(new StaticTextSqlNode(data));
}
} else if (child.getNode().getNodeType() == Node.ELEMENT_NODE) {
// 子節點為元素節點
// CURD節點的子節點中的元素節點只可能為<if>,<foreach>等動態Sql標籤節點
String nodeName = child.getNode().getNodeName();
// 根據動態Sql標籤節點的名稱獲取對應的處理器
NodeHandler handler = nodeHandlerMap.get(nodeName);
if (handler == null) {
throw new BuilderException("Unknown element <" + nodeName + "> in SQL statement.");
}
// 處理動態Sql標籤節點
handler.handleNode(child, contents);
// 設置isDynamic為true
isDynamic = true;
}
}
// 創建MixedSqlNode
return new MixedSqlNode(contents);
}
按照正常執行流程調用parseDynamicTags()時,入參是CURD標籤節點,此時會遍歷CURD標籤節點的所有子節點,基於每個子節點都會創建一個SqlNode然後添加到SqlNode集合contents中,最後將contents作為入參創建MixedSqlNode並返回。SqlNode是一個接口,在parseDynamicTags()方法中,可以知道,TextSqlNode實現了SqlNode接口,StaticTextSqlNode實現了SqlNode接口,所以當節點的子節點是文本節點時,如果文本值包含有${}佔位符,則創建TextSqlNode添加到contents中並設置isDynamic為true,如果文本值不包含${}佔位符,則創建StaticTextSqlNode並添加到contents中。如果CURD標籤節點的子節點是元素節點時,由於CURD標籤節點的元素節點只可能為<if>,<foreach>等動態SQL標籤節點,所以直接會設置isDynamic為true,同時還會調用動態SQL標籤節點對應的處理器來生成SqlNode並添加到contents中。這裏以<if>標籤節點對應的處理器的handleNode()方法為例進行説明,如下所示。
public void handleNode(XNode nodeToHandle, List<SqlNode> targetContents) {
// 遞歸調用parseDynamicTags()解析<if>標籤節點
MixedSqlNode mixedSqlNode = parseDynamicTags(nodeToHandle);
String test = nodeToHandle.getStringAttribute("test");
// 創建IfSqlNode
IfSqlNode ifSqlNode = new IfSqlNode(mixedSqlNode, test);
// 將IfSqlNode添加到contents中
targetContents.add(ifSqlNode);
}
在<if>標籤節點對應的處理器的handleNode()方法中,遞歸的調用了parseDynamicTags()方法來解析<if>標籤節點,例如<where>,<foreach>等標籤節點對應的處理器的handleNode()方法中也會遞歸調用parseDynamicTags()方法,這是因為這些動態SQL標籤是可以嵌套使用的,比如<where>標籤節點的子節點可以為<if>標籤節點。通過上面的handleNode()方法,大致可以知道MixedSqlNode和IfSqlNode也實現了SqlNode接口,下面看一下MixedSqlNode和IfSqlNode的實現,如下所示。
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;
}
}
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 (evaluator.evaluateBoolean(test, context.getBindings())) {
contents.apply(context);
return true;
}
return false;
}
}
其實到這裏已經逐漸清晰明瞭了,按照正常執行流程調用parseDynamicTags()方法時,是為了將CURD標籤節點的所有子節點根據子節點類型生成不同的SqlNode並放在MixedSqlNode中,然後將MixedSqlNode返回,但是CURD標籤節點的子節點中如果存在動態SQL標籤節點,因為這些動態SQL標籤節點也會有子節點,所以此時會遞歸的調用parseDynamicTags()方法,以解析動態SQL標籤節點的子節點,同樣會將這些子節點生成SqlNode並放在MixedSqlNode中然後將MixedSqlNode返回,遞歸調用parseDynamicTags()方法時得到的MixedSqlNode會保存在動態SQL標籤節點對應的SqlNode中,比如IfSqlNode中就會將遞歸調用parseDynamicTags()生成的MixedSqlNode賦值給IfSqlNode的contents。
不同的SqlNode都是可以包含彼此的,這是組合設計模式的應用,SqlNode之間的關係如下所示。
SqlNode接口定義了一個方法,如下所示。
public interface SqlNode {
boolean apply(DynamicContext context);
}
每個SqlNode的apply()方法中,除了實現自己本身的邏輯外,還會調用自己所持有的所有SqlNode的apply()方法,最終逐層調用下去,所有SqlNode的apply()方法均會被執行。
現在回到XMLScriptBuilder的parseScriptNode()方法,該方法中會調用parseDynamicTags()方法以解析CURD標籤節點並得到MixedSqlNode,MixedSqlNode中含有被解析的CURD標籤節點的所有子節點對應的SqlNode,最後會基於MixedSqlNode創建DynamicSqlSource或者RawSqlSource,如果CURD標籤中含有動態SQL標籤或者SQL語句中含有${}佔位符,則創建DynamicSqlSource,否則創建RawSqlSource。下面分別對DynamicSqlSource和RawSqlSource的實現進行分析。
DynamicSqlSource的實現如下所示。
public class DynamicSqlSource implements SqlSource {
private final Configuration configuration;
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的apply()方法完成Sql語句的生成
rootSqlNode.apply(context);
// SqlSourceBuilder可以將Sql語句中的#{}佔位符替換為?
SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
Class<?> parameterType = parameterObject == null ? Object.class : parameterObject.getClass();
// 將Sql語句中的#{}佔位符替換為?,並生成一個StaticSqlSource
SqlSource sqlSource = sqlSourceParser.parse(context.getSql(), parameterType, context.getBindings());
// StaticSqlSource中保存有動態生成好的Sql語句,並且#{}佔位符全部替換成了?
BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
// 生成有序參數映射列表
context.getBindings().forEach(boundSql::setAdditionalParameter);
return boundSql;
}
}
DynamicSqlSource的構造函數只是進行了簡單的賦值操作,重點在於其getBoundSql()方法,在getBoundSql()方法中,先是調用DynamicSqlSource中的SqlNode的apply()方法以完成動態SQL語句的生成,此時生成的SQL語句中的佔位符(如果有的話)為#{},然後再調用SqlSourceBuilder的parse()方法將SQL語句中的佔位符從#{}替換為?並基於替換佔位符後的SQL語句生成一個StaticSqlSource並返回,這裏可以看一下StaticSqlSource的實現,如下所示。
public class StaticSqlSource implements SqlSource {
private final String sql;
private final List<ParameterMapping> parameterMappings;
private final Configuration configuration;
public StaticSqlSource(Configuration configuration, String sql) {
this(configuration, sql, null);
}
public StaticSqlSource(Configuration configuration, String sql,
List<ParameterMapping> parameterMappings) {
// 構造函數只是進行簡單的賦值操作
this.sql = sql;
this.parameterMappings = parameterMappings;
this.configuration = configuration;
}
@Override
public BoundSql getBoundSql(Object parameterObject) {
// 基於Sql語句創建一個BoundSql並返回
return new BoundSql(configuration, sql, parameterMappings, parameterObject);
}
}
所以分析到這裏,可以知道DynamicSqlSource的getBoundSql()方法實際上會完成動態SQL語句的生成和#{}佔位符替換,然後基於生成好的SQL語句創建BoundSql並返回。BoundSql對象的類圖如下所示。
實際上,Mybatis中執行SQL語句時,如果映射文件中的SQL使用到了動態SQL標籤,那麼Mybatis中的Executor(執行器,後續文章中會進行介紹)會調用MappedStatement的getBoundSql()方法,然後在MappedStatement的getBoundSql()方法中又會調用DynamicSqlSource的getBoundSql()方法,所以Mybatis中的動態SQL語句會在這條語句實際要執行時才會生成。
現在看一下RawSqlSource的實現,如下所示。
public class RawSqlSource implements SqlSource {
private final SqlSource sqlSource;
public RawSqlSource(Configuration configuration, SqlNode rootSqlNode, Class<?> parameterType) {
// 先調用getSql()方法獲取Sql語句
// 然後再執行構造函數
this(configuration, getSql(configuration, rootSqlNode), parameterType);
}
public RawSqlSource(Configuration configuration, String sql, Class<?> parameterType) {
SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
Class<?> clazz = parameterType == null ? Object.class : parameterType;
// 將Sql語句中的#{}佔位符替換為?,生成一個StaticSqlSource並賦值給sqlSource
sqlSource = sqlSourceParser.parse(sql, clazz, new HashMap<>());
}
private static String getSql(Configuration configuration, SqlNode rootSqlNode) {
DynamicContext context = new DynamicContext(configuration, null);
rootSqlNode.apply(context);
return context.getSql();
}
@Override
public BoundSql getBoundSql(Object parameterObject) {
// 實際是調用StaticSqlSource的getBoundSql()方法
return sqlSource.getBoundSql(parameterObject);
}
}
RawSqlSource會在構造函數中就將SQL語句生成好並替換#{}佔位符,在SQL語句實際要執行時,就直接將生成好的SQL語句返回。所以Mybatis中,靜態SQL語句的執行通常要快於動態SQL語句的執行,這在RawSqlSource類的註釋中也有提及,如下所示。
Static SqlSource. It is faster than {@link DynamicSqlSource} because mappings are calculated during startup.
總結
Mybatis會為映射文件中的每個CURD標籤節點裏的SQL語句生成一個SqlSource,如果是靜態SQL語句,那麼會生成RawSqlSource,如果是動態SQL語句,則會生成DynamicSqlSource。Mybatis在生成SqlSource時,會為CURD標籤節點的每個子節點都生成一個SqlNode,無論子節點是文本值節點還是動態SQL元素節點,最終所有子節點對應的SqlNode都會放在SqlSource中以供生成SQL語句使用。如果是靜態SQL語句,那麼在創建RawSqlSource時就會使用SqlNode完成SQL語句的生成以及將SQL語句中的#{}佔位符替換為?,然後保存在RawSqlSource中,等到這條靜態SQL語句要被執行時,就直接返回這條靜態SQL語句。如果是動態SQL語句,在創建DynamicSqlSource時只會簡單的將SqlNode保存下來,等到這條動態SQL語句要被執行時,才會使用SqlNode完成SQL語句的生成以及將SQL語句中的#{}佔位符替換為?,最後返回SQL語句,所以Mybatis中,靜態SQL語句執行要快於動態SQL語句。
大家好,我是半夏之沫 😁😁 一名金融科技領域的JAVA系統研發😊😊
我希望將自己工作和學習中的經驗以最樸實,最嚴謹的方式分享給大家,共同進步👉💓👈
👉👉👉👉👉👉👉👉💓寫作不易,期待大家的關注和點贊💓👈👈👈👈👈👈👈👈
👉👉👉👉👉👉👉👉💓關注微信公眾號【技術探界】 💓👈👈👈👈👈👈👈👈