基于Mybatis的SQL模版解析
使用场景
在低代码平台、BI平台等研发类系统中,允许用户自定义SQL配置是一个常见的关键能力。此类场景下,单纯支持静态SQL(无动态参数)无法满足需求。
为了优化用户体验,很多时候还需要通过动态SQL模版提取模版中涉及的全部变量名称。
典型方案对比分析
-
基础参数透传(如腾讯微搭低代码平台)
-
优势:简单易用,通过
{param}
占位符实现参数替换。 -
缺陷:
-
无法处理动态逻辑(如分支判断、循环遍历)。
-
示例痛点:
sql-- 无法根据参数数量自动生成 WHERE id IN (?, ?, ?) SELECT * FROM table WHERE id IN ({ids})
-
-
-
通用模板引擎(如阿里有数BI平台)
-
优势:使用Velocity模版引擎,支持条件判断、循环等复杂逻辑。
-
缺陷:
-
语法与SQL风格冲突(需用
#foreach
拼接逗号)。 -
安全性依赖开发规范(直接拼接值可能引发注入)。
-
用户需要熟悉Velocity,存在一定学习成本。
示例:
velocity#foreach($id in $ids) ${id}#if($foreach.hasNext),#end #end -- 若$ids未校验,可能拼接恶意字符串
-
-
-
自研模版引擎
-
优势:可以根据平台差异化垂直场景进行定制
-
缺陷:
- 维护成本高
- 用户学习成本高
-
为什么选择MyBatis动态SQL?
面向Java技术栈为主的平台,MyBatis动态SQL提供更优解:
-
零学习成本 :XML标签与SQL语句自然融合(如
<where>
、<foreach>
),Java开发者无额外学习成本。 -
安全性内置 :
#{ }
统一采用预编译机制,从根源杜绝SQL注入。 -
专业级动态能力:
- 自动处理
WHERE IN
遍历、空集合过滤(<foreach>
结合<if>
)。 - 智能修剪连接符(如
<trim>
自动去除多余AND
/,
)。
- 自动处理
选择Mybatis动态SQL的局限性:
-
自定义函数(UDF)调用语法冗长 :MyBatis依赖OGNL表达式调用外部方法,需使用全限定类名访问静态方法,导致代码可读性下降,而且写起来不方便。例如:
sql<if test="orderByClause != null"> order by ${@com.alibaba.security.SecurityUtil@trimSql4OrderBy(orderByClause)} </if>
原理简介
本文只做简要介绍,如果需要更深入了解技术细节,推荐阅读《Mybatis技术内幕》。
Mybatis的SQL解析分为三步:
- XML解析:将XML文件转换为Document对象。
- SqlSource生成:根据是否含动态标签,构建静态/动态SqlSource。
- SQL执行 :结合参数(
parameterObject
)生成最终BoundSql
。
scss
UserMapper.xml → XmlDocument → SqlSource ───┐
├─→ BoundSql (SQL + SQLArguments)
parameterObject ─┘
SqlSource类型:
- 静态SqlSource:没有XML标签,在初始化阶段就会解析SQL(如将
#{ }
替换为?
,生成ParameterMapping
),运行时只需填充参数值,性能更好 - 动态SqlSource:包含XML标签(如:where、if、foreach等),需要解释渲染
静态SqlSource:
scss
├── RawSqlSource(sqlSource)
│ └── StaticSqlSource(sql + parameterMappings)
动态SqlSource:
scss
├── DynamicSqlSource(rootSqlNode)
│ └── MixedSqlNode(contents)
│ └── ForEachSqlNode(collectionExpression + item + index + contents)
│ └── ChooseSqlNode(ifSqlNodes + defaultSqlNode)
│ └── IfSqlNode(testExpression + contents)
│ └── TrimSqlNode[including WhereSqlNode and SetSqlNode](contents)
│ └── StaticTextSqlNode(text)
│ └── TextSqlNode(text)
│ └── ...
参数语法:
#{expression}
:进行参数化,防止SQL注入。${expression}
:直接替换字符串,需自行评估SQL注入风险。- 转义:把
\
放在#
或$
前面,例如:#{expression}
表达式语法:OGNL
OGNL(Object-Graph Navigation Language): ognl.orphan.software/language-gu...
示例
解析渲染Mybatis的XML字符串代码示例:
java
import org.apache.ibatis.builder.xml.XMLMapperEntityResolver;
import org.apache.ibatis.parsing.XPathParser;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
public class MyBatisUtils {
private static final Configuration CONFIGURATION;
private static final Object[] ZERO_LENGTH_ARGS = new Object[0];
static {
// 初始化MyBatis的环境配置
Environment environment = new Environment("development",
new JdbcTransactionFactory(),
new UnpooledDataSource());
CONFIGURATION = new Configuration(environment);
}
@Getter
@AllArgsConstructor
public static final class SqlStatement {
/**
* SQL语句
*/
private final String sql;
/**
* 参数数组,用于填充可执行的SQL
*/
private final Object[] args;
}
/**
* 用于将XML格式的SQL语句和参数进行解析,并返回渲染后的SQL和参数列表。
*
* @param xmlString XML格式的SQL语句
* @param parameterObject 参数对象,可以是Map,在SQL中用作参数
* @return SQL语句和参数
* @throws RuntimeException XML解析或SQL渲染出错时抛出
*/
public static SqlStatement renderSql(String xmlString, Object parameterObject) {
if (StringUtils.isBlank(xmlString)) {
throw new IllegalArgumentException("xmlString is null or empty");
}
SqlSource sqlSource = parseSqlScript(xmlString);
BoundSql boundSql;
try {
boundSql = sqlSource.getBoundSql(parameterObject);
} catch (Exception e) {
throw new RuntimeException("SQL模板与参数不匹配", e);
}
String sql = boundSql.getSql();
Object[] args = parseArgs(boundSql);
return new SqlStatement(sql, args);
}
private static SqlSource parseSqlScript(String xmlString) {
xmlString = "<script>" + xmlString + "</script>";
XPathParser parser;
try {
parser = new XPathParser(xmlString, false, CONFIGURATION.getVariables(), new XMLMapperEntityResolver());
} catch (Exception e) {
throw new RuntimeException("SQL模板的XML格式错误", e);
}
XMLScriptBuilder builder = new XMLScriptBuilder(CONFIGURATION, parser.evalNode("/script"), Map.class);
return builder.parseScriptNode();
}
private static Object[] parseArgs(BoundSql boundSql) {
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
if (CollectionUtils.isEmpty(parameterMappings)) {
return ZERO_LENGTH_ARGS;
}
Object[] args = new Object[parameterMappings.size()];
Object parameterObject = boundSql.getParameterObject();
for (int i = 0; i < parameterMappings.size(); i++) {
ParameterMapping parameterMapping = parameterMappings.get(i);
if (parameterMapping.getMode() != ParameterMode.OUT) {
Object value;
String propertyName = parameterMapping.getProperty();
if (boundSql.hasAdditionalParameter(propertyName)) {
value = boundSql.getAdditionalParameter(propertyName);
} else if (parameterObject == null) {
value = null;
} else {
MetaObject metaObject = CONFIGURATION.newMetaObject(parameterObject);
value = metaObject.getValue(propertyName);
}
args[i] = value;
}
}
return args;
}
private MyBatisUtils() {
}
}
SQL结构解析示例
SQL:
sql
<set>
<if test="name != null">
name = #{name},
</if>
<if test="age != null">
age = #{age},
</if>
</set>
FROM tableA
<trim prefix="WHERE" prefixOverrides="AND |OR "><choose>
<when test="userId != null">
AND user_id = #{userId}
</when>
<otherwise>
AND user_name = #{userName}
</otherwise>
</choose></trim>
SqlSource结构:
ini
├── DynamicSqlSource
│ └── MixedSqlNode
│ ├── SetSqlNode (prefix="SET", prefixesToOverride=[","])
│ │ ├── StaticTextSqlNode (text="\n ")
│ │ ├── IfSqlNode (test="name != null")
│ │ │ └── StaticTextSqlNode (text="\n name = #{name},\n")
│ │ ├── StaticTextSqlNode (text="\n ")
│ │ ├── IfSqlNode (test="age != null")
│ │ │ └── StaticTextSqlNode (text="\n age = #{age},\n")
│ │ └── StaticTextSqlNode (text="\n ")
│ ├── StaticTextSqlNode (text="\nFROM tableA\n")
│ └── TrimSqlNode (prefix="WHERE", prefixesToOverride=["AND", "OR"])
│ └── ChooseSqlNode
│ ├── IfSqlNode (test="userId != null")
│ │ └── StaticTextSqlNode (text="\n AND user_id = #{userId}\n ")
│ └── MixedSqlNode
│ └── StaticTextSqlNode (text="\n AND user_name = #{userName}\n ")
OGNL解析示例
OGNL 表达式:
css
order || order[num].item[0].price > 100
语法树(AST)结构:
ini
├── ASTOr
│ └── ASTProperty
│ └── ASTConst(value="order")
│ └── ASTGreater
│ ├── ASTChain
│ │ ├── ASTProperty
│ │ │ └── ASTConst(value="order")
│ │ ├── ASTProperty(_indexedAccess=true) // 数组索引访问
│ │ │ └── ASTProperty
│ │ │ └── ASTConst(value="num")
│ │ ├── ASTProperty
│ │ │ └── ASTConst(value="item")
│ │ ├── ASTProperty(_indexedAccess=true)
│ │ │ └── ASTConst(value=0)
│ │ └── ASTProperty
│ │ └── ASTConst(value="price")
│ └── ASTConst(value=100)