文章目录
前言
在使用JDBC API进行编程时,通常都要对SQL语句进行拼接,甚至还要根据不同的查询条件动态地拼接SQL语句,这个过程是比较繁琐且容易出错的,以致于花费更多的时间。
MyBatis的动态SQL特性就用于解决这个问题。本节先对动态SQL的基本使用,以及涉及到的核心组件进行研究,下一节完整地研究动态SQL的解析过程。
第八章 动态SQL实现原理
8.1 动态SQL的使用
顾名思义,动态SQL指的是事先无法预知具体的条件,需要在运行时根据具体的情况动态地生成SQL语句。
8.1.1 <if>
<if>标签的语法格式是:<if test="OGNL表达式"> SQL代码片段 </if>
。
如果OGNL表达式的结果为true,则将<if>标签内部的SQL代码片段加入到最终的SQL语句中,否则这一SQL代码片段将被忽略。例如:
<!--UserMapper.xml-->
<select id="selectByCons" parameterType="User" resultType="User">
select * from user
where 1 = 1
<if test="id != null">
and id = #{id}
</if>
<if test="age != null">
and age > 18
</if>
</select>
在上面的案例中,根据id和age属性是否为空,动态地构建SQL语句。但不足之处在于,如果id和age属性都为空,则构建的SQL语句为:select * from user where 1 = 1
,显然where子句很多余。
8.1.2 <where|trim>
<where>标签的作用就在于弥补<if>标签的不足,它可以构建一个where子句,并解决where子句中因为不同的条件成立时导致的where、and或or关键字多余的问题。例如:
<!--UserMapper.xml-->
<select id="selectByCons" parameterType="User" resultType="User">
select * from user
<where>
<if test="id != null">
and id = #{id}
</if>
<if test="age != null">
and age > 18
</if>
</where>
</select>
在上面的案例中,必须保证至少有一个查询条件时,MyBatis才会在SQL语句中追加where关键字,同时剔除where关键字后相邻的and或or关键字。 如果id和age属性都为空,那么where子句并不会构建。
另外,使用<trim>标签的作用与<where>标签的作用类似,例如上面的案例还可以改成如下所示,两者的效果是一样的:
<!--UserMapper.xml-->
<select id="selectByCons" parameterType="User" resultType="User">
select * from user
<trim prefix="where" prefixOverrides="and|or">
<if test="id != null">
and id = #{id}
</if>
<if test="age != null">
and age > 18
</if>
</trim>
</select>
8.1.3 <choose|when|otherwise>
这几个标签需要组合使用,例如:
<!--UserMapper.xml-->
<select id="selectByConditions" parameterType="User" resultType="User">
select * from user
where 1 = 1
<choose>
<when test="id != null">
and id = #{id}
</when>
<when test="name != null and name != ''">
and name like '%${name}%'
</when>
<otherwise>
and age > 18
</otherwise>
</choose>
</select>
在上面案例中,所有的<when>标签和<otherwise>标签是互斥的,也就是说只有一个<when>标签成立或最后的<otherwise>标签成立,其余的均不成立。
MyBatis会从上往下依次开始判断<when>标签中的OGNL表达式,一旦有一个<when>标签满足条件,则判定其余的均不成立。当所有的<when>标签都不成立时,则<otherwise>标签成立。
同时,<choose>标签不会追加where关键字,也不会剔除and或or关键字。
8.1.4 <foreach>
<foreach>标签用于对集合参数进行遍历,通常用于构建in条件语句或者insert批量插入语句。例如,当需要以一组ID查询用户信息时:
<!--UserMapper.xml-->
<select id="selectByConditions" parameterType="User" resultType="User">
select * from user
where id in
<foreach item="id" collection="idList" open="(" separator="," close=")">
#{id}
</foreach>
</select>
在上面案例中,<foreach>标签的collection属性是保存了多个ID值的集合,item属性定义了一个临时变量,从集合中遍历取出的ID值就会赋值到这个变量中。open和close属性分别指要构建的SQL语句片段的开始和结尾字符,separator属性指多个ID值之间的分隔符。
因此,案例中<foreach>标签最终构建出来的SQL语句片段是:(id1,id2,id3,...)
,整条SQL语句是:select * from user where id in (id1,id2,id3,...)
。
8.1.5 <set>
<set>标签用于update语句中set子句的构建,可以剔除set子句中多余的逗号。例如:
<!--UserMapper.xml-->
<update id="updateById" parameterType="User">
update user
<set>
<if test="name != null and name != ''">
name = #{name},
</if>
<if test="age != null">
age = #{age},
</if>
</set>
where id = #{id}
</update>
在上面案例中,如果name属性和age属性均不为空,构建的SQL语句片段是:name = #{name}, age = #{age},
,明显会多一个逗号,<set>标签则会将这个多余的逗号去掉。
总结一下,用于构建动态SQL语句的标签主要就是:<if>、<where|trim>、<choose|when|otherwise>、<foreach>、<set>等,用法比较简单,更详细的使用方法可以参考 MyBatis官方文档-动态SQL。
8.2 SqlSource组件&BoundSql组件
MyBatis支持两种方式配置SQL信息,一种是通过@Select
、@Insert
、@Update
、@Delete
或者@SelectProvider
、@InsertProvider
、@UpdateProvider
、@DeleteProvider
等注解,另一种是通过XML配置文件。
SqlSource组件就代表着Java注解或者XML配置文件的SQL资源。 其定义如下:
源码1:org.apache.ibatis.mapping.SqlSource
public interface SqlSource {
BoundSql getBoundSql(Object parameterObject);
}
由 源码1 可知,SqlSource接口只有一个getBoundSql()
方法,该方法返回一个BoundSql对象。
借助IDE,可以查看SqlSource接口的四个实现类:
这四个实现类的作用如下:
- ProviderSqlSource:用于描述通过
@Select
、@SelectProvider
等注解配置的SQL资源信息。 - DynamicSqlSource:用于描述通过XML配置文件配置的SQL资源信息,这种SQL通常包含动态SQL配置或者``${}```参数占位符,需要在Mapper调用时才能确定具体的SQL语句。
- RawSqlSource:用于描述通过XML配置文件配置的SQL资源信息,与DynamicSqlSource不同的是,这些SQL语句在解析XML配置文件时就能确定,即不包含动态SQL相关配置。
- StaticSqlSource:用于描述ProviderSqlSource、DynamicSqlSource及RawSqlSource解析后得到的静态SQL资源。
无论是Java注解还是XML配置文件配置的SQL信息,在Mapper调用时都会根据用户传入的参数将Mapper配置转换为StaticSqlSource类。StaticSqlSource类的定义如下:
源码2:org.apache.ibatis.builder.StaticSqlSource
public class StaticSqlSource implements SqlSource {
// Mapper解析后的SQL语句
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) {
return new BoundSql(configuration, sql, parameterMappings, parameterObject);
}
}
由 源码2 可知,StaticSqlSource类封装了Mapper解析后的SQL语句和Mapper参数映射信息,并重写了getBoundSql()
方法,该方法根据SQL语句和参数映射信息创建了一个BoundSql对象。
BoundSql是对SQL语句及参数信息的封装,它是SqlSource解析后的结果。 其定义如下:
源码3:org.apache.ibatis.mapping.BoundSql
public class BoundSql {
// 解析后的SQL语句
private final String sql;
// 参数映射信息
private final List<ParameterMapping> parameterMappings;
// 参数对象
private final Object parameterObject;
// 额外参数信息
private final Map<String, Object> additionalParameters;
// 参数对象对应的MetaObject对象
private final MetaObject metaParameters;
public BoundSql(Configuration configuration, String sql, List<ParameterMapping> parameterMappings,
Object parameterObject) {
this.sql = sql;
this.parameterMappings = parameterMappings;
this.parameterObject = parameterObject;
this.additionalParameters = new HashMap<>();
this.metaParameters = configuration.newMetaObject(additionalParameters);
}
// ......
}
由 源码3 可知,BoundSql除了封装Mapper解析后的SQL语句和参数映射信息,还封装了Mapper调用时传入的参数对象及其对应的MetaObject对象,以及一些额外的参数信息。这些属性均在BoundSql类的构造方法中初始化。
在【MyBatis3源码深度解析(十六)SqlSession的创建与执行(三)Mapper方法的调用过程】中指出,SELECT类型的Mapper方法的调用过程中,会调用BaseExecutor类的query()
方法:
源码4:org.apache.ibatis.executor.BaseExecutor
@Override
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler)
throws SQLException {
// 根据参数对象获取BoundSql对象
BoundSql boundSql = ms.getBoundSql(parameter);
CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
由 源码4 可知,BaseExecutor类的query()
方法中,会调用MappedStatement对象的getBoundSql()
方法获取一个BoundSql对象。
假设有如下Mapper配置:
<!--UserMapper.xml-->
<select id="selectByCons" parameterType="User" resultType="User">
select * from user where 1 = 1
<if test="id != null">
and id = #{id}
</if>
<if test="name != null and name != ''">
and name = #{name}
</if>
<if test="age != null">
and age = #{age}
</if>
</select>
有如下单元测试:
@Test
public void testBoundSql() throws IOException, NoSuchMethodException {
Reader reader = Resources.getResourceAsReader("mybatis-config.xml");
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(reader);
SqlSession sqlSession = sqlSessionFactory.openSession();
UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
User user = new User();
user.setId(1);
user.setAge(18);
// 调用selectByCons()方法
userMapper.selectByCons(user);
}
借助IDE,可以查看调用selectByCons()
方法时的BoundSql对象和SqlSource对象的具体封装的信息。
由图可知,BoundSql对象封装了具体的SQL语句(包含参数占位符)和参数映射信息,以及Mapper调用时传入的参数对象。
另外,MyBatis的任意一个Mapper都有两个内置参数,即_parameter
和_databaseId
。_parameter
代表参数对象,_databaseId
为Mapper配置中通过databaseId属性指定的数据库类型。两者都存放在BoundSql对象的additionalParameters属性中。
由图可知,SqlSource对象中存放的一系列SqlNode对象的实现类,最底层的是StaticSqlSource,这些SqlNode对象的实现类封装了全部SQL节点(SqlNode对象的原理详见8.4节)。
8.3 LanguageDriver组件
MyBatis是通过SqlSource对象描述XML文件或者Java注解中配置的SQL信息的,而SQL配置信息到SqlSource对象的转换是由LanguageDriver组件来完成的。
LanguageDriver组件的定义如下:
源码5:org.apache.ibatis.scripting.LanguageDriver
public interface LanguageDriver {
// 创建ParameterHandler对象
ParameterHandler createParameterHandler(MappedStatement mappedStatement, Object parameterObject, BoundSql boundSql);
// 创建SqlSource对象
SqlSource createSqlSource(Configuration configuration, XNode script, Class<?> parameterType);
SqlSource createSqlSource(Configuration configuration, String script, Class<?> parameterType);
}
由 源码5 可知,LanguageDriver接口有3个方法,其中createParameterHandler()
方法用于创建ParameterHandler对象,两个重载的createSqlSource()
方法用于创建SqlSource对象。
借助IDE,可以查看LanguageDriver接口的继承结构:
由继承结构可知,LanguageDriver接口有两个实现类:第一个XMLLanguageDriver,它是XML语言驱动,为MyBatis提供了通过XML标签(如<if>、<where>标签)实现动态SQL的功能;第二是RawLanguageDriver,它仅支持静态SQL配置,不支持动态SQL功能。
8.3.1 XMLLanguageDriver
源码6:org.apache.ibatis.scripting.xmltags.XMLLanguageDriver
public class XMLLanguageDriver implements LanguageDriver {
// 创建ParameterHandler对象,默认实现是DefaultParameterHandler
@Override
public ParameterHandler createParameterHandler(MappedStatement mappedStatement, Object parameterObject,
BoundSql boundSql) {
return new DefaultParameterHandler(mappedStatement, parameterObject, boundSql);
}
// 解析XML文件中的SQL配置信息,创建SqlSource对象
@Override
public SqlSource createSqlSource(Configuration configuration, XNode script, Class<?> parameterType) {
// 创建XMLScriptBuilder对象
XMLScriptBuilder builder = new XMLScriptBuilder(configuration, script, parameterType);
// 解析SQL资源
return builder.parseScriptNode();
}
// 解析Java注解中的SQL配置信息,创建SqlSource对象
@Override
public SqlSource createSqlSource(Configuration configuration, String script, Class<?> parameterType) {
// 若字符串以<script>标签开头,则以XML方式解析
if (script.startsWith("<script>")) {
XPathParser parser = new XPathParser(script, false, configuration.getVariables(), new XMLMapperEntityResolver());
return createSqlSource(configuration, parser.evalNode("/script"), parameterType);
}
// 解析Java注解中的SQL配置信息
script = PropertyParser.parse(script, configuration.getVariables());
TextSqlNode textSqlNode = new TextSqlNode(script);
// 根据是否是动态SQL语句返回不同的实现类
if (textSqlNode.isDynamic()) {
return new DynamicSqlSource(configuration, textSqlNode);
} else {
return new RawSqlSource(configuration, script, parameterType);
}
}
}
由 源码6 可知,XMLLanguageDriver类实现了LanguageDriver接口中的两个重载的createSqlSource()
方法,分别用于处理XML文件和Java注解中配置的SQL信息,他们都能将SQL配置信息转换为SqlSource对象。
第一个重载的createSqlSource()
方法用于解析XML文件中的SQL配置信息。 在该方法中,创建了一个XMLScriptBuilder对象,然后调用其parseScriptNode()
方法将SQL配置信息转换为SqlSource对象。
源码7:org.apache.ibatis.scripting.xmltags.XMLScriptBuilder
public class XMLScriptBuilder extends BaseBuilder {
public XMLScriptBuilder(Configuration configuration, XNode context, Class<?> parameterType) {
super(configuration);
this.context = context;
this.parameterType = parameterType;
initNodeHandlerMap();
}
public SqlSource parseScriptNode() {
// 将XNode对象解析转换为MixedSqlNode对象
MixedSqlNode rootSqlNode = parseDynamicTags(context);
SqlSource sqlSource;
if (isDynamic) {
sqlSource = new DynamicSqlSource(configuration, rootSqlNode);
} else {
sqlSource = new RawSqlSource(configuration, rootSqlNode, parameterType);
}
return sqlSource;
}
// ......
}
由 源码7 可知,创建XMLScriptBuilder对象时,会传入<select|insert|update|delete>标签对应的XNode对象。调用其parseScriptNode()
方法时,首先通过parseDynamicTags()
方法将XNode对象解析转换为MixedSqlNode对象,再判断是否是动态SQL语句,是的话封装成DynamicSqlSource对象返回,不是的话封装成RawSqlSource对象返回。
第二个重载的createSqlSource()
方法用于解析Java注解中的SQL配置信息。 在该方法中,首先判断SQL配置是否以<script>标签开头,如果是,则转调第一个重载的createSqlSource()
方法以XML方式处理Java注解中配置的SQL信息,并返回SqlSource对象。
如果不是以<script>标签开头,则通过PropertyParser类的parse()
方法替换掉SQL配置中的全局变量。最终再判断是否是动态SQL语句,是的话使用DynamicSqlSource对象描述SQL信息,否则使用RawSqlSource对象描述SQL信息。
8.3.2 RawLanguageDriver
源码8:org.apache.ibatis.scripting.defaults.RawLanguageDriver
public class RawLanguageDriver extends XMLLanguageDriver {
// 解析XML文件中的SQL配置信息
@Override
public SqlSource createSqlSource(Configuration configuration, XNode script, Class<?> parameterType) {
// 调用父类的createSqlSource()方法创建SqlSource对象
SqlSource source = super.createSqlSource(configuration, script, parameterType);
// 校验是否非动态,如果是动态则会抛出异常
checkIsNotDynamic(source);
return source;
}
// 解析Java注解中的SQL配置信息
@Override
public SqlSource createSqlSource(Configuration configuration, String script, Class<?> parameterType) {
// 调用父类的createSqlSource()方法创建SqlSource对象
SqlSource source = super.createSqlSource(configuration, script, parameterType);
// 校验是否非动态,如果是动态则会抛出异常
checkIsNotDynamic(source);
return source;
}
private void checkIsNotDynamic(SqlSource source) {
if (!RawSqlSource.class.equals(source.getClass())) {
throw new BuilderException("Dynamic content is not allowed when using RAW language");
}
}
}
由 源码8 可知,RawLanguageDriver继承自XMLLanguageDriver,重写了XMLLanguageDriver的两个createSqlSource()
方法。
在这两个方法中,均直接调用父类XMLLanguageDriver的createSqlSource()
方法创建SqlSource对象,唯一不同的是多加了一步:checkIsNotDynamic(source);
,这一步会校验SqlSource对象是否非动态,如果是动态的则会抛出异常,因为RawLanguageDriver就是用于静态SQL配置信息的。
8.4 SqlNode组件
8.4.1 SqlNode组件的作用
SqlNode组件用于描述XML配置文件中的SQL节点,例如<if>标签就是一个SQL节点,它对应了一个SqlNode对象。
源码9:org.apache.ibatis.scripting.xmltags.SqlNode
public interface SqlNode {
boolean apply(DynamicContext context);
}
由 源码9 可知,SqlNode接口只有一个apply()
方法,该方法用于解析SQL节点,根据参数信息生成静态SQL。该方法接收一个DynamicContext对象作为参数,该对象封装了Mapper方法调用时传入的参数信息。
在使用动态SQL时编写的<if>、<where>、<trim>等标签,都对应一种具体的SqlNode实现类。借助IDE,可以列出SqlNode接口的实现类:
这些实现类与XML配置文件中的动态SQL标签对应关系如下:
SqlNode实现类 | 动态SQL标签 |
---|---|
IfSqlNode | <if> |
ChooseSqlNode | <choose> |
ForEachSqlNode | <forcahe> |
SetSqlNode | <set> |
WhereSqlNode | <where> |
TrimSqlNode | <trim> |
VarDeclSqlNode | <bind> |
另外,还有几个比较特殊的实现类:
SqlNode实现类 | 作用 |
---|---|
MixedSqlNode | 用于描述一组SqlNode对象,通常一个Mapper配置是有多个SqlNode对象组成的,这些SqlNode对象通过MixedSqlNode进行关联,组成一个完整的动态SQL配置 |
StaticTextSqlNode | 用于描述动态SQL中的静态文本内容 |
TextSqlNode | 与StaticTextSqlNode作用类似,不同的地方在于,当静态文本中包含${} 占位符时使用,${} 需要在Mapper调用时将${} 替换为具体的参数值 |
在这些实现类中,绝大多数都是SqlNode接口的直接子类,但也有例外,即WhereSqlNode和SetSqlNode。如图:
这样设计是因为,<where>标签和<set>标签实际上是<trim>标签的一种特例,<where>标签和<set>标签实现的功能都可以用<trim>标签来完成,因此WhereSqlNode和SetSqlNode是TrimSqlNode的子类,属于特殊的TrimSqlNode。
8.4.2 IfSqlNode的实现原理
源码10:org.apache.ibatis.scripting.xmltags.IfSqlNode
public class IfSqlNode implements SqlNode {
// 用于解析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>标签内的SQL内容对应的SqlNode对象的apply()方法
if (evaluator.evaluateBoolean(test, context.getBindings())) {
contents.apply(context);
return true;
}
return false;
}
}
由 源码10 可知,IfSqlNode类中维护了一个ExpressionEvaluator类的实例,用于根据当前参数对象解析OGNL表达式;还维护了<if>标签的test属性的内容,以及<if>标签中的SQL内容对应的SqlNode对象。
在IfSqlNode的apply()
方法中,首先解析test属性指定的OGNL表达式,当表达式的结果为true时,执行<if>标签中的SQL内容对应的SqlNode对象的apply()
方法,该方法由具体的SqlNode实现类来完成。
这样就实现了,只有当<if>标签内test属性表达式值为true时,才追加<if>标签内的SQL信息。
8.4.3 StaticTextSqlNode的实现原理
源码11:org.apache.ibatis.scripting.xmltags.StaticTextSqlNode
public class StaticTextSqlNode implements SqlNode {
// 保存静态SQL文本内容
private final String text;
public StaticTextSqlNode(String text) {
this.text = text;
}
// 追加SQL内容
@Override
public boolean apply(DynamicContext context) {
context.appendSql(text);
return true;
}
}
由 源码11 可知,StaticTextSqlNode实现类维护了静态SQL文本内容,调用其apply()
方法时,将静态SQL文本内容追加到DynamicContext对象中。
8.4.4 MixedSqlNode的实现原理
前面提到,通常一个Mapper配置中有多个SqlNode对象,这些SqlNode对象通过MixedSqlNode进行关联。
源码12:org.apache.ibatis.scripting.xmltags.MixedSqlNode
public class MixedSqlNode implements SqlNode {
// 保存所有的SqlNode对象的容器
private final List<SqlNode> contents;
public MixedSqlNode(List<SqlNode> contents) {
this.contents = contents;
}
@Override
public boolean apply(DynamicContext context) {
// 遍历SqlNode对象,调用所有SqlNode对象的apply()方法
contents.forEach(node -> node.apply(context));
return true;
}
}
由 源码12 可知,MixedSqlNode实现类内部维护了一个List容器,以保存所有的SqlNode对象。调用其apply()
方法时,对所有的SqlNode对象进行遍历,以当前DynamicContext对象为参数,调用所有SqlNode对象的apply()
方法。
8.4.5 SqlNode组件的使用案例
上面详细研究了三个SqlNode实现类的实现原理,其他的实现类的原理类似。下面通过一个案例来加深一下SqlNode组件的原理。
仍然是上面的Mapper配置:
<!--UserMapper.xml-->
<select id="selectByCons" parameterType="User" resultType="User">
select * from user where 1 = 1
<if test="id != null">
and id = #{id}
</if>
<if test="name != null and name != ''">
and name = #{name}
</if>
<if test="age != null">
and age > 18
</if>
</select>
从MyBatis动态SQL的角度看,它由4个SqlNode对象构成,分别是3个IfSqlNode和1个StaticTextSqlNode。测试代码如下:
@Test
public void testSqlNode() throws IOException {
// 1.读取配置文件,创建会话
Reader reader = Resources.getResourceAsReader("mybatis-config.xml");
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(reader);
SqlSession sqlSession = sqlSessionFactory.openSession();
// 2.构建SqlNode
SqlNode sn1 = new StaticTextSqlNode("select * from user where 1 = 1");
SqlNode sn2 = new IfSqlNode(new StaticTextSqlNode("and id = #{id}"), "id != null");
SqlNode sn3 = new IfSqlNode(new StaticTextSqlNode("and name = #{name}"), "name != null and name != ''");
SqlNode sn4 = new IfSqlNode(new StaticTextSqlNode("and age = #{age}"), "age != null");
// 3.使用MixedSqlNode将SqlNode组合起来
MixedSqlNode mixedSqlNode = new MixedSqlNode(Arrays.asList(sn1, sn2, sn3, sn4));
// 4.构建参数对象
HashMap<String, Object> paramMap = new HashMap<>();
paramMap.put("id", "1");
paramMap.put("age", "18");
// 5.调用MixedSqlNode的apply()方法
DynamicContext dynamicContext = new DynamicContext(sqlSession.getConfiguration(), paramMap);
mixedSqlNode.apply(dynamicContext);
// 6.获取SQL语句
System.out.println(dynamicContext.getSql());
}
执行单元测试,控制台打印出SQL语句:
select * from user where 1 = 1 and id = #{id} and age = #{age}
可见,SQL语句根据传入的参数动态地构建出来了。
······
本节完,更多内容请查阅分类专栏:MyBatis3源码深度解析
标签:对象,标签,SqlSource,SqlNode,源码,SQL,动态,public From: https://blog.csdn.net/weixin_42739799/article/details/136967645