本文已参与「新人创作礼」活动,一起开启掘金创作之路。
mybatis作为一个轻量级的ORM框架,应用广泛,上手也比较简单;一个成熟的框架,必然有值得学习的地方。本期就来讲${}和#{}两种传参方式在安全性方面的差异。
mybatis框架,在sql语句中获取传入的参数有如下两种方式: ${paramName}和 #{paramName}。为了理解其区别,我们看下原生的JDBC查询,代码如下:
public static void main(String[] args) throws Exception {
// sql语句
String sql = "select id,name from customer limit 2";
// 1.加载驱动, 此处使用的mysql驱动包是8.0版本, 若为5.0+版本, 请修改以下类路径
Class.forName("com.mysql.cj.jdbc.Driver");
// 2.获取数据库连接
String url = "jdbc:mysql://localhost:3306/work?useSSL=false&useUnicode=true" +
"&characterEncoding=UTF-8&useJDBCCompliantTimezoneShift=true" +
"&useLegacyDatetimeCode=false&serverTimezone=UTC";
Connection conn = DriverManager.getConnection(url,"root", "123456");
// 3、获得可以执行sql语句的对象
Statement st = conn.createStatement();
// 4、使用对象去执行SQL语句
ResultSet rs = st.executeQuery(sql);
// 5、处理sql语句返回的结果集
while(rs.next()){
// 获得一行数据
Integer id = rs.getInt("id");
String name = rs.getString("name");
System.out.println("sql查询: id = " + id + " , name = " + name);
}
// 6、释放资源
rs.close();
st.close();
conn.close();
}
除此之外,第3、4步两条语句也可以换成如下两条:
// 3.创建 PreparedStatement 对象去执行sql
PreparedStatement preparedStatement = conn.prepareStatement(sql);
// 4.执行sql语句
ResultSet rs = preparedStatement.executeQuery();
这里就可以发现区别了,后者在创建 PreparedStatement 对象时就把sql语句传入,在执行语句时就不用传入sql了;而 Statement 则刚好相反。
这就是我们常说的预编译。使用PreparedStatement 对象,在执行第3步时,我们已经传入了sql,则相当于这条sql会被数据库编译(数据库对sql语句的编译也是相当复杂的),所以在第4步执行的时候就不用再传入sql了,因为数据库已经知道我们要执行的sql了,我们只需传入参数即可;如果使用Statement对象,数据库就没有提前去解析sql,因为创建对象时没有传入;当执行sql时,数据库再编译与执行。
简单说就是一个预先编译sql了,一个没有预先编译。PreparedStatement对象的好处是,sql已经提前编译好,剩下的工作就是传入参数即可,编译好的sql可以复用,传入不同的参数,则数据库就将相应的参数填入编译好的sql。而Statement对象就是每次都要传入sql,丢给数据库去编译再执行;但是创建PreparedStatement对象的开销是比Statement对象大的。不过在日常开发中,两者的开销差异不用在意。实际上,90%的场景我们使用的是PreparedStatement对象,如果用了框架则看不到,因为框架已经封装了。系统出现性能问题,也不会是因为这两个对象的原因。
mybatis中${} 与 #{} 这两种取值方式对应着Statement和PreparedStatement对象。 #{} 传参,代表sql已经预编译好了,传入的参数不再参与SQL语句的编译,仅仅作为普通字符串处理; ${} 传参,传完了之后统一编译,参数也包含在SQL语句中一起编译,参数是SQL语句的一部分。
具体在使用中的不同,看如下两种场景:
@Override
public List<Map<String, Object>> listUser() {
String param = " and name = '李白'";
return indexMapper.listUser(param);
}
<select id="listUser" resultType="map">
select * from customer
where 1 = 1 #{param}
</select>
上述代码会报sql语句规则错误,因为#{} 代表sql已经编译好了,传入的仅仅是参数。语句select * from customer where 1 = 1本身已经完整,后面传入的 and name = '李白'其实就是一串字符,加在后面肯定报错。我们实际上想要表达的是select * from customer where 1 = 1 and name = ?
则需要改进如下:
@Override
public List<Map<String, Object>> listUser() {
String param = "李白";
return indexMapper.listUser(param);
}
<select id="listUser" resultType="map">
select * from customer
where 1 = 1 and name = #{param}
</select>
这种情况使用 #{} 就是对的了,因为传入的参数仅仅就是参数,替换进sql语句中即可。
参数类型的影响
我们再看这个例子:
@Override
public List<Map<String, Object>> listUser() {
String param = "李白";
return indexMapper.listUser(param);
}
<select id="listUser" resultType="map">
select * from customer
where 1 = 1 and name = ${param}
</select>
上述代码按理说,传入的仅仅是参数,不管是否预编译都应该能执行,但是实际还是会报错。我们看执行时打印出的sql语句:select * from customer where 1 = 1 and name = 李白。显然,问题就在于参数没有加单引号,name字段是字符串类型,传入的也是字符串,偏偏mybatis转换之后没有加单引号。
所以当传入字符串类型参数时,应该用 #{} 取值,此时会自动加上单引号。
@Override
public List<Map<String, Object>> listUser() {
String param = "name";
return indexMapper.listUser(param);
}
<select id="listUser" resultType="map">
select * from customer
where 1 = 1
order by ${param} desc
</select>
大家注意上述代码参数的位置,此时传入的参数是要排序的字段名称,如果采用#{} 取值,则会自动加上单引号的,但是order by后面的排序字段不需要加,所以这种情况只能使用 ${} 取值。不过此处用 #{} 取值也不会报错,那是因为mysql支持这种写法,但是查询的结果是不对的。
安全性
日常开发中,只要能理解上述两种情形,那么就能正确使用 ${} 和 #{},由于这两种方式取值原理的区别,也容易明白 #{} 这种方式是可以防止sql注入的。
MyBatis的#{}之所以能够预防SQL注入是因为底层使用了PreparedStatement类的setString()方法来设置参数,此方法会获取传递进来的参数的每个字符,然后进行循环对比,如果发现有敏感字符(如:单引号、双引号等),则会在前面加上一个'\'代表转义此符号,让其变为一个普通的字符串,不参与SQL语句的生成,达到防止SQL注入的效果。
${}匹配的是真实传递的值,传递过后,会与sql语句进行字符串拼接。${}会与其他sql进行字符串拼接,不能预防sql注入问题。
对于安全编程来说,不能仅仅依靠#{}预编译来防止SQL注入,而是需要对输入的参数进行过滤,这样使得业务可以更灵活。