从源码讲解mybatis中#{}和${}的区别

前言

看到这个标题读者们会觉得作者在老生常谈。这个面试中问烂了的问题,在百度一搜一大把答案,然而作者发现这些答案无非是如下:

  • #{}是预编译处理,${}是字符串替换
  • Mybatis在处理#{}时,会将sql中的#{}替换为?号,使用sql预编译处理
  • Mybatis在处理时,就是把${}替换成变量的值
  • 使用#{}可以有效的防止SQL注入,提高系统安全性
  • 等等…

当然这些答案言简意赅,通俗易懂,但是作者在看到这些答案时,并没有一种满足感,作者想知道这些区别产生的原因(这应该是一篇技术文章的核心)。在解答这个问题时,脱离了原理的讲解,如同填鸭式一般。能给读者什么收益呢?应付面试或许勉强过关,对技术增益或许一点帮助也没有,因为读完这些博客你只得到了一个问题的答案而已,换种提问方式读者还会继续懵逼的。不信可以思考这个问题

statementType类型的选择,对带有#{}和${}的sql语句的执行有何影响?(这也算是两者的区别吧)
注:statementType类型有STATEMENT、CALLABLE、PREPARED

讲解

对于这个问题的答案作者不再累述。作者在此通过分析mybatis源码,让读者找到自己心目中的最佳答案。首先我们要知道mybatis是如何解析sql的,这就需要讲解下以下几个类,同时讲解所使用的样例如下:

<select id="getStudentBydId" resultMap="baseResultMao">
        SELECT * FROM ${tableName} WHERE std_id = #{studentId}
</select>
<!-- 调用 getStudentById("ms_student",1); -->
1. XMLStatementBuilder

顾名思义,该类是从XML中构建statement,该类仅有一个公有方法,该方法是用来解析mapper映射文件中的<select>、<insert>、<update>、<delete>等标签的,代码如下:

public void parseStatementNode() {
	// 获取Id
    String id = context.getStringAttribute("id");
    // 获得databaseId
    String databaseId = context.getStringAttribute("databaseId");

    if (!databaseIdMatchesCurrent(id, databaseId, this.requiredDatabaseId)) {
      return;
    }

    Integer fetchSize = context.getIntAttribute("fetchSize");
    Integer timeout = context.getIntAttribute("timeout");
    String parameterMap = context.getStringAttribute("parameterMap");
    String parameterType = context.getStringAttribute("parameterType");
    Class<?> parameterTypeClass = resolveClass(parameterType);
    String resultMap = context.getStringAttribute("resultMap");
    String resultType = context.getStringAttribute("resultType");
    String lang = context.getStringAttribute("lang");
    LanguageDriver langDriver = getLanguageDriver(lang);

    Class<?> resultTypeClass = resolveClass(resultType);
    String resultSetType = context.getStringAttribute("resultSetType");
    // 这个属性重点关注一下,在不配置时,默认值为PREPARED
    StatementType statementType = StatementType.valueOf(context.getStringAttribute("statementType", StatementType.PREPARED.toString()));
    ResultSetType resultSetTypeEnum = resolveResultSetType(resultSetType);

    String nodeName = context.getNode().getNodeName();
    SqlCommandType sqlCommandType = SqlCommandType.valueOf(nodeName.toUpperCase(Locale.ENGLISH));
    boolean isSelect = sqlCommandType == SqlCommandType.SELECT;
    boolean flushCache = context.getBooleanAttribute("flushCache", !isSelect);
    boolean useCache = context.getBooleanAttribute("useCache", isSelect);
    boolean resultOrdered = context.getBooleanAttribute("resultOrdered", false);

    // Include Fragments before parsing
    XMLIncludeTransformer includeParser = new XMLIncludeTransformer(configuration, builderAssistant);
    includeParser.applyIncludes(context.getNode());

    // Parse selectKey after includes and remove them.
    processSelectKeyNodes(id, parameterTypeClass, langDriver);
    
    // Parse the SQL (pre: <selectKey> and <include> were parsed and removed)
    // 解析SQL
    SqlSource sqlSource = langDriver.createSqlSource(configuration, context, parameterTypeClass);
    String resultSets = context.getStringAttribute("resultSets");
    String keyProperty = context.getStringAttribute("keyProperty");
    String keyColumn = context.getStringAttribute("keyColumn");
    KeyGenerator keyGenerator;
    String keyStatementId = id + SelectKeyGenerator.SELECT_KEY_SUFFIX;
    keyStatementId = builderAssistant.applyCurrentNamespace(keyStatementId, true);
    if (configuration.hasKeyGenerator(keyStatementId)) {
      keyGenerator = configuration.getKeyGenerator(keyStatementId);
    } else {
      keyGenerator = context.getBooleanAttribute("useGeneratedKeys",
          configuration.isUseGeneratedKeys() && SqlCommandType.INSERT.equals(sqlCommandType))
          ? Jdbc3KeyGenerator.INSTANCE : NoKeyGenerator.INSTANCE;
    }

    builderAssistant.addMappedStatement(id, sqlSource, statementType, sqlCommandType,
        fetchSize, timeout, parameterMap, parameterTypeClass, resultMap, resultTypeClass,
        resultSetTypeEnum, flushCache, useCache, resultOrdered, 
        keyGenerator, keyProperty, keyColumn, databaseId, langDriver, resultSets);
} 

显而易见,<select>、<insert>、<update>、<delete>标签中含有sql语句,sql的解析调用的是下面这句代码:

SqlSource sqlSource = 
langDriver.createSqlSource(configuration, context, parameterTypeClass);

我们是解析XML中sql,当然langDriver应该是XMLLanguageDriver

2. XMLLanguageDriver

XMLLanguageDriver中有两个createSqlSource方法,属于方法重载,现在看一下该类中的createSqlSource方法源码

/** createSqlSource方法1 **/
public SqlSource createSqlSource(Configuration configuration, String script, Class<?> parameterType) {
    // issue #3
    if (script.startsWith("<script>")) {
        XPathParser parser = new XPathParser(script, false, configuration.getVariables(), new XMLMapperEntityResolver());
        // 调用了createSqlSource方法
        return createSqlSource(configuration, parser.evalNode("/script"), parameterType);
    } else {
        // issue #127
        script = PropertyParser.parse(script, configuration.getVariables());
        TextSqlNode textSqlNode = new TextSqlNode(script);
        if (textSqlNode.isDynamic()) {
            return new DynamicSqlSource(configuration, textSqlNode);
        } else {
            return new RawSqlSource(configuration, script, parameterType);
        }
    }
}
/** createSqlSource方法2和方法1的区别在于参数列表的不同 **/
public SqlSource createSqlSource(Configuration configuration, XNode script, Class<?> parameterType) {
    XMLScriptBuilder builder = new XMLScriptBuilder(configuration, script, parameterType);
    return builder.parseScriptNode();
}

经上述代码分析,XMLLanguageDriver#createSqlSource调用的是XMLLanguageDriver中第二个createSqlSource方法,而此法中将SqlSource的创建委托给了XMLScriptBuilder,看下XMLScriptBuilder.

3. XMLScriptBuilder

XMLScriptBuilder#parseScriptNode方法的源码如下:

public SqlSource parseScriptNode() {
    // parseDynamicTags(context):
    // 此处的context是由<select>、<insert>、<update>、<delete>
    // 标签解析成的XNode对象
    // 而parseDynamicTags方法则是处理该XNode对象
    // 把其中包含的sql片段解析出来封装成SqlNode对象
    MixedSqlNode rootSqlNode = parseDynamicTags(context);//见下文截图一
    SqlSource sqlSource = null;
    // 根据sql语句是否为动态sql,创建不同的SqlSource对象
    if (isDynamic) {
        sqlSource = new DynamicSqlSource(configuration, rootSqlNode);
    } else {
        sqlSource = new RawSqlSource(configuration, rootSqlNode, parameterType);
    }
    return sqlSource;
}
/** parseDynamicTags方法(见截图二) **/
protected MixedSqlNode parseDynamicTags(XNode node) {
    // 创建一个空列表用于保存SqlNode
    List<SqlNode> contents = new ArrayList<SqlNode>();
    /* 此处使用DOM解析获得<select>标签的子节点
    	<select id="getStudentBydId" resultMap="baseResultMao">
        	SELECT * FROM ${tableName} WHERE std_id = #{studentId}
   		</select>(<select> 标签)
    */
    NodeList children = node.getNode().getChildNodes();
    // 循环解析<select>标签的子节点
    // 由于我们的<select>标签比较简单,只有一个子节点
    // <#text SELECT * FROM ${tableName} WHERE std_id = #{studentId} >
    for (int i = 0; i < children.getLength(); i++) {
        XNode child = node.newXNode(children.item(i));
        // 判断子节点是否为文本类型或者CDATA类型
        // 此处子节点为文本类型
        if (child.getNode().getNodeType() == Node.CDATA_SECTION_NODE 
            || child.getNode().getNodeType() == Node.TEXT_NODE) {
            // data="\n SELECT * FROM ${tableName} WHERE std_id = #{studentId} \n"
            String data = child.getStringBody("");
            // 将data传入TextSqlNode,据此作为判读sql语句是否为
            // 动态sql的一个标准(并不是唯一标准)
            TextSqlNode textSqlNode = new TextSqlNode(data);
            // TextSqlNode#isDynamic()方法解析见下文TextSqlNode类
            if (textSqlNode.isDynamic()) {
                // TextSqlNode是SqlNode的一个实现类,
                // 此处将解析好的TextSqlNode放进方法开头的列表中
                // 同时还告诉我们TextSqlNode与动态SQL有关
                contents.add(textSqlNode);
                isDynamic = true;
            } else {
                // 如果不是动态sql,则创建StaticTextSqlNode,
                // 同时也放进方法开头的列表中
                contents.add(new StaticTextSqlNode(data));
            }
            // 如果子节点是节点类型:如<if>、<where>、<when>等标签则
            // 封装成对应的NodeHandler对象,做进一步处理,
            // 在NodeHandler中handleNode方法中大都调用parseDynamicTags方法
            // 由此可见此处形成一个递归调用,且递归结束的条件是
            // 解析到子节点为text或CDATA类型,此时都会走上面的逻辑
            // 先判读是否是动态sql,然后创建TextSqlNode或StaticSqlNode
        } else if (child.getNode().getNodeType() == Node.ELEMENT_NODE) { // issue #628
            String nodeName = child.getNode().getNodeName();
            NodeHandler handler = nodeHandlerMap.get(nodeName);
            if (handler == null) {
                throw new BuilderException("Unknown element <" 
                                           + nodeName 
                                           + "> in SQL statement.");
            }
            handler.handleNode(child, contents);
            isDynamic = true;
        }
    }
    // 解析完成将SqlNode列表传入MixedSqlNode对象
    return new MixedSqlNode(contents);
}

截图一p1.jpg
截图二p2.jpg

4. TextSqlNode

由于我们的sql比较简单,没有进入NodeHandler去解析,从上节中我们获知信息如下

  • data="\n SELECT * FROM ${tableName} WHERE std_id = #{studentId} \n"
  • TextSqlNode textSqlNode = new TextSqlNode(data);
  • textSqlNode#isDynamic()
// 变量 text
private final String text;
// 构造方法1
public TextSqlNode(String text) {
    this(text, null);
}
// 构造方法2
public TextSqlNode(String text, Pattern injectionFilter) {
    this.text = text;
    this.injectionFilter = injectionFilter;
}
// 此时text = "\n SELECT * FROM ${tableName} WHERE std_id = #{studentId} \n"
public boolean isDynamic() {
    // DynamicCheckerTokenParser是TextSqlNode的内部类
    DynamicCheckerTokenParser checker = new DynamicCheckerTokenParser();
    // 实例化一个GenericTokenParser的对象
    GenericTokenParser parser = createParser(checker);
    // 调用GenericTokenParser#parse方法,处理text
    parser.parse(text);
    return checker.isDynamic();
}
// createParser方法,创建了GenericTokenParser对象
private GenericTokenParser createParser(TokenHandler handler) {
    // openToken = "${"
    // closeToken = "}"
    // handler = DynamicCheckerTokenParser
    return new GenericTokenParser("${", "}", handler);
}
// 这个内部类比价简单
private static class DynamicCheckerTokenParser implements TokenHandler {
    // 初始化后isDynamic = false
    private boolean isDynamic;
    public DynamicCheckerTokenParser() {
        // Prevent Synthetic Access
    }
    // 返回是否为动态
    public boolean isDynamic() {
        return isDynamic;
    }
    // 这个方法没关于content的操作,只是用来给isDynamic赋值
    // 只要调用过此方法 isDynamic就会被赋值为true
    @Override
    public String handleToken(String content) {
        this.isDynamic = true;
        return null;
    }
}

经上述源码分析,获知isDynamic的判断是由DynamicCheckerTokenParser和GenericTokenParser共同作用的结果,DynamicCheckerTokenParser已经分析,只要调用过其handleToken方法,就会判断isDynamic为true,那么我们需要看一下在GenericTokenParser类中何时何地调用handleToken方法。

5. GenericTokenParser

这个类比较重要,mybatis中使用的比较多,解析#{param}、${param}都用到此类

public class GenericTokenParser {
 	// 开始标记符
    private final String openToken;
    // 结束标记符
    private final String closeToken;
    // 标记处理接口
    // 具体的处理操作取决于它的实现类中的具体方法
    private final TokenHandler handler;
    // 构造函数
    public GenericTokenParser(String openToken, 
                              String closeToken, 
                              TokenHandler handler) {
        this.openToken = openToken;
        this.closeToken = closeToken;
        this.handler = handler;
    }
    public String parse(String text) {
        // 文本空值判断
        if (text == null || text.isEmpty()) {
            return "";
        }
        // 获取开始标记符在文本中的位置
        int start = text.indexOf(openToken, 0);
        //  //位置索引值为-1,说明不存在该开始标记符,直接返回文本
        if (start == -1) {
            return text;
        }
        // 将text转为char数组,便于后面通过”位置“操作
        char[] src = text.toCharArray();
        // 偏移量
        int offset = 0;
        // 用于存储解析后的text
        final StringBuilder builder = new StringBuilder();
        // 用于存储openToken和closeToken之间的字符串
        StringBuilder expression = null;
        while (start > -1) {
            // 判断开始标记符前是否有转移字符,如果存在转义字符则移除转义字符
            if (start > 0 && src[start - 1] == '\\') {
                // 移除转义字符
                builder.append(src, offset, start - offset - 1).append(openToken);
                // 重新给偏移量赋值赋值
                offset = start + openToken.length();
            } else {
                // 开始查找结束标记符
                if (expression == null) {
                    expression = new StringBuilder();
                } else {
                    expression.setLength(0);
                }
                builder.append(src, offset, start - offset);
                offset = start + openToken.length();
                // 结束标记符索引值
                int end = text.indexOf(closeToken, offset);
                while (end > -1) {
                    //同样判断标识符前是否有转义字符,有就移除
                    if (end > offset && src[end - 1] == '\\') {
                        // this close token is escaped. remove the backslash and continue.
                        expression.append(src, offset, end - offset - 1).append(closeToken);
                         // 重新计算偏移量
                        offset = end + closeToken.length();
                         // 重新计算结束标识符的索引值
                        end = text.indexOf(closeToken, offset);
                    } else {
                        expression.append(src, offset, end - offset);
                        offset = end + closeToken.length();
                        break;
                    }
                }
                  // 没有找到结束标记符
                if (end == -1) {
                    // close token was not found.
                    builder.append(src, start, src.length - start);
                    offset = src.length;
                } else {
                    // 调用了TokenHandler#handleToken方法
                    // 找到了一组标记符,对该标记符进行值替换,替换的值由
                    // TokenHandler#handleToken方法产生
                    builder.append(handler.handleToken(expression.toString()));
                    offset = end + closeToken.length();
                }
            }
            // 接着查找下一组标记符
            start = text.indexOf(openToken, offset);
        }
        if (offset < src.length) {
            builder.append(src, offset, src.length - offset);
        }
        // 返回处理后的字符串
        return builder.toString();
    }
}
// 整个过程可以理解为,根据给定的"开始标记符"和"结束标识符",遍历整个字符串,
// 寻找位于"开始标识符"和"结束标识符"之间的字符串("expression"),
// 调用TokenHandler#handleToken处理这些字符串("expression")
// 将处理后的字符串替换原来的("开始标识符""expression""结束标识符")
// 处理"expression"的细节由TokenHandler的具体的实现方法去做

以上节中数据为例,数据如下,分析GenericTokenParser#parse方法

  • text = “SELECT * FROM ${tableName} WHERE std_id = #{studentId}”(为了方便叙述去除了空格和换行符)
  • openToken = “${”
  • closeToken = “}”
  • token = DynamicCheckerTokenParser

按上述解析流程,由于text存在${tableName}匹配串,则处理位于"${“和”}“之间的"tableName”

调用DynamicCheckerTokenParser的handleToken方法

// content = "tableName"
public String handleToken(String content) {
    this.isDynamic = true;
    return null;
}

该方法将expression替换成了null(这不是重点),同时isDynamic被赋值为true,我们可以理解为只要sql中含有"${param}",该sql就属于动态sql,同时还有一个重要结论:含有"${param}"的sql片段将被解析成TextSqlNode!!
经过以上分析,我们也没找到任何关于#{}和${}的区别。别急,上面的铺垫只是让我们了解下mapper映射文件的解析流程和sql的解析的部分流程,以及GenericTokenParser的解析过程。通过判断是否为动态sql,决定我们创建DynamicSqlSource对象或者RawSqlSource对象。经分析我们的样例会被解析成动态sql,所以我们接着分析DynamicSqlSource

6. 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
    // parameterObject对象为参数集合,见图三截图
    public BoundSql getBoundSql(Object parameterObject) {
        // 创建DynamicContext对象,此处不展开说该对象,
        // 仅需知道该对象中有一个StringBuilder sqlBuilder变量
        // sqlBuilder用于sql拼装
        DynamicContext context = new DynamicContext(configuration, parameterObject);
        // 调用SqlNode#apply()方法,
        // 此时的rootSqlNode为XMLScriptBuilder#parseScriptNode解析出的MixedSqlNode
        // MixedSqlNode#apply循环遍历内部List<SqlNode>并调用相应的apply方法
        // 此时List<SqlNode>仅有一个元素TextSqlNode,分析过程见下文
        rootSqlNode.apply(context);
        SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
        Class<?> parameterType = 
            parameterObject == null ? Object.class : parameterObject.getClass();
        // 此处完成#{param}的替换,见下文源码分析SqlSourceBuilder#parse
        // context.getSql()返回的是DynamicContext中sqlBuilder.toString().trim()
        SqlSource sqlSource =  sqlSourceParser.parse(context.getSql(), 
                                  parameterType, context.getBindings());
        BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
        for (Map.Entry<String, Object> entry : context.getBindings().entrySet()) {
            boundSql.setAdditionalParameter(entry.getKey(), entry.getValue());
        }
        return boundSql;
    }
}

图三p3.jpg
图三是这个过程的debug信息,我们发现此时的${tableName}已被sm_student替换,替换的规则在TextSqlNode#apply中实现。

TextSqlNode#apply与${}

重点来了,下面就是${param}的替换过程!

public boolean apply(DynamicContext context) {
    // 上文已经把GenericTokenParser的功能讲清楚
    // 此时只需讲解BindingTokenParser#parse方法
    GenericTokenParser parser = createParser(new BindingTokenParser(context, injectionFilter));
    // 将解析完成的sql片段拼装到DynamicContext的sqlBuilder中
    // 在此处完成${param}的替换
    context.appendSql(parser.parse(text));
    return true;
}
private GenericTokenParser createParser(TokenHandler handler) {
    // 这里还是创建处理${param}类型的GenericTokenParser
    return new GenericTokenParser("${", "}", handler);
}
private static class BindingTokenParser implements TokenHandler {
    private DynamicContext context;
    private Pattern injectionFilter;
    public BindingTokenParser(DynamicContext context, Pattern injectionFilter) {
        this.context = context;
        this.injectionFilter = injectionFilter;
    }
    // 此处的content即${param}中的param,
    // 此方法操作就是从参数集合里获得key=param对应的value
    // 并返回该value
    // 按照样例:content是tableName,对应的value就是sm_student
    // 见图四截图
    public String handleToken(String content) {
        // 获得参数集合
        Object parameter = context.getBindings().get("_parameter");
        if (parameter == null) {
            context.getBindings().put("value", null);
        } else if (SimpleTypeRegistry.isSimpleType(parameter.getClass())) {
            context.getBindings().put("value", parameter);
        }
        Object value = OgnlCache.getValue(content, context.getBindings());
        // issue #274 return "" instead of "null"
        String srtValue = (value == null ? "" : String.valueOf(value)); 
        checkInjection(srtValue);
        // 返回value,用于替换原来的key
        return srtValue;
    }
}

进过此步骤,${tableName}被替换成了"sm_student",由于是字符串拼接,在sql语句中直接拼接的是 sm_student ,是不含引号的。
图四p5.jpg

SqlSourceBuilder#parse 与 #{}
// originalSql="SELECT * FROM sm_students WHERE std_id = #{studentId}"
public SqlSource parse(String originalSql, Class<?> parameterType, 
                       Map<String, Object> additionalParameters) {
    ParameterMappingTokenHandler handler = 
        new ParameterMappingTokenHandler(configuration, parameterType, additionalParameters);
    // 同样的这里还是使用GenericTokenParser的parse方法,通过前文我们已经知道
    // 此处功能是将#{param}替换成TokenHandler#handleToken
    // 我们只需看一下此处的ParameterMappingTokenHandler#handleToken
    GenericTokenParser parser = new GenericTokenParser("#{", "}", handler);
    String sql = parser.parse(originalSql);
    return new StaticSqlSource(configuration, sql, handler.getParameterMappings());
}
// ParameterMappingTokenHandler是SqlSourceBuilder内部类
private static class ParameterMappingTokenHandler extends BaseBuilder implements TokenHandler {
    ...省略
    public String handleToken(String content) {
        parameterMappings.add(buildParameterMapping(content));
        // 到此就明白了了吧,#{param}直接被替换成了"?"
        return "?";
    }
    ...省略
}

到此#{}和${}替换原理作者已经从源码中做了解释。

预防sql注入

关于两者在预防sql注入的讨论:

  • #{param}不仅仅涉及参数替换,还涉及参数类型的处理,这是${}不能代替的,也就是说使用${}来替换#{}本身就不符合mybatis的使用原则,所以两者并没有安全性比较的意义!
  • #{param}只能用于statementType=“PREPARED"情况,因为#{param}在mybatis内部肯定会被替换成”?"的,这就要求必须使用PreparedStatement来处理,这是mybatis内部原理实现的,并不是很多博文所说的#{param}会加上"引号"云云…如果#{param}代表的是数字,mybatis断然不会给该数字加"引号"的。所以说#{}能有效预防sql注入是因为底层使用了PreparedStatement,而不是其他任何原因。
结语

作者通过源码分析了#{}和${}的处理过程,让读者从mybatis设计理念上理解两者的区别,同时纠正一些不准确的表达,让读者从真正意义上了解两者,而不是断章取义的只知道一个结果,不清楚其中过程。本文使用的sql相对简单,所以sql的生成过程也是比较简单,其实mybatis的sql生成所涉及的SqlNode解析处理是特别巧妙的。关注作者,作者将会在后续文章中讲解SqlNode相关知识。

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值