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注入。

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