基于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)
相关推荐
鬼火儿19 小时前
SpringBoot】Spring Boot 项目的打包配置
java·后端
cr7xin19 小时前
缓存三大问题及解决方案
redis·后端·缓存
间彧21 小时前
Kubernetes的Pod与Docker Compose中的服务在概念上有何异同?
后端
间彧21 小时前
从开发到生产,如何将Docker Compose项目平滑迁移到Kubernetes?
后端
间彧21 小时前
如何结合CI/CD流水线自动选择正确的Docker Compose配置?
后端
间彧21 小时前
在多环境(开发、测试、生产)下,如何管理不同的Docker Compose配置?
后端
间彧21 小时前
如何为Docker Compose中的服务配置健康检查,确保服务真正可用?
后端
间彧21 小时前
Docker Compose和Kubernetes在编排服务时有哪些核心区别?
后端
间彧21 小时前
如何在实际项目中集成Arthas Tunnel Server实现Kubernetes集群的远程诊断?
后端
brzhang1 天前
读懂 MiniMax Agent 的设计逻辑,然后我复刻了一个MiniMax Agent
前端·后端·架构