基于Mybatis的SQL模版解析

基于Mybatis的SQL模版解析

使用场景

在低代码平台、BI平台等研发类系统中,允许用户自定义SQL配置是一个常见的关键能力。此类场景下,单纯支持静态SQL(无动态参数)无法满足需求。

为了优化用户体验,很多时候还需要通过动态SQL模版提取模版中涉及的全部变量名称。

典型方案对比分析
  1. 基础参数透传(如腾讯微搭低代码平台)

    • 优势:简单易用,通过{param}占位符实现参数替换。

    • 缺陷:

      • 无法处理动态逻辑(如分支判断、循环遍历)。

      • 示例痛点:

        sql 复制代码
        -- 无法根据参数数量自动生成 WHERE id IN (?, ?, ?)
        SELECT * FROM table WHERE id IN ({ids})
  2. 通用模板引擎(如阿里有数BI平台)

    • 优势:使用Velocity模版引擎,支持条件判断、循环等复杂逻辑。

    • 缺陷:

      • 语法与SQL风格冲突(需用#foreach拼接逗号)。

      • 安全性依赖开发规范(直接拼接值可能引发注入)。

      • 用户需要熟悉Velocity,存在一定学习成本。

        示例:

        velocity 复制代码
        #foreach($id in $ids) 
          ${id}#if($foreach.hasNext),#end 
        #end
        -- 若$ids未校验,可能拼接恶意字符串
  3. 自研模版引擎

    • 优势:可以根据平台差异化垂直场景进行定制

    • 缺陷:

      • 维护成本高
      • 用户学习成本高
为什么选择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解析分为三步:

  1. XML解析:将XML文件转换为Document对象。
  2. SqlSource生成:根据是否含动态标签,构建静态/动态SqlSource。
  3. 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)
相关推荐
佚名涙3 小时前
go中锁的入门到进阶使用
开发语言·后端·golang
草捏子8 小时前
从CPU原理看:为什么你的代码会让CPU"原地爆炸"?
后端·cpu
嘟嘟MD8 小时前
程序员副业 | 2025年3月复盘
后端·创业
胡图蛋.9 小时前
Spring Boot 支持哪些日志框架?推荐和默认的日志框架是哪个?
java·spring boot·后端
无责任此方_修行中9 小时前
关于 Node.js 原生支持 TypeScript 的总结
后端·typescript·node.js
吃海鲜的骆驼9 小时前
SpringBoot详细教程(持续更新中...)
java·spring boot·后端
迷雾骑士10 小时前
SpringBoot中WebMvcConfigurer注册多个拦截器(addInterceptors)时的顺序问题(二)
java·spring boot·后端·interceptor
uhakadotcom10 小时前
Thrift2: HBase 多语言访问的利器
后端·面试·github
Asthenia041210 小时前
Java 类加载规则深度解析:从双亲委派到 JDBC 与 Tomcat 的突破
后端
方圆想当图灵10 小时前
从 Java 到 Go:面向对象的巨人与云原生的轻骑兵
后端·代码规范