前言
在面试中我们经常会被到MyBatis中 #{} 占位符与
${}
占位符的区别。大多数的小伙伴都可以脱口而出#{} 会对值进行转义,防止SQL注入。而
${}
则会原样输出传入值,不会对传入值做任何处理。本文将通过源码层面分析为啥#{} 可以防止SQL注入。
#{}占位符的解析与参数的设置过程梳理
-
假如我们有如下SQL语句。
1SELECT * FROM author WHERE name = #{name} AND age = #{age}
-
这个SQL语句中包含两个#{}
占位符,在运行时这两个占位符会被解析成两个ParameterMapping 对象。如下:
1ParameterMapping{property='name', mode=IN, javaType=class java.lang.String, jdbcType=null, ...}
和
1ParameterMapping{property='age', mode=IN, javaType=class java.lang.Integer, jdbcType=null, ...}
#{}
占位符解析完毕后,得到的SQL如下:
1SELECT * FROM Author WHERE name = ? AND age = ?
这里假设下面这个方法与上面的 SQL 对应:
1Author findByNameAndAge(@Param("name") String name, @Param("age") Integer age)
2
该方法的参数列表会被ParamNameResolver解析成一个map
如下:
1{2 0: "name",3 1: "age"4}
假设该方法在运行时有如下的调用:
1findByNameAndAge("张三", 30)
此时,需要再次借助 ParamNameResolver 力量。这次我们将参数名和运行时的参数值绑定起来,得到如下的映射关系。
1{
2 "name": "张三",
3 "age": 30,
4 "param1": "张三",
5 "param2": 30
6}
下一步,我们要将运行时参数设置到 SQL 中。由于原 SQL 经过解析后,占位符信息已经被擦除掉了,我们无法直接将运行时参数 SQL 中。不过好在,这些占位符信息被记录在了 ParameterMapping 中了,MyBatis 会将 ParameterMapping 会按照 #{} 的解析顺序存入到 List 中。这样我们通过 ParameterMapping 在列表中的位置确定它与 SQL 中的哪个 ? 占位符相关联。同时通过 ParameterMapping 中的 property 字段,我们到“参数名与参数值”映射表中查找具体的参数值。这样,我们就可以将参数值准确的设置到 SQL 中了,此时 SQL 如下:
1SELECT * FROM Author WHERE name = "张三" AND age = 30
解析${}占位符
在MyBatis中,当SQL配置中包含
${}
或者
<if>
,
<set>
等标签时,会被认定为是动态SQL,使用 DynamicSqlSource 存储 SQL 片段,而RawSqlSource 是对原始的SQL 进行解析,而StaticSqlSource 是对静态SQL进行解析。这里我们重点介绍下DynamicSqlSource。话不多说,直接看源码。
public BoundSql getBoundSql(Object parameterObject) {
2 //生成一个动态上下文
3 DynamicContext context = new DynamicContext(configuration, parameterObject);
4 //这里SqlNode.apply只是将${}这种参数替换掉,并没有替换#{}这种参数
5 rootSqlNode.apply(context);
6 //调用SqlSourceBuilder
7 SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
8 Class<?> parameterType = parameterObject == null ? Object.class : parameterObject.getClass();
9 //SqlSourceBuilder.parse,注意这里返回的是StaticSqlSource,解析完了就把那些参数都替换成?了,也就是最基本的JDBC的SQL写法
10 SqlSource sqlSource = sqlSourceParser.parse(context.getSql(), parameterType, context.getBindings());
11 //看似是又去递归调用SqlSource.getBoundSql,其实因为是StaticSqlSource,所以没问题,不是递归调用
12 BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
13// 将DynamicContext的ContextMap中的内容拷贝到BoundSql中
14 for (Map.Entry<String, Object> entry : context.getBindings().entrySet()) {
15 boundSql.setAdditionalParameter(entry.getKey(), entry.getValue());
16 }
17 return boundSql;
18 }
TextSqlNode用于存储带有
${}
占位符的文本
1//***TextSqlNode
2 public boolean apply(DynamicContext context) {
3// 创建${} 占位符解析器
4 GenericTokenParser parser = createParser(new BindingTokenParser(context, injectionFilter));
5// 解析${} 占位符,并将解析结果添加到DynamicContext中
6 context.appendSql(parser.parse(text));
7 return true;
8 }
9
10 private GenericTokenParser createParser(TokenHandler handler) {
11// 创建占位符解析器,GenericTokenParser 是一个通用解析器,并非只能解析${}
12 return new GenericTokenParser("${", "}", handler);
13 }
例如;SQL语句
1SELECT * FROM article WHERE author = '${author}'
加入我们传入值为 张三 ,则替换之后的结果是
1SELECT * FROM article WHERE author = '张三'
当用这些恶意的参数替换 ${author} 时就会出现灾难性问题 – SQL 注入。比如我们构建这样一个参数 author = 李四'; DELETE FROM article;#,然后我们把这个参数传给 TextSqlNode 进行解析。得到的结果如下:
1SELECT * FROM article WHERE author = '张三'; DELETE FROM article;#'
2
看到没,由于传入的参数没有经过转义,最终导致了一条 SQL 被恶意参数拼接成了两条 SQL。更要命的是,第二天 SQL 会把 article 表的数据清空。
解析`#{}`占位符
经过前面的解析,我们已经能够从DynamicContext 中获取到完整的SQL语句了。但是这并不意味着解析工作就结束了。我们还有
#{}
占位符没有处理。
#{}
占位符不同于
${}
占位符的处理方式。MyBatis 并不会直接将
#{}
占位符替换成相应的参数值。
#{}
的解析过程封装在SqlSourceBuilder 的parse方法中。解析后的结果交给StaticSqlSource处理。话不多说,来看看源码吧。
1//*SqlSourceBuilder
2 public SqlSource parse(String originalSql, Class<?> parameterType, Map<String, Object> additionalParameters) {
3// 创建#{} 占位符处理器
4 ParameterMappingTokenHandler handler = new ParameterMappingTokenHandler(configuration, parameterType, additionalParameters);
5 //替换#{}中间的部分,如何替换,逻辑在ParameterMappingTokenHandler
6 GenericTokenParser parser = new GenericTokenParser("#{", "}", handler);
7// 解析#{}占位符,并返回解析结果
8 String sql = parser.parse(originalSql);
9 //封装解析结果到StaticSqlSource中,并返回
10 return new StaticSqlSource(configuration, sql, handler.getParameterMappings());
11 }
如上源码,该解析过程主要有四步,核心步骤就是解析
#{}
占位符,并返回结果。GenericTokenParser 类在前面已经解析过了,下面我们重点看看SqlSourceBuilder的内部类ParameterMappingTokenHandler。该类的核心方法是handleToken方法。该方法的主要作用是将
#{}
替换成
?
并返回。然后就是构建参数映射。ParameterMappingTokenHandler 该类同样实现了TokenHandler 接口,所以GenericTokenParser 类的parse方法可以调用到。
1//参数映射记号处理器,静态内部类
2 private static class ParameterMappingTokenHandler extends BaseBuilder implements TokenHandler {
3
4 private List<ParameterMapping> parameterMappings = new ArrayList<ParameterMapping>();
5 private Class<?> parameterType;
6 private MetaObject metaParameters;
7
8 public ParameterMappingTokenHandler(Configuration configuration, Class<?> parameterType, Map<String, Object> additionalParameters) {
9 super(configuration);
10 this.parameterType = parameterType;
11 this.metaParameters = configuration.newMetaObject(additionalParameters);
12 }
13
14 public List<ParameterMapping> getParameterMappings() {
15 return parameterMappings;
16 }
17
18 @Override
19 public String handleToken(String content) {
20 //获取context的对应的ParameterMapping
21 parameterMappings.add(buildParameterMapping(content));
22 //如何替换很简单,永远是一个问号,但是参数的信息要记录在parameterMappings里面供后续使用
23 return "?";
24 }
25
26 //构建参数映射
27 private ParameterMapping buildParameterMapping(String content) {
28 //#{favouriteSection,jdbcType=VARCHAR}
29 //先解析参数映射,就是转化成一个hashmap
30 /*
31 * parseParameterMapping 内部依赖 ParameterExpression 对字符串进行解析,ParameterExpression 的
32 */
33 Map<String, String> propertiesMap = parseParameterMapping(content);
34 String property = propertiesMap.get("property");
35 Class<?> propertyType;
36 // metaParameters 为 DynamicContext 成员变量 bindings 的元信息对象
37 if (metaParameters.hasGetter(property)) {
38 /*
39 * parameterType 是运行时参数的类型。如果用户传入的是单个参数,比如 Article 对象,此时
40 * parameterType 为 Article.class。如果用户传入的多个参数,比如 [id = 1, author = "coolblog"],
41 * MyBatis 会使用 ParamMap 封装这些参数,此时 parameterType 为 ParamMap.class。如果
42 * parameterType 有相应的 TypeHandler,这里则把 parameterType 设为 propertyType
43 */
44 propertyType = metaParameters.getGetterType(property);
45 } else if (typeHandlerRegistry.hasTypeHandler(parameterType)) {
46 propertyType = parameterType;
47 } else if (JdbcType.CURSOR.name().equals(propertiesMap.get("jdbcType"))) {
48 propertyType = java.sql.ResultSet.class;
49 } else if (property != null) {
50 MetaClass metaClass = MetaClass.forClass(parameterType);
51 if (metaClass.hasGetter(property)) {
52 propertyType = metaClass.getGetterType(property);
53 } else {
54 // 如果 property 为空,或 parameterType 是 Map 类型,则将 propertyType 设为 Object.class
55 propertyType = Object.class;
56 }
57 } else {
58 propertyType = Object.class;
59 }
60 // ----------------------------分割线---------------------------------
61 ParameterMapping.Builder builder = new ParameterMapping.Builder(configuration, property, propertyType);
62// 将propertyType赋值给javaType
63 Class<?> javaType = propertyType;
64 String typeHandlerAlias = null;
65// 遍历propertiesMap
66 for (Map.Entry<String, String> entry : propertiesMap.entrySet()) {
67 String name = entry.getKey();
68 String value = entry.getValue();
69 if ("javaType".equals(name)) {
70// 如果用户明确配置了javaType,则以用户的配置为准。
71 javaType = resolveClass(value);
72 builder.javaType(javaType);
73 } else {
74 throw new BuilderException("An invalid property '" + name + "' was found in mapping #{" + content + "}. Valid properties are " + parameterProperties);
75 }
76 }
77 //#{age,javaType=int,jdbcType=NUMERIC,typeHandler=MyTypeHandler}
78 if (typeHandlerAlias != null) {
79 builder.typeHandler(resolveTypeHandler(javaType, typeHandlerAlias));
80 }
81 return builder.build();
82 }
如上,buildParameterMapping方法,主要做了如下三件事
-
-
-
解析content,
-
解析propertyType,对应分割线上面的代码
-
构建ParameterMapping,对应分割下下面的代码。
最终的结果是将 #{xxx} 占位符中的内容解析成 Map。
例如:
-
{age,javaType=int,jdbcType=NUMERIC,typeHandler=MyTypeHandler}
上面占位符中的内容最终会被解析成如下的结果:
1 {
2 "property": "age",
3 "typeHandler": "MyTypeHandler",
4 "jdbcType": "NUMERIC",
5 "javaType": "int"
6 }
BoundSql的创建过程就此结束了。我们接着往下看。
参数设值
1//*DefaultParameterHandler
2public void setParameters(PreparedStatement ps) throws SQLException {
3 ErrorContext.instance().activity("setting parameters").object(mappedStatement.getParameterMap().getId());
4 /*
5 * 从BoundSql中获取ParameterMapping列表,每个ParameterMapping
6 * 与原始SQL中的#{xxx} 占位符一一对应
7 * */
8 List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
9 if (parameterMappings != null) {
10 //循环设参数
11 for (int i = 0; i < parameterMappings.size(); i++) {
12 ParameterMapping parameterMapping = parameterMappings.get(i);
13// 检测参数类型,排除掉mode为OUT类型的parameterMapping
14 if (parameterMapping.getMode() != ParameterMode.OUT) {
15 //如果不是OUT,才设进去
16 Object value;
17// 获取属性名
18 String propertyName = parameterMapping.getProperty();
19// 检测BoundSql的additionalParameter是否包含propertyName
20 if (boundSql.hasAdditionalParameter(propertyName)) { // issue #448 ask first for additional params
21 //若有额外的参数, 设为额外的参数
22 value = boundSql.getAdditionalParameter(propertyName);
23 } else if (parameterObject == null) {
24 //若参数为null,直接设null
25 value = null;
26 } else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
27 //若参数有相应的TypeHandler,直接设object
28 value = parameterObject;
29 } else {
30 //除此以外,MetaObject.getValue反射取得值设进去
31 MetaObject metaObject = configuration.newMetaObject(parameterObject);
32 value = metaObject.getValue(propertyName);
33 }
34// 之上,获取#{xxx}占位符属性所对应的运行时参数
35// -------------------分割线-----------------------
36// 之下,获取#{xxx}占位符属性对应的TypeHandler,并在最后通过TypeHandler将运行时参数值设置到
37// PreparedStatement中。
38 TypeHandler typeHandler = parameterMapping.getTypeHandler();
39 JdbcType jdbcType = parameterMapping.getJdbcType();
40 if (value == null && jdbcType == null) {
41 //不同类型的set方法不同,所以委派给子类的setParameter方法
42 jdbcType = configuration.getJdbcTypeForNull();
43 }
44// 由类型处理器typeHandler向ParameterHandler设置参数
45 typeHandler.setParameter(ps, i + 1, value, jdbcType);
46 }
47 }
48 }
49 }
如上代码,分割线以上的大段代码用于获取 #{xxx} 占位符属性所对应的运行时参数。分割线以下的代码则是获取 #{xxx} 占位符属性对应的 TypeHandler,并在最后通过 TypeHandler 将运行时参数值设置到 PreparedStatement 中。
总结
#{}
和
${}
的解析过程至此完成,解析方式不同,设置方式也不同,
${}
会通过TextSqlNode直接将传入的参数进行替换,存在SQL注入的风险。而每个
#{}
占位符都会解析成一个ParameterMapping对象,最后通过DefaultParameterHandler的setParameters方法进行设值,此时已经完成了预编译。。