MyBatis

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.PreparedStatementsetString 方法:

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 上,以供下游进行消费。

参考