MyBatis
防止 SQL 注入
$
vs #
#
将传入的数据都当成一个字符串,会对自动传入的数据加一个双引号 (底层基于 PreparedStatement
)。如:where username=#{username}
,如果传入的值是 111
,那么解析成 sql 时的值为 where username="111"
, 如果传入的值是id,则解析成的 sql 为 where username="id"
:
<select id="selectByNameAndPassword" parameterType="java.util.Map" resultMap="BaseResultMap">
select id, username, password, role
from user
where username = #{username,jdbcType=VARCHAR}
and password = #{password,jdbcType=VARCHAR}
</select>
$
将传入的数据直接显示生成在 sql 中:
<select id="selectByNameAndPassword" parameterType="java.util.Map" resultMap="BaseResultMap">
select id, username, password, role
from user
where username = ${username,jdbcType=VARCHAR}
and password = ${password,jdbcType=VARCHAR}
</select>
#
方式能够很大程度防止sql注入,$
方式无法防止Sql注入。$
方式一般用于传入数据库对象,例如传入表名。
PreparedStatement
String param = "'test' or 1=1";
String sql = "select file from file where name = ?";
对于上述 SQL 语句和参数,如果使用拼接方式,就会构成 SQL 注入,而如果使用 PreparedStatement
执行的 SQL 语句就会如下所示:
select file from file where name = '\'test\' or 1=1'
SQL 把整个参数用引号包起来,并把参数中的引号作为转义字符,从而避免了参数也作为条件的一部分。下面来看 com.mysql.jdbc.PreparedStatement
的 setString
方法:
public void setString(int parameterIndex, String x) throws SQLException {
synchronized (checkClosed().getConnectionMutex()) {
// if the passed string is null, then set this column to null
if (x == null) {
setNull(parameterIndex, Types.CHAR);
} else {
checkClosed();
int stringLength = x.length();
if (this.connection.isNoBackslashEscapesSet()) {
// Scan for any nasty chars // 判断是否需要转义处理(比如包含引号,换行等字符)
boolean needsHexEscape = isEscapeNeededForString(x, stringLength); // 如果不需要转义,则在两边加上单引号
if (!needsHexEscape) {
byte[] parameterAsBytes = null;
StringBuilder quotedString = new StringBuilder(x.length() + 2);
quotedString.append('\'');
quotedString.append(x);
quotedString.append('\'');
...
} else {
...
}
String parameterAsString = x;
boolean needsQuoted = true;
// 如果需要转义,则做转义处理
if (this.isLoadDataQuery || isEscapeNeededForString(x, stringLength)) {
...
缓存
MyBatis 有一级缓存和二级缓存,并且预留了集成第三方缓存的接口。
一级缓存
一级缓存也叫本地缓存,MyBatis 的一级缓存是在会话(SqlSession)层面进行缓存的。MyBatis 的一级缓存是默认开启的,不需要任何的配置。
在对数据库的一次会话中,我们有可能会反复地执行完全相同的查询语句,如果不采取一些措施的话,每一次查询都会查询一次数据库,而我们在极短的时间内做了完全相同的查询,那么它们的结果极有可能完全相同,由于查询一次数据库的代价很大,这有可能造成很大的资源浪费。为了解决这一问题,减少资源的浪费,MyBatis 会在表示会话的 SqlSession
对象中建立一个简单的缓存,将每次查询到的结果结果缓存起来,当下次查询的时候,如果判断先前有个完全一样的查询,会直接从缓存中直接将结果取出,返回给用户,不需要再进行一次数据库查询了。
一级缓存的生命周期有多长?
- MyBatis在开启一个数据库会话时,会创建一个新的SqlSession对象,SqlSession对象中会有一个新的Executor对象,Executor对象中持有一个新的
PerpetualCache
对象;当会话结束时,SqlSession
对象及其内部的Executor对象还有PerpetualCache
对象也一并释放掉。 - 如果
SqlSession
调用了close()
方法,会释放掉一级缓存PerpetualCache
对象,一级缓存将不可用; - 如果
SqlSession
调用了clearCache()
,会清空PerpetualCache
对象中的数据,但是该对象仍可使用; SqlSession
中执行了任何一个 update 操作(update()
、delete()
、insert()
) ,都会清空PerpetualCache
对象的数据,但是该对象可以继续使用;
二级缓存
二级缓存是用来解决一级缓存不能跨会话共享的问题的,范围是 namespace 级别的,可以被多个SqlSession
共享(只要是同一个接口里面的相同方法,都可以共享),生命周期和应用同步。如果你的MyBatis使用了二级缓存,并且你的Mapper
和select语句也配置使用了二级缓存,那么在执行select查询的时候,MyBatis会先从二级缓存中取输入,其次才是一级缓存,即MyBatis查询数据的顺序是:二级缓存 -> 一级缓存 -> 数据库。
开启二级缓存的方法
- 第一步:配置
mybatis.configuration.cache-enabled=true
。 - 第二步:在
Mapper.xml
中配置<cache/>
标签:
<cache type="org.apache.ibatis.cache.impl.PerpetualCache"
size="1024"
eviction="LRU"
flushInterval="120000"
readOnly="false"/>
某个方法不需要二级缓存
<select id="selectBlog" resultMap="BaseResultMap" useCache="false">
分页
MyBatis-Plus(简称 MP)是一个 MyBatis 的增强工具,在 MyBatis 的基础上只做增强不做改变,为简化开发、提高效率而生。
这个库提供了一个分页插件,下面简单看下此分页插件的用法和原理。
使用示例
//Spring boot方式
@Configuration
@MapperScan("com.baomidou.cloud.service.*.mapper*")
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.H2));
return interceptor;
}
}
然后查看 Mapper.java
如何使用分页:
public interface UserMapper {//可以继承或者不继承BaseMapper
IPage<User> selectPageVo(Page<?> page, Integer state);
}
UserMapper.xml
的内容如下:
<select id="selectPageVo" resultType="com.baomidou.cloud.entity.UserVo">
SELECT id,name FROM user WHERE state=#{state}
</select>
然后当调用 selectPageVo
的时候,Mybatis-Plus 自动替你分页。
分页原理
基于 MyBatis-Plus 3.0 版本的源码进行的分析。
处理分页的主要逻辑在 PaginationInterceptor
这个类的 intercept
方法里面:
@Override
public Object intercept(Invocation invocation) throws Throwable {
// SQL 解析
this.sqlParser(metaObject);
// 先判断是不是SELECT操作
MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
if (SqlCommandType.SELECT != mappedStatement.getSqlCommandType()
|| StatementType.CALLABLE == mappedStatement.getStatementType()) {
// 如果不是 SELECT 那么直接返回
return invocation.proceed();
}
// 判断参数里是否有page对象
IPage<?> page = ParameterUtils.findPage(paramObj).orElse(null);
if (page.isSearchCount() && !page.isHitCount()) {
SqlInfo sqlInfo = SqlParserUtils.getOptimizeCountSql(page.optimizeCountSql(), countSqlParser, originalSql, metaObject);
this.queryTotal(sqlInfo.getSql(), mappedStatement, boundSql, page, connection);
if (!this.continueLimit(page)) {
return null;
}
}
IDialect dialect = Optional.ofNullable(this.dialect).orElseGet(() -> DialectFactory.getDialect(dbType));
String buildSql = concatOrderBy(originalSql, page);
DialectModel model = dialect.buildPaginationSql(buildSql, page.offset(), page.getSize());
metaObject.setValue("delegate.boundSql.sql", model.getDialectSql());
metaObject.setValue("delegate.boundSql.parameterMappings", mappings);
return invocation.proceed();
}
上述主要逻辑就是先进行 SQL 解析,如果是 SELECT 操作的话,那么用 queryTotal
方法查询总 count,期内部的大致的执行的 SQL 语句的写法如下所示:
/**
* 获取 COUNT 原生 SQL 包装
*
* @param originalSql ignore
* @return ignore
*/
public static String getOriginalCountSql(String originalSql) {
return String.format("SELECT COUNT(*) FROM (%s) TOTAL", originalSql);
}
获取到 count
信息后,会放到 page
对象的 total
字段上。随后根据数据库的不同,在 SQL 语句后面拼接成不同的分页 SQL 语句。
Oracle12c 数据库拼凑分页的 SQL 语句如下:
public class Oracle12cDialect implements IDialect {
@Override
public DialectModel buildPaginationSql(String originalSql, long offset, long limit) {
String sql = originalSql + " OFFSET " + FIRST_MARK + " ROWS FETCH NEXT " + SECOND_MARK + " ROWS ONLY";
return new DialectModel(sql, offset, limit).setConsumerChain();
}
}
MySQL 数据库拼凑分页的 SQL 语句如下:
public class MySqlDialect implements IDialect {
@Override
public DialectModel buildPaginationSql(String originalSql, long offset, long limit) {
StringBuilder sql = new StringBuilder(originalSql).append(" LIMIT ").append(FIRST_MARK);
if (offset != 0L) {
sql.append(StringPool.COMMA).append(SECOND_MARK);
return new DialectModel(sql.toString(), offset, limit).setConsumerChain();
} else {
return new DialectModel(sql.toString(), limit).setConsumer(true);
}
}
}
拼接好分页 SQL 语句以后,将其放到 metaObject
上,以供下游进行消费。