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