MyBatis预编译机制详解

MyBatis预编译机制详解

一. "#{}“和”${}"的区别

  1. "#{}"是将传入的值按照字符串的形式进行处理,如下面这条语句:

    select user_id,user_name from t_user where user_id = #{user_id}
    

    MyBaits会首先对其进行预编译,将#{user_ids}替换成?占位符,然后在执行时替换成实际传入的user_id值,**并在两边加上单引号,以字符串方式处理。**下面是MyBatis执行日志:

    10:27:20.247 [main] DEBUG william.mybatis.quickstart.mapper.UserMapper.selectById - ==>  Preparing: select id, user_name from t_user where id = ? 
    10:27:20.285 [main] DEBUG william.mybatis.quickstart.mapper.UserMapper.selectById - ==> Parameters: 1(Long)
    
    

    因为"#{}"会在传入的值两端加上单引号,所以可以很大程度上防止SQL注入。有关SQL注入的知识会在后文进行说明。因此在大多数情况下,建议使用"#{}"。

  2. "${}"是做简单的字符串替换,即将传入的值直接拼接到SQL语句中,且不会自动加单引号。将上面的SQL语句改为:

    select user_id,user_name from t_user where user_id = ${user_id}
    

    再观察MyBatis的执行日志:

    10:41:32.242 [main] DEBUG william.mybatis.quickstart.mapper.UserMapper.selectById - ==>  Preparing: select id, user_name, real_name, sex, mobile, email, note, position_id from t_user where id = 1 
    10:41:32.288 [main] DEBUG william.mybatis.quickstart.mapper.UserMapper.selectById - ==> Parameters: 
    

    可以看到,参数是直接替换的,且没有单引号处理,这样就有SQL注入的风险。

    但是在一些特殊情况下,使用${}是更适合的方式,如表名、orderby等。见下面这个例子:

    select user_id,user_name from ${table_name} where user_id = ${user_id}
    

    这里如果想要动态处理表名,就只能使用"${}",因为如果使用"#{}",就会在表名字段两边加上单引号,变成下面这样:

    select user_id,user_name from 't_user' where user_id = ${user_id}
    

    这样SQL语句就会报错。

二. MyBatis预编译源码分析

MyBatis对SQL语句解析的处理在XMLStatementBuilder类中,见源码:

/**
   * 解析mapper中的SQL语句
   */
  public void parseStatementNode() {
    //SQL语句id,对应着Mapper接口的方法
    String id = context.getStringAttribute("id");

    //校验databaseId是否匹配
    String databaseId = context.getStringAttribute("databaseId");
    if (!databaseIdMatchesCurrent(id, databaseId, this.requiredDatabaseId)) {
      return;
    }

    //SQL标签属性解析
    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");

    //Statement类型,默认PreparedStatement
    StatementType statementType = StatementType.valueOf(context.getStringAttribute("statementType", StatementType.PREPARED.toString()));
    ResultSetType resultSetTypeEnum = resolveResultSetType(resultSetType);

    String nodeName = context.getNode().getNodeName();
    //SQL命令类型:增删改查
    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 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;
    }

    //解析完毕,最后通过MapperBuilderAssistant创建MappedStatement对象,统一保存到Configuration的mappedStatements属性中
    builderAssistant.addMappedStatement(id, sqlSource, statementType, sqlCommandType,
        fetchSize, timeout, parameterMap, parameterTypeClass, resultMap, resultTypeClass,
        resultSetTypeEnum, flushCache, useCache, resultOrdered, 
        keyGenerator, keyProperty, keyColumn, databaseId, langDriver, resultSets);
  }

前面是对SQL标签的一些处理,如id、缓存、结果集映射等。我们这次主要分析预编译机制,因此重点关注 SqlSource sqlSource = langDriver.createSqlSource(configuration, context, parameterTypeClass)这个方法。这该方法会通过LanguageDriver对SQL语句进行解析,生成一个SqlSource。SqlSource封装了映射文件或者注解中定义的SQL语句,它不能直接交给数据库执行,因为里面可能包含动态SQL或者占位符等元素。而MyBatis在实际执行SQL语句时,会调用SqlSource的getBoundSql()方法获取一个BoundSql对象,BoundSql是将SqlSource中的动态内容经过处理后,返回的实际可执行的SQL语句,其中包含?占位符List封装的有序的参数映射关系,此外还有一些额外信息标识每个参数的属性名称等。

LanguageDriver的默认实现类是XMLLanguageDriver,我们进入到这个方法里面看下:

//创建SqlSource
@Override
public SqlSource createSqlSource(Configuration configuration, XNode script, Class<?> parameterType) {
  //创建XMLScriptBuilder对象
  XMLScriptBuilder builder = new XMLScriptBuilder(configuration, script, parameterType);

  //通过XMLScriptBuilder解析SQL脚本
  return builder.parseScriptNode();
}

这里通过XMLScriptBuilder对象的parseScriptNode()方法进行SQL脚本的解析,继续跟进去:

/**
   * 解析SQL脚本
   */
public SqlSource parseScriptNode() {
  //解析动态标签,包括动态SQL和${}。执行后动态SQL和${}已经被解析完毕。
  //此时SQL语句中的#{}还没有处理,#{}会在SQL执行时动态解析
  MixedSqlNode rootSqlNode = parseDynamicTags(context);

  //如果是dynamic的,则创建DynamicSqlSource,否则创建RawSqlSource
  SqlSource sqlSource = null;
  if (isDynamic) {
    sqlSource = new DynamicSqlSource(configuration, rootSqlNode);
  } else {
    sqlSource = new RawSqlSource(configuration, rootSqlNode, parameterType);
  }
  return sqlSource;
}

parseScriptNode的功能就是判断该SQL节点是否是动态的,然后根据是否动态返回DynamicSqlSource或

RawSqlSource。是否为动态SQL的判断在parseDynamicTags()方法中:

protected MixedSqlNode parseDynamicTags(XNode node) {
  List<SqlNode> contents = new ArrayList<>();
  NodeList children = node.getNode().getChildNodes();
  for (int i = 0; i < children.getLength(); i++) {
    XNode child = node.newXNode(children.item(i));

    //处理文本节点(SQL语句)
    if (child.getNode().getNodeType() == Node.CDATA_SECTION_NODE || child.getNode().getNodeType() == Node.TEXT_NODE) {
      //把SQL封装到TextSqlNode
      String data = child.getStringBody("");
      TextSqlNode textSqlNode = new TextSqlNode(data);

      //如果包含${},则是dynamic的
      if (textSqlNode.isDynamic()) {
        contents.add(textSqlNode);
        isDynamic = true;
      } else {
        //除了${}外,其他的SQL都是静态的
        contents.add(new StaticTextSqlNode(data));
      }
    } 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;
    }
  }
  return new MixedSqlNode(contents);
}

在这个方法中,会对SQL语句进行动态标签的解析。以<select>标签为例,会获取标签中的文本节点(即具体的SQL语句),将其封装成TextSqlNode,然后调用isDynamic()方法判断是否为动态标签。那么我们来看看这个方法:

public boolean isDynamic() {
  DynamicCheckerTokenParser checker = new DynamicCheckerTokenParser();
  GenericTokenParser parser = createParser(checker);
  parser.parse(text);
  return checker.isDynamic();
}

这里涉及一些底层的文本解析,这里就不具体说明了,我们仅需看下createParser()这个方法:

private GenericTokenParser createParser(TokenHandler handler) {
  return new GenericTokenParser("${", "}", handler);
}

这样一来就明白了,该方法会创建一个以"${}“为token的解析器GenericTokenParser,对指定的SQL语句进行解析,如果解析成功,说明语句中包含”${}",则将其标记为动态SQL标签。

如果是动态标签,创建的SqlSource就是DynamicSqlSource,其获取的BoundSql就是直接进行字符串的替换。对于非动态标签,则创建RawSqlSource,对应?占位符的SQL语句,如前文所述。

三. SQL注入问题

  1. 问题演示

    前面说到了使用#{}可以有效防止SQL注入。那么SQL注入到底是什么呢?

    考虑下面这个常见的场景:用户登录。根据前端传过来的用户名和密码,去数据库进行校验,如果查到是有效用户,则通知前端登录成功。这个场景相信大家都经历过。在数据库会执行这样一段SQL:

     select * from users where username='admin' and password=md5('admin')
    

    如果前端传如正确的用户名和密码,可以登录成功,这样在正常情况下没有问题。

    那么如果有人恶意攻击,在用户名框输入了’or 1=1#,而密码框随意输入,这个SQL语句就变为:

     select * from users where username='' or 1=1#' and password=md5('')
    

    “#”在mysql中是注释符,这样"#"后面的内容将被mysql视为注释内容,就不会去执行了。换句话说,上面的SQL语句等价于:

    select * from users where username='' or 1=1
    

    由于1=1恒成立,因此SQL语句可以被进一步简化为:

     select * from users
    

    这样一来,这段SQL语句可以执行成功,用户就可以恶意登录了。这样就实现了简单的SQL注入。

  2. 通过MyBatis预编译防SQL注入

    如前文所述,在MyBatis中,采用"${}“是简单的字符串替换,肯定无法应对SQL注入。那么”#{}"是怎样解决SQL注入的呢?

    将上面的查询语句在MyBatis中实现为:

     select * from users where username=#{username} and password=md5(#{password})
    

    这样一来,当用户再次输入’or 1=1#,MyBatis执行SQL语句时会将其替换成:

     select * from users where username=''or 1=1#' and password=md5('')
    

    由于在两端加了双引号,因此输入的内容就是一个普通字符串,其中的#注释和or 1=1都不会生效,这样就无法登陆成功了,从而有效防止了SQL注入。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章