mybatis从入门到精通(其他章节)

8. mybatis缓存配置

1.一级缓存

springboot是在开启事务的情况下是开启一级缓存的,不开启事务的情况下不开启一级缓存。
一级缓存是指,在对查询的方法和结果存储到hashMap中,key值由方法名和参数列表决定的,value值是查询到的结果
如果再次进行相同的查询那么会取hashMap中的值而不是数据库中的值,这样做会残生一些问题。

测试

@Transactional
@Test
void test1Cache(){
    SysUser sysUser = userMapper.selectByPrimaryKey(1L);
    sysUser.setUserName("new name");
    SysUser sysUser1 = userMapper.selectByPrimaryKey(1L);
    Assert.assertEquals(sysUser,sysUser1);
    log.info(sysUser1.getUserName());
}

这里的断言是通过的,也就是说sysUser和sysUser1指向的是相同的内存地址,要小心这种情况,可能你以为sysUser1是数据库中的值

关闭一级缓存可以通过添加select标签中的flushCache="true"那么执行完查询后会清除当前的sqlSession所有的缓存,在进行增,删,改操作并成功提交的情况下会清空一级缓存。

2.二级缓存

mybatis开启二级缓存,先要全局性的打开缓存配置

<!--全局性地开启或关闭所有映射器配置文件中已配置的任何缓存。默认值为true-->
<setting name="cacheEnabled" value="true"/>

在mapper.xml文件中添加配置,也可以在mapper接口文件中添加

<!--开启二级缓存,
该文件下的select语句都会被缓存,
所有的delete update insert都会刷新缓存
缓存默认使用LRU算法收回~eviction
缓存不会按照时间顺序自动刷新~flushInterval 单位毫秒
缓存中会存储集合或者对象1024个引用~size
缓存会被视为read/ write(可读/可写)的,意味着对象检索不是共享的,而且可以安全地被调用者修改,而不干扰其他调用者或线程所做的潜在修改。
readOnly="true" 是说返回的对象是相同的实例,这提高了性能(但是只要修改了对象的属性,会导致数据库与缓存中的数据不一致会产生脏读),
默认为false,那么mybatis会通过序列化返回对象的拷贝,这就需要查询出来的对象实现可序列化接口
-->
<cache
 eviction="FIFO"
 flushInterval="60000"
 size = "512"
 readOnly="true"
/>

测试

@Test
void test1Cache(){
    SysUser sysUser = userMapper.selectByPrimaryKey(1L);
    sysUser.setUserName("new name");
    SysUser sysUser1 = userMapper.selectByPrimaryKey(1L);
    Assert.assertEquals(sysUser,sysUser1);
    log.info(sysUser1.getUserName());
}

image-20201031111314924

由于第一次select语句未执行所以命中率为0,这里是让 readOnly="true"所以修改对象以后,第二条读到的数据在缓存和数据库中的数据不一致是脏读

改为false并且令SysUser继承Serializable接口可以解决这个问题

什么是序列化?

3.二级缓存使用的情况

  1. 已查询为主的表,该表很少进行增,删,改操作
  2. 绝大多数都是单表查询时,很少出现与其他的表相关联
    1. 如果要通过两张表查询数据,可能出现脏读的情况,可以使用参照缓存来解决。但是如果几十张表都已不同的关联关系存在时,显然参照缓存也不起作用了。当某几个表可以作为一个业务整体时,通常是让几个会关联的ER表同时使用同一个二级缓存,这样就能解决脏数据问题。
  3. 可以按照业务划分对表进行分组,如果关联的表比较少可以使用参照缓存

9. Mybatis插件开发

Mybatis允许在已映射语句执行过程中的某一点进行拦截调用。默认情况下, Mybatis允许使用插件来拦截的接口包括以下几个。

Executor
ParameterHandler
ResultSetHandler
StatementHandler

想要自定义插件就得自定义拦截器,通过继承interceptor接口,在该类上配置拦截器注解

1. interceptor

public interface Interceptor {

  Object intercept(Invocation invocation) throws Throwable;

  default Object plugin(Object target) {
    return Plugin.wrap(target, this);
  }

  default void setProperties(Properties properties) {
    // NOP
  }

}

setProperties设置插件的参数用来改变插件的行为,插件的参数在mybatis.config中配置的时候设置

<plugins>
    <plugin interceptor="com.yogurt.plugin.MybatisInterceptor">
        <property name="pro1" value="value1"/>
        <property name="pro2" value="value2"/>
    </plugin>
</plugins>

plugin(target:被拦截的对象)该方法会在创建被拦截接口对象时调用

Plugin.wrap(target, this)会自动判断拦截器的签名被拦截对象的接口是否匹配,只有匹配的情况下才会动态代理拦截对象???

intercept:mybatis运行时要执行的拦截方法,通过invocation参数可以获取以下信息

@Override
public Object intercept(Invocation invocation) throws Throwable {
    //获取当前被拦截的对象
    Object target = invocation.getTarget();
    //获取当前被拦截的方法
    Method method = invocation.getMethod();
    //获取当前被拦截的方法的参数
    Object[] args = invocation.getArgs();
    //真正的执行被拦截的方法就是 method.invoke(target,args);
    Object proceed = invocation.proceed();
    return null;
}

NOTE:

当配置多个拦截器时, Mybatis会遍历所有拦截器,按顺序执行拦截器的plugin方法,被拦截的对象就会被层层代理。在执行拦截对象的方法时,会一层层地调用拦截器,拦截器通过 Invocation.proceed()调用下一层的方法,直到真正的方法被执行。方法执行的结果会从最里面开始向外一层层返回,所以如果存在按顺序配置的A、B、C三个签名相同的拦截器,Mybaits会按照C>B>A> target.proceed()>A>B>C的顺序执行。如果A、B、C签名不同,就会按照 Mybatis 拦截对象的逻辑执行。

2. 拦截器签名

拦截器签名通过两个注解实现,@Intercepts和@Signature

@Intercepts注解中的属性是一个@Signature数组。用来拦截多个方法

type :需要拦截的类,从上面提到的四个中间选

method和args可以唯一定位到一个方法

@Intercepts(
        @Signature(
                type = ResultSetHandler.class,
                method = "handleResultSets",
                args = Statement.class
        )
)

note: 可以被拦截的四个接口中的方法并不是都可以被拦截,详情请看《mybatis从入门到精通》

3.开发一个下划线键值转驼峰式插件

需求:我们在处理mybatis查询结果的时候为了方便扩展,有时候会使用Map作为返回值,那么返回回来的Map的key就是数据库中的列名,是下划线形式的,使用起来特别不方便,所以需要在结果返回回来之后进行处理,需要开发一个下划线键值转驼峰式插件,这个插件可以通过拦截ResultSetHandler中的 handleResultSets(Statement stmt) 方法来实现

ResultSetHandler中的 List handleResultSets(Statement stmt) throws SQLException;

该方法是在存储过程返回值不为Cursor时调用,拦截处理Mybatis的查询结果特别有效,并且这个接口被调用的位置在处理二级缓存之前,因此通过这种方式处理的结果可以在二级缓存中体现。

/**
 * 下滑线转驼峰插件
 */
@Intercepts(
        @Signature(
                type = ResultSetHandler.class,
                method = "handleResultSets",
                args = {Statement.class}
        )
)
@SuppressWarnings({"unchecked", "rawTypes"})
public class UnderscoreToCamelCaseInterceptor implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        //拿到该方法的返回值,这可不是乱强转的,是因为被拦截的方法返回值是List<E>才能这么转
        List<Object> list = (List<Object>) invocation.proceed();
        for (Object object : list) {
            if (object instanceof Map) {
                processMap((Map<String, Object>) object);
            } else {
                break;
            }
        }
        return list;
    }

    /**
     * 处理map类型
     */
    private void processMap(Map<String, Object> map) {
        Set<String> set = map.keySet();//set里面没有实现Iterator,所以转一下
        HashSet<String> keySet = new HashSet<>(set);
        for (String key : keySet) {
            //将大写开头的字母转为小写,如果包含下划线则转换为驼峰
            if (key.charAt(0) >= 'A' && key.charAt(0) <= 'Z' || key.indexOf("_") > 0) {
                Object value = map.get(key);
                map.remove(key);
                map.put(underscoreToCamelCaseKey(key), value);
            }
        }
    }

    /**
     * 将下划线分格转换成驼峰分格
     */
    private String underscoreToCamelCaseKey(String key) {
        StringBuilder sb = new StringBuilder();
        //设置一个flag表示一个单词的结束,下一个单词的开始
        boolean nextUpperCase = false;
        //遍历key的每个字母
        for (int i = 0; i < key.length(); i++) {
            char c = key.charAt(i);
            if (c == '_') {
                if (sb.length() > 0) {
                    //如果这个当前字符是下划线,且不是sb的开头(有时候列名是_role_name的)那么下一个字母就是大写的
                    nextUpperCase = true;
                }
            } else {
                if (nextUpperCase) {
                    sb.append(Character.toUpperCase(c));
                    nextUpperCase = false;//重置默认标记
                } else {
                    sb.append(Character.toLowerCase(c));
                }
            }
        }
        return sb.toString();
    }
}

最后在mybatis-config.xml文件中配置一下就能实现需求了。

<plugins>
    <plugin interceptor="com.yogurt.plugin.UnderscoreToCamelCaseInterceptor"/>
</plugins>

4.实现分页插件

在实现分页查询的时候需要添加分页条件offset、limit,且由于每个数据库的分页实现都不一样,还要databaseId判断。如果要查询总数还要增加一个count并手动添加另外一条sql语句,所以显得sql十分臃肿,这时候可以通过拦截器来实现分页功能。

分页插件需要拦截的方法:

Executor 中的 query方法,这里注意是四个参数的,还有跟多参数的query方法由于Mybatis的内部实现无法被拦截

<E> List<E> query(MappedStatement ms//执行sql用的
    , Object parameter//参数
    , RowBounds rowBounds//包含了offset limit,后面还有PageRowBounds继承了RowBounds
    , ResultHandler resultHandler//实现对结果的处理
                 ) throws SQLException;

实现分页插件需要两个关键的类:PageInterceptor和Dialect

PageInterceptor负责实现分页和查询总数的逻辑,Dialect是一个接口,不同的数据库有不同的实现方式,主要是实现了查询总数的Sql,和分页sql的拼接等。

1. PageInterceptor

@Intercepts(
   @Signature(
      type = Executor.class, 
      method = "query", 
      args = {MappedStatement.class, Object.class, 
            RowBounds.class, ResultHandler.class}
   )
)
public class PageInterceptor implements Interceptor {
    private static final List<ResultMapping> EMPTY_RESULTMAPPING
          = new ArrayList<ResultMapping>(0);
    private Dialect dialect;
    private Field additionalParametersField;

   @Override
    public Object intercept(Invocation invocation) throws Throwable {
        //获取拦截方法的参数
        Object[] args = invocation.getArgs();
        MappedStatement ms = (MappedStatement) args[0];
        Object parameterObject = args[1];
        RowBounds rowBounds = (RowBounds) args[2];
        //使用rowBounds参数判断是否需要进行分页,如果不需要,直接返回结果
        if (!dialect.skip(ms.getId(), parameterObject, rowBounds)) {
           ResultHandler resultHandler = (ResultHandler) args[3];
            //当前的目标对象~被拦截的对象
            Executor executor = (Executor) invocation.getTarget();
            //获取执行的select ~ sql语句 例如 select * from xxx
            BoundSql boundSql = ms.getBoundSql(parameterObject);

            //反射获取动态参数
            Map<String, Object> additionalParameters = 
                  (Map<String, Object>) additionalParametersField.get(boundSql);
            //根据当前rowBounds的类型是不是PageRowBounds来判断是否需要进行 count 查询
            if (dialect.beforeCount(ms.getId(), parameterObject, rowBounds)){
               //根据当前的 ms 创建一个返回值为 Long 类型的 ms
                MappedStatement countMs = newMappedStatement(ms, Long.class);
                //创建 count 查询的缓存 key
                CacheKey countKey = executor.createCacheKey(
                      countMs, 
                      parameterObject, 
                      RowBounds.DEFAULT, 
                      boundSql);
                //调用方言获取 count sql
                String countSql = dialect.getCountSql(
                      boundSql, 
                      parameterObject, 
                      rowBounds, 
                      countKey);
                //根据countSql获取到BoundSql对象
                BoundSql countBoundSql = new BoundSql(
                      ms.getConfiguration(), 
                      countSql, 
                      boundSql.getParameterMappings(), 
                      parameterObject);
                //当使用动态 SQL 时,可能会产生临时的参数,这些参数需要手动设置到新的 BoundSql 中
                for (String key : additionalParameters.keySet()) {
                    countBoundSql.setAdditionalParameter(
                          key, additionalParameters.get(key));
                }
                //执行 count 查询
                Object countResultList = executor.query(
                      countMs, 
                      parameterObject, 
                      RowBounds.DEFAULT, 
                      resultHandler, 
                      countKey, 
                      countBoundSql);
                Long count = (Long) ((List) countResultList).get(0);
                //处理查询总数,把数据库中的总条数放进PageRowBounds的total中
                dialect.afterCount(count, parameterObject, rowBounds);
                //没有数据不执行之后的流程
                if(count == 0L){
                   //当查询总数为 0 时,直接返回空的结果
                   return dialect.afterPage(
                         new ArrayList(), 
                         parameterObject, 
                         rowBounds); 
                }
            }
            //根据有没有rowBounds参数来判断是否需要进行分页查询
            if (dialect.beforePage(ms.getId(), parameterObject, rowBounds)){
               //生成分页的缓存 key
                CacheKey pageKey = executor.createCacheKey(
                      ms, 
                      parameterObject, 
                      rowBounds, 
                      boundSql);
                //调用方言获取分页 sql,每个数据库的分页查询都不一样这里获取的是Mysql的分页查询
                String pageSql = dialect.getPageSql(
                      boundSql, 
                      parameterObject, 
                      rowBounds, 
                      pageKey);
                BoundSql pageBoundSql = new BoundSql(
                      ms.getConfiguration(), 
                      pageSql, 
                      boundSql.getParameterMappings(), 
                      parameterObject);
                //设置动态参数
                for (String key : additionalParameters.keySet()) {
                    pageBoundSql.setAdditionalParameter(
                          key, additionalParameters.get(key));
                }
                //执行分页查询
                List resultList = executor.query(
                      ms, 
                      parameterObject, 
                      RowBounds.DEFAULT, 
                      resultHandler, 
                      pageKey, 
                      pageBoundSql);
                //返回resultList
                return dialect.afterPage(resultList, parameterObject, rowBounds);
            }
        }
        //返回默认查询
        return invocation.proceed();
    }

    /**
     * 根据现有的 ms 创建一个新的,使用新的返回值类型
     *
     * @param ms
     * @param resultType
     * @return
     */
    public MappedStatement newMappedStatement(
          MappedStatement ms, Class<?> resultType) {
        MappedStatement.Builder builder = new MappedStatement.Builder(
              ms.getConfiguration(), 
              ms.getId() + "_Count", 
              ms.getSqlSource(), 
              ms.getSqlCommandType()
        );
        builder.resource(ms.getResource());
        builder.fetchSize(ms.getFetchSize());
        builder.statementType(ms.getStatementType());
        builder.keyGenerator(ms.getKeyGenerator());
        if (ms.getKeyProperties() != null 
              && ms.getKeyProperties().length != 0) {
            StringBuilder keyProperties = new StringBuilder();
            for (String keyProperty : ms.getKeyProperties()) {
                keyProperties.append(keyProperty).append(",");
            }
            keyProperties.delete(
                  keyProperties.length() - 1, keyProperties.length());
            builder.keyProperty(keyProperties.toString());
        }
        builder.timeout(ms.getTimeout());
        builder.parameterMap(ms.getParameterMap());
        //count查询返回值int
        List<ResultMap> resultMaps = new ArrayList<ResultMap>();
        ResultMap resultMap = new ResultMap.Builder(
              ms.getConfiguration(), 
              ms.getId(), 
              resultType, 
              EMPTY_RESULTMAPPING).build();
        resultMaps.add(resultMap);
        builder.resultMaps(resultMaps);
        builder.resultSetType(ms.getResultSetType());
        builder.cache(ms.getCache());
        builder.flushCacheRequired(ms.isFlushCacheRequired());
        builder.useCache(ms.isUseCache());
        return builder.build();
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    @Override
    public void setProperties(Properties properties) {
        String dialectClass = properties.getProperty("dialect");
        try {
            //这里是初始化了MySqlDialect它实现了Dialect接口
            dialect = (Dialect) Class.forName(dialectClass).newInstance();
        } catch (Exception e) {
            throw new RuntimeException(
                  "使用 PageInterceptor 分页插件时,必须设置 dialect 属性");
        }
        //这里时也没干
        dialect.setProperties(properties);
        try {
            //反射获取 BoundSql 中的 additionalParameters 属性
            //additionalParameters是一个map对象。
            additionalParametersField = BoundSql.class.getDeclaredField(
                  "additionalParameters");
            //使得private也能用,可以使用该对象获取键和值
            additionalParametersField.setAccessible(true);
        } catch (NoSuchFieldException e) {
            throw new RuntimeException(e);
        }
    }

}

该类的逻辑是:拿到dialect的实例,这个实例是配置插件的时候就通过property设置好的。先判断是否进行分页查询,再判断是否要查询数据库的总条数,如果进行总条数查询则查询到数据库的总条数,如果数据库的总条数是0就直接返回一个空的ArrayList。再判断是否执行分页查询(这一层其实我觉得多余了)。。

在controller层传过来的所有的参数除了rowBounds都会保存到query的parameterObject中,如果你还是用了动态sql(if where..)那么在动态sql中使用的参数都会保存到additionalParameters中,需要让BoundSql添加这些动态sql需要的参数

2. dialect

public interface Dialect {
   /**
    * 跳过 count 和 分页查询
    * 
    * @param msId 执行的  MyBatis 方法全名
    * @param parameterObject 方法参数
    * @param rowBounds 分页参数
    * @return true 跳过,返回默认查询结果,false 执行分页查询
    */
   boolean skip(String msId, Object parameterObject, RowBounds rowBounds);
   
   /**
    * 执行分页前,返回 true 会进行 count 查询,false 会继续下面的 beforePage 判断
    * 
    * @param msId 执行的  MyBatis 方法全名
    * @param parameterObject 方法参数
    * @param rowBounds 分页参数
    * @return
    */
   boolean beforeCount(String msId, Object parameterObject, RowBounds rowBounds);
   
   /**
    * 生成 count 查询 sql
    * 
    * @param boundSql 绑定 SQL 对象
    * @param parameterObject 方法参数
    * @param rowBounds 分页参数
    * @param countKey count 缓存 key
    * @return
    */
   String getCountSql(BoundSql boundSql, Object parameterObject, RowBounds rowBounds, CacheKey countKey);
   
   /**
    * 执行完 count 查询后
    * 
    * @param count 查询结果总数
    * @param parameterObject 接口参数
    * @param rowBounds 分页参数
    */
   void afterCount(long count, Object parameterObject, RowBounds rowBounds);
   
   /**
    * 执行分页前,返回 true 会进行分页查询,false 会返回默认查询结果
    * 
    * @param msId 执行的 MyBatis 方法全名
    * @param parameterObject 方法参数
    * @param rowBounds 分页参数
    * @return
    */
   boolean beforePage(String msId, Object parameterObject, RowBounds rowBounds);
   
   /**
    * 生成分页查询 sql
    * 
    * @param boundSql 绑定 SQL 对象
    * @param parameterObject 方法参数
    * @param rowBounds 分页参数
    * @param pageKey 分页缓存 key
    * @return
    */
   String getPageSql(BoundSql boundSql, Object parameterObject, RowBounds rowBounds, CacheKey pageKey);
   
   /**
    * 分页查询后,处理分页结果,拦截器中直接 return 该方法的返回值
    * 
    * @param pageList 分页查询结果
    * @param parameterObject 方法参数
    * @param rowBounds 分页参数
    * @return
    */
   Object afterPage(List pageList, Object parameterObject, RowBounds rowBounds);
   
   /**
    * 设置参数
    * 
    * @param properties 插件属性
    */
   void setProperties(Properties properties);
}

3.MysqlDialect和PageRowBounds

MysqlDialect实现了在mysql数据库中的总条数查询sql语句和分页语句

PageRowBounds继承了rowBounds除了offset、limit还添加了一个参数total,如果用户设置了PageRowBounds那么会多进行一次sql查询,查询到的总条数保存到total变量中

public class MySqlDialect implements Dialect {

   @Override
   public boolean skip(String msId, Object parameterObject, RowBounds rowBounds) {
      //这里使用 RowBounds 分页,默认没有 RowBounds 参数时,会使用 RowBounds.DEFAULT 作为默认值
      return rowBounds == RowBounds.DEFAULT;
   }
   //返回false执行 beforePage 返回true执行 countSql
   @Override
   public boolean beforeCount(String msId, Object parameterObject, RowBounds rowBounds) {
      //只有使用 PageRowBounds 才能记录总数,否则查询了总数也没用
      return rowBounds instanceof PageRowBounds;
   }
   
   @Override
   public String getCountSql(BoundSql boundSql, Object parameterObject, RowBounds rowBounds, CacheKey countKey) {
      //简单嵌套实现 MySql count 查询,count(*)是 boundSql.getSql() 返回的条数
      log.info(boundSql.getSql());
      //这里是使用派生表查询到数据库的总条数,执行了两次sql查询猜得到count(*),实际上可以优化
      return "select count(*) from (" + boundSql.getSql() + ") temp";
   }
   
    @Override
    public void afterCount(long count, Object parameterObject, RowBounds rowBounds) {
       //记录总数,按照 beforeCount 逻辑,只有 PageRowBounds 时才会查询 count,所以这里直接强制转换
       ((PageRowBounds)rowBounds).setTotal(count);
    }

    @Override
   public boolean beforePage(String msId, Object parameterObject, RowBounds rowBounds) {
      //执行分页前,返回 true 会进行分页查询,false 会返回默认查询结果
      return rowBounds != RowBounds.DEFAULT;
   }
   
   @Override
   public String getPageSql(BoundSql boundSql, Object parameterObject, RowBounds rowBounds, CacheKey pageKey) {
      //pageKey 会影响缓存,通过固定的 RowBounds 可以保证二级缓存有效
      pageKey.update("RowBounds");
      return boundSql.getSql() + " limit " + rowBounds.getOffset() + "," + rowBounds.getLimit();
   }

   @Override
   public Object afterPage(List pageList, Object parameterObject, RowBounds rowBounds) {
      return pageList;
   }
   
   @Override
   public void setProperties(Properties properties) {
      
   }
}
public class PageRowBounds extends RowBounds{
   private long total;

   public PageRowBounds() {
      super();
   }

   public PageRowBounds(int offset, int limit) {
      super(offset, limit);
   }

   public long getTotal() {
      return total;
   }

   public void setTotal(long total) {
      this.total = total;
   }
}

5.总结

如果要根据自己的需求开发插件,必须要学好Mybatis中的源码,任重而道远!!!

posted @ 2020-10-31 11:58  ${yogurt}  阅读(167)  评论(0编辑  收藏  举报