mybatis源码分析—sql动态解析

一、相关类

DynamicContext:动态上下文,持有方法的参数对象,以及解析替换后的sql

XMLScriptBuilder:从XNode中解析并构建SqlNode,构建过程中会通过TextSqlNode#isDynamic()检查原始sql中是否含有${}判断是否为动态sql,有则是

XNode:其中的字符类型的body保存解析后的sql,用于构造SqlNode

SqlNode:sql节点,接口中唯一的方法定义了对DynamicContext的操作

StaticTextSqlNode:静态SqlNode,直接向DynamicContext中的sqlBuilder添加sql

TextSqlNode:动态SqlNode,isDynamic方法用于检测该SqlNode是否为动态sql,apply方法用于执行过程中动态替换${}

MixedSqlNode:持有List contents,成员可以是TextSqlNode或StaticTextSqlNode

BoundSql:保存运行过程中的一些查询信息,如动态sql,参数映射等

SqlSource:接口,唯一的方法传入一个参数对象,返回BoundSql

StaticSqlSource:主要用于构建BoundSql,是RawSqlSource的实际持有SqlSource,在DynamicSqlSource中是在运行过程中会动态生成StaticSqlSource

RawSqlSource:原始SqlSource,在初始化RawSqlSource过程中通过SqlSourceBuilder将sql中的#{}替换为"?",同时创建一个StaticSqlSource

DynamicSqlSource:动态SqlSource,与RawSqlSource不同的时,DynamicSqlSource不会在初始化过程中替换变量(不论是${}还是#{}),而是会在运行时先通过TextSqlNode替换${},再通过SqlSourceBuilder替换#{}

SqlSourceBuilder:StaticSqlSource构建器,用于将sql中的变量#{}生成"?"(注意不会替换sql中的${})

TokenHandler:token处理类,定义了如何处理token

VariableTokenHandler:变量处理类,作用是将token用${}包装后返回,如:token → ${token}

BindingTokenParser:绑定token分析器,用于执行前将sql中的${}变量替换为参数中的值,参数值保存在DynamicContext中;注意:由于这一步中是直接用参数中的变量替换sql中的${},这会导致sql注入

DynamicCheckerTokenParser:动态token分析器,唯一的作用是判断sql是否为动态sql,并不进行解析替换

GenericTokenParser:一般token分析器,有三个属性:openToken表示token开始标识,closeToken表示标识的结束,tokenHandler表示token处理器,主要方法parse根据token标识及tokenHandler,将传入的sql解析为动态sql后返回,具体如何解析依赖于tokenHandler,如果原始sql中不包含token则不需要解析。

PropertyParser:属性解析器,具体作用见2.1说明

二、动态解析过程

 mybatis的sql动态解析是指通过固定的标签(if、choose、when、otherwise、trim、where、set、foreach、bind等),从mapper.xml文件中拼接sql语句的过程。本文不打算讨论每个标签的具体用法及拼接过程,只从参数替换角度分析动态解析是如何实现的。

 解析sql是从XMLMapperBuilder的parse方法开始的。

 XMLMapperBuilder.java

public void parse() {
  if (!configuration.isResourceLoaded(resource)) {
    // 解析mapper.xml文件的mapper结点
    configurationElement(parser.evalNode("/mapper"));
    configuration.addLoadedResource(resource);
    bindMapperForNamespace();
  }
 
  parsePendingResultMaps();
  parsePendingCacheRefs();
  parsePendingStatements();
}
 
private void configurationElement(XNode context) {
  try {
    String namespace = context.getStringAttribute("namespace");
    if (namespace == null || namespace.equals("")) {
      throw new BuilderException("Mapper's namespace cannot be empty");
    }
    builderAssistant.setCurrentNamespace(namespace);
    cacheRefElement(context.evalNode("cache-ref"));
    cacheElement(context.evalNode("cache"));
    parameterMapElement(context.evalNodes("/mapper/parameterMap"));
    resultMapElements(context.evalNodes("/mapper/resultMap"));
    sqlElement(context.evalNodes("/mapper/sql"));
 
    // 这里是解析各sql语句的入口
    buildStatementFromContext(context.evalNodes("select|insert|update|delete"));
  } catch (Exception e) {
    throw new BuilderException("Error parsing Mapper XML. The XML location is '" + resource + "'. Cause: " + e, e);
  }
}
 
private void buildStatementFromContext(List<XNode> list) {
  if (configuration.getDatabaseId() != null) {
    buildStatementFromContext(list, configuration.getDatabaseId());
  }
  buildStatementFromContext(list, null);
}
 
private void buildStatementFromContext(List<XNode> list, String requiredDatabaseId) {
  for (XNode context : list) {
    final XMLStatementBuilder statementParser = new XMLStatementBuilder(configuration, builderAssistant, context, requiredDatabaseId);
    try {
      // 调用XMLStatementBuilder的parseStatementNode方法,其内部获得了一个SqlSource
      statementParser.parseStatementNode();
    } catch (IncompleteElementException e) {
      configuration.addIncompleteStatement(statementParser);
    }
  }
}

 进入XMLStatementBuilder查看源码实现

public void parseStatementNode() {
  String id = context.getStringAttribute("id");
  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);
 
  ……
 
  // XMLLanguageDriver的createSqlSource实际上调用了XMLScriptBuilder.parseScriptNode()返回sqlSource对象
  // sqlSource的getBoundSql方法返回一个BoundSql对象,BoundSql对象中包含有sql值
  // Parse the SQL (pre: <selectKey> and <include> were parsed and removed)
  SqlSource sqlSource = langDriver.createSqlSource(configuration, context, parameterTypeClass);
 
  ……
 
  // 内部创建一个MappedStatement并注册到Configuration的Map<String, MappedStatement> mappedStatements中
  builderAssistant.addMappedStatement(id, sqlSource, statementType, sqlCommandType,
      fetchSize, timeout, parameterMap, parameterTypeClass, resultMap, resultTypeClass,
      resultSetTypeEnum, flushCache, useCache, resultOrdered,
      keyGenerator, keyProperty, keyColumn, databaseId, langDriver, resultSets);
}

2.1、SqlSource的生成

 下面分析下XMLScriptBuilder#parseScriptNode方法里发生了什么。

public SqlSource parseScriptNode() {
  // 解析获得一个混合SqlNode,用于构造SqlSource
  MixedSqlNode rootSqlNode = parseDynamicTags(context);
  SqlSource sqlSource = null;
  // 如果解析结果为动态类型,则创建DynamicSqlSource,否则创建RawSqlSource;实现在L25
  if (isDynamic) {
    sqlSource = new DynamicSqlSource(configuration, rootSqlNode);
  } else {
    sqlSource = new RawSqlSource(configuration, rootSqlNode, parameterType);
  }
  return sqlSource;
}
 
protected MixedSqlNode parseDynamicTags(XNode node) {
  List<SqlNode> contents = new ArrayList<>();
  NodeList children = node.getNode().getChildNodes();
  for (int i = 0; i < children.getLength(); i++) {
    // 这里的构造函数中会解析获得data
    XNode child = node.newXNode(children.item(i));
    if (child.getNode().getNodeType() == Node.CDATA_SECTION_NODE || child.getNode().getNodeType() == Node.TEXT_NODE) {
      String data = child.getStringBody("");
      TextSqlNode textSqlNode = new TextSqlNode(data);
 
      // 这里判断是否为动态sql,其实就是通过GenericTokenParser的parse方法判断TextSqlNode的构造参数data(L-21)中是否包含${},所以关键是看data是如何产生的,具体见XNode的构造函数内部实现
      if (textSqlNode.isDynamic()) {
        contents.add(textSqlNode);
        // 标记为动态类型,用于判别生成sqlSource的类型
        isDynamic = true;
      } else {
        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;
    }
  }
 
  // 不论是动态还是静态,最后都封装成混合SqlNode用于创建SqlSource
  return new MixedSqlNode(contents);
}

 XNode的构造函数中会调用parseBody(Node node)方法获得body,即上面提到的data

private String parseBody(Node node) {
  String data = getBodyData(node);
  if (data == null) {
    ……
  }
  return data;
}
 
private String getBodyData(Node child) {
  if (child.getNodeType() == Node.CDATA_SECTION_NODE || child.getNodeType() == Node.TEXT_NODE) {
    String data = ((CharacterData) child).getData();
    // 通过PropertyParser.parse解析data
    data = PropertyParser.parse(data, variables);
    return data;
  }
  return null;
}

 跟进PropertyParser中发现,其还是借助于GenericTokenParser(同判断TextSqlNode是否为动态类型)来判断和解析生成sql:如果原始sql中包含${},则从传入的Properties中替换(Properties不为null)${}中的内容content,或重新用${}包装后返回(见VariableTokenHandler的handleToken实现)。

 PropertyParser#parse

public static String parse(String string, Properties variables) {
  VariableTokenHandler handler = new VariableTokenHandler(variables);
  GenericTokenParser parser = new GenericTokenParser("${", "}", handler);
  return parser.parse(string);
}

 上面涉及到两个类:GenericTokenParser与TokenHandler(VariableTokenHandler实现了该接口),它们经常一起出现并配合使用,其中GenericTokenParser的作用主要是检测占位符(${}或#{},构造函数中传入,同时传入的还有TokenHandler对象),并按照TokenHandler的策略替换占位符中的内容content;

 TokenHandler是一个接口,content被替换的内容就是由它来确定,可以是具体的参数值(BindingTokenParser),可以是判断操作(DynamicCheckerTokenParser),或者直接是用固定字符替换(ParameterMappingTokenHandler)等等,总之,该接口约定了检测到占位符之后的处理和替换策略。

 上面说明了通过isDynamic来确定生成的SqlSource是DynamicSqlSource还是RawSqlSource,那么具体的SqlSource创建过程中又执行了些哪些操作呢,让我们分别看一下。

2.2、DynamicSqlSource的实现

public class DynamicSqlSource implements SqlSource {
 
  private final Configuration configuration;
  private final SqlNode rootSqlNode;    // 内部的实际类型为TextSqlNode
 
  public DynamicSqlSource(Configuration configuration, SqlNode rootSqlNode) {
    this.configuration = configuration;
    this.rootSqlNode = rootSqlNode;
  }
 
  @Override
  public BoundSql getBoundSql(Object parameterObject) {
    DynamicContext context = new DynamicContext(configuration, parameterObject);
    rootSqlNode.apply(context);
    SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
    Class<?> parameterType = parameterObject == null ? Object.class : parameterObject.getClass();
     
    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;
  }
}

 从上面可以看到,在DynamicSqlSource的构造函数中没有特别的操作,唯一的方法getBoundSql的返回对象是通过动态生成的,在该方法中rootSqlNode的实际类型为TextSqlNode,TextSqlNode的apply方法将会用传入的实际参数对象中的属性值对${}进行直接替换,并且不会进行任何检查!

 换言之,假设原始sql为SELECT * FROM table WHERE id = ${param},如果用户传入的param=“1 OR 1=1”,经过这一步替换后的sql=“SELECT * FROM table WHERE id = 1 OR 1=1”,这将导致灾难性的后果,这就是导致sql注入攻击的直接原因。

下面附上TextSqlNode的实现。

public class TextSqlNode implements SqlNode {
  private final String text;
  private final Pattern injectionFilter;
 
  public TextSqlNode(String text) {
    this(text, null);
  }
 
  public TextSqlNode(String text, Pattern injectionFilter) {
    this.text = text;
    this.injectionFilter = injectionFilter;
  }
 
  // 这里为XMLScriptBuilder.parseScriptNode()判断sql是否为动态类型的实现
  public boolean isDynamic() {
    DynamicCheckerTokenParser checker = new DynamicCheckerTokenParser();
    // 只判断是否包含${}
    GenericTokenParser parser = createParser(checker);
    parser.parse(text);
    return checker.isDynamic();
  }
 
  @Override
  public boolean apply(DynamicContext context) {
    GenericTokenParser parser = createParser(new BindingTokenParser(context, injectionFilter));
    context.appendSql(parser.parse(text));
    return true;
  }
 
  private GenericTokenParser createParser(TokenHandler handler) {
    // ###### 只检测是否包含${}并做相应处理,这也是为什么使用${}不安全而#{}是安全的原因 ######
    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;
    }
 
    @Override
    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());
      String srtValue = value == null ? "" : String.valueOf(value); // issue #274 return "" instead of "null"
      checkInjection(srtValue);
      return srtValue;
    }
 
    private void checkInjection(String value) {
      if (injectionFilter != null && !injectionFilter.matcher(value).matches()) {
        throw new ScriptingException("Invalid input. Please conform to regex" + injectionFilter.pattern());
      }
    }
  }
 
  // 用于sql的动态检测
  private static class DynamicCheckerTokenParser implements TokenHandler {
    private boolean isDynamic;
 
    public DynamicCheckerTokenParser() {
      // Prevent Synthetic Access
    }
 
    public boolean isDynamic() {
      return isDynamic;
    }
 
    // 当GenericTokenParser检测到sql中包含有${}时,DynamicCheckerTokenParser只是简单的记录为动态
    @Override
    public String handleToken(String content) {
      this.isDynamic = true;
      return null;
    }
  }
}

2.3、RawSqlSource的实现

public class RawSqlSource implements SqlSource {
  private final SqlSource sqlSource;
 
  public RawSqlSource(Configuration configuration, SqlNode rootSqlNode, Class<?> parameterType) {
    this(configuration, getSql(configuration, rootSqlNode), parameterType);
  }
 
  public RawSqlSource(Configuration configuration, String sql, Class<?> parameterType) {
    SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
    Class<?> clazz = parameterType == null ? Object.class : parameterType;
    // 在构造方法中就已经生成了SqlSource对象
    sqlSource = sqlSourceParser.parse(sql, clazz, new HashMap<>());
  }
 
  private static String getSql(Configuration configuration, SqlNode rootSqlNode) {
    DynamicContext context = new DynamicContext(configuration, null);
    // rootSqlNode内部的实际类型为StaticTextSqlNode,相比于TextSqlNode,StaticTextSqlNode的apply方法只是简单的将sql拼接到DynamicContext的sqlBuilder中
    rootSqlNode.apply(context);
    return context.getSql();
  }
 
  @Override
  public BoundSql getBoundSql(Object parameterObject) {
    return sqlSource.getBoundSql(parameterObject);
  }
}

 与DynamicSqlSource不同的是,RawSqlSource的主要逻辑在构造方法中就已经实现,包括sql的生成及SqlSource的创建。

 同时DynamicSqlSource和RawSqlSource也有相同的地方,那就是内部对象sqlSource的创建方式都是通过SqlSourceBuilder实现,并且创建过程中已经将原始sql中的占位符#{}及内容替换为"?"了。

三、核心调用链路

XMLLanguageDriver.createSqlSource
    XMLScriptBuilder.parseScriptNode
        XMLScriptBuilder.parseDynamicTags
            XNode child = node.newXNode
                body = XNode.parseBody
                    XNode.getBodyData
                        PropertyParser.parse
                            GenericTokenParser.parse(rawSql)
                                if (rawSql.match("${}"))
                                    return rawSql.replace(${}, ${})
                                return sql
                            return sql
                        return sql
                    body = sql
                return child
            data = child.body
            TextSqlNode textSqlNode = new TextSqlNode(child.data)
            isDynamic = textSqlNode.isDynamic
            List<SqlNode> contents = new ArrayList<>()
            contents.add(isDynamic ? TextSqlNode(data) : StaticTextSqlNode(data))
            return MixedSqlNode(contents)
        return isDynamic ? DynamicSqlSource or RawSqlSource
    return SqlSource

 下面简单梳理下解析sql(生成SqlSource)的关键步骤:

 1、XMLStatementBuilder的parseStatementNode方法负责对一个可执行语句节点(select|insert|update|delete)进行解析,它会生成一个SqlSource(第2步)并通过builderAssistant向configuration注册一个MappedStatement

 2、XMLScriptBuilder的parseScriptNode和parseDynamicTags方法解析当前XNode(第3步)得到一个MixedSqlNode,并根据MixedSqlNode是否为动态类型,创建一个RawSqlSource(第5步)或DynamicSqlSource对象(第6步)

 3、XNode的构造函数借助PropertyParser对传入的结点数据进行解析(第4步),得到解析替换后的sql(data),通过判断data是否包含${}能够获知该结点是否为动态结点

 4、PropertyParser的parse方法判断传入的参数sql是否包含占位符${},(特定条件下)用properties中的对应值进行替换

 5、创建RawSqlSource时,会从MixedSqlNode的SqlNode列表中取出text拼接到DynamicContext的sqlBuilder中组成originalSql,originalSql经替换#{}后得到sql,之后创建一个StaticSqlSource并赋给RawSqlSource的内部对象SqlSource

 6、创建DynamicSqlSource比较简单,逻辑主要在获取BoundSql方法上,该过程部分与创建RawSqlSource类似,都有从MixedSqlNode的SqlNode列表中执行apply方法组成originalSql,及替换originalSql中的#{}并创建StaticSqlSource这两步。不同点在于,DynamicSqlSource的rootSqlNode#apply方法中会对${}进行替换

四、示例

4.1、测试用例

 新建user表并插入2条数据

 创建及初始化user表

CREATE TABLE `user` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `name` varchar(20) DEFAULT '' COMMENT '姓名',
  `ic_no` char(20) DEFAULT '' COMMENT '学号',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间',
  `is_delete` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否删除',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
 
INSERT INTO `user` VALUES ('1', '1', '1', now(), now(), '0');
INSERT INTO `user` VALUES ('2', '2', '2', now(), now(), '0');

 UserMapper.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.apple.learning.dao.UserDao">
    <resultMap id="resultMap" type="user">
        <id property="id" column="id"/>
        <result property="name" column="name"/>
        <result property="icNo" column="ic_no"/>
        <result property="createTime" column="create_time"/>
        <result property="updateTime" column="update_time"/>
        <result property="isDelete" column="is_delete"/>
    </resultMap>
 
    <select id="getById" resultMap="resultMap">
        SELECT `id`, `name`, `ic_no`, `create_time`, `update_time`, `is_delete` FROM user WHERE id = #{id} LIMIT 1
    </select>
 
    <!-- 可被注入攻击的sql -->
    <select id="queryByIcNo" resultMap="resultMap">
        SELECT `id`, `name`, `ic_no`, `create_time`, `update_time`, `is_delete` FROM user WHERE ic_no = ${icNo}
    </select>
</mapper>

 测试用例

@Slf4j
public class UserServiceImplTest extends BaseTest {
    @Autowired
    private UserService userService;
 
    @Test
    public void getTest() {
        log.info("getById result: " + userService.getById(1L).toString());
        log.info("queryByIcNo(1) result: " + userService.queryByIcNo("1").toString());
        log.info("queryByIcNo(1 or 1=1) result: " + userService.queryByIcNo("1 or 1=1").toString());
    }
}

4.2、结果

2019-04-18 19:19:35 INFO [com.**.service.impl.UserServiceImplTest] getById result: User(name=1, icNo=1)                                         // 不能被注入攻击的sql返回了正常的查询结果
2019-04-18 19:19:35 INFO [com.**.service.impl.UserServiceImplTest] queryByIcNo(1) result: [User(name=1, icNo=1)]                                // 没被注入攻击情况下返回了满足查询条件的user
2019-04-18 19:19:35 INFO [com.**.service.impl.UserServiceImplTest] queryByIcNo(1 or 1=1) result: [User(name=1, icNo=1), User(name=2, icNo=2)]   // 被注入攻击后返回了所有的user信息
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值