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 3ff7 ); } 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信息
- 通过源码分析MyBatis的缓存/Mybatis解析动态sql原理分析
- MyBatis源码(五)之动态Sql解析运行阶段参数处理
- Mybatis解析动态sql原理分析
- Mybatis3源码分析(15)-Sql解析执行-Statement初始化和参数设置
- Mybatis3源码分析(17)-Sql解析执行-缓存的实现
- MyBatis-3.4.2-源码分析18:XML解析之RoleMapper userMapper = sqlSession.getMapper(RoleMapper.class)
- Mybatis3源码分析(11)-Sql解析执行-BoundSql的加载-1
- MyBatis-3.4.2-源码分析16:XML解析之SqlSessionFactory|SqlSession
- MyBatis-3.4.2-源码分析14:XML解析之sqlElement(context.evalNodes("/mapper/sql"))
- Mybatis 动态SQL之<trim>,<where>,<set>源码解析
- Mybatis解析动态sql原理分析
- Mybatis3源码分析(16)-Sql解析执行-结果集映射(ResultSetHandler)
- Mybatis源码分析 之 sql解析
- Mybatis3源码分析(13)-Sql解析执行-BoundSql的加载-2
- Mybatis3源码分析(14)-Sql解析执行-StatementHandler
- MyBatis源码(四)之mapper文件解析和动态Sql解析启动阶段
- Mybatis3源码分析(12)-Sql解析执行-MetaObject
- Mybatis解析动态sql原理分析
- Mybatis解析动态sql原理分析
- Mybatis实现【4】-查询解析(一次SQL查询的源码分析)