前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >玩转Spring Cache --- @Cacheable使用在MyBatis的Mapper接口上(解决Null key returned for cache operation)【享学Spring】

玩转Spring Cache --- @Cacheable使用在MyBatis的Mapper接口上(解决Null key returned for cache operation)【享学Spring】

作者头像
YourBatman
发布2019-09-03 15:21:09
3.5K1
发布2019-09-03 15:21:09
举报
文章被收录于专栏:BAT的乌托邦BAT的乌托邦
前言

据我观察,很多小伙伴学习一门技术一般都是度娘 + ctrl v的模式。比如本文的知识点,从网络的世界里你能找到有人介绍说:@Cacheable不仅仅能标注在实例方法上,也能标注在接口方法上

so,你回来试了试把它标注在自己的MyBatisMapper接口上,希望它能帮助分摊DB的压力。想法非常正派且看似可行,但一经实操却发现发现报错如下:

代码语言:javascript
复制
java.lang.IllegalArgumentException: Null key returned for cache operation (maybe you are using named params on classes without debug info?) Builder ...

顿时丈二的和尚了有木有,难道网上说法有误是个坑:@Cacheable不能使用在接口上吗?

其实都不是,而是因为Spring它只说了其一,并没有说其二。所以请相信,本文会给你意想不到的收获~

缓存注解使用在接口上的场景实用吗?

答:非常实用。

我们知道MyBatis作为一个优秀的、灵活的持久层框架,现在被大量的使用在我们项目中(国内使用Hibernate、JPA还是较少的)。并且我们大都是用Mapper接口 + xml文件/注解的方式去使用它来操作DB,而缓存作为缓解DB压力的一把好手,因此我们亟待需要在某些请求中在DB前面挡一层缓存。

举例最为经典的一个使用场景:DB里会存在某些配置表,它大半年都变不了一次,但读得又非常非常的频繁,这种场景还不在少数,因此这种case特别适合在Mapper接口层加入一层缓存,极大的减轻DB的压力~

本文目标

我们目标是:没有蛀牙–>能让缓存注解在Mapper接口上正常work~~~

Demo示例构造

现在我通过一个示例,模拟小伙伴们在MyBatis的Mapper接口中使用缓存注解的真实场景。

第一步:准备MyBatis环境

代码语言:javascript
复制
@Configuration
@MapperScan(basePackages = {"com.fsx.dao"})
@PropertySource(value = "classpath:jdbc.properties", ignoreResourceNotFound = false, encoding = "UTF-8")
public class MyBatisConfig {

    @Value("${datasource.username}")
    private String userName;
    @Value("${datasource.password}")
    private String password;
    @Value("${datasource.url}")
    private String url;

    // 配置数据源
    @Bean
    public DataSource dataSource() {
        MysqlDataSource dataSource = new MysqlDataSource();
        dataSource.setUser(userName);
        dataSource.setPassword(password);
        dataSource.setURL(url);
        return dataSource;
    }

    @Bean
    public SqlSessionFactoryBean sqlSessionFactory() {
        SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
        factoryBean.setDataSource(dataSource());
        // factoryBean.setMapperLocations(); // 若使用全注解写SQL 此处不用书写Mapper.xml文件所在地
        return factoryBean;
    }

}

准备Mapper接口(为了简便用MyBatis注解方式实现SQL查询):

代码语言:javascript
复制
// @Repository //备注:这个注解是没有必要的    因为已经被@MapperScan扫进去了  
public interface CacheDemoMapper {

    @Select("select * from user where id = #{id}")
    @Cacheable(cacheNames = "demoCache", key = "#id")
    User getUserById(Integer id);

}

单元测试:

代码语言:javascript
复制
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {RootConfig.class, CacheConfig.class, MyBatisConfig.class})
public class TestSpringBean {

    @Autowired
    private CacheDemoMapper cacheDemoMapper;
    @Autowired
    private CacheManager cacheManager;


    @Test
    public void test1() {
        cacheDemoMapper.getUserById(1);
        cacheDemoMapper.getUserById(1);

        System.out.println("----------验证缓存是否生效----------");
        Cache cache = cacheManager.getCache("demoCache");
        System.out.println(cache);
        System.out.println(cache.get(1, User.class));
    }
}

看似一切操作自如,风平浪静,但运行后报错如下:

代码语言:javascript
复制
java.lang.IllegalArgumentException: Null key returned for cache operation (maybe you are using named params on classes without debug info?) Builder[public final com.fsx.bean.User com.sun.proxy.$Proxy51.getUserById(java.lang.Integer)] caches=[demoCache] | key='#id' | keyGenerator='' | cacheManager='' | cacheResolver='' | condition='' | unless='' | sync='false'

	at org.springframework.cache.interceptor.CacheAspectSupport.generateKey(CacheAspectSupport.java:578)
	...

错误提示竟然告诉我没有key,不禁爆粗口:接口方法上注解里写的**key = "#id"**难道程序瞎吗?

报错原因分析

要相信:所有人都可能骗人,但程序不会骗人。

其实报错能给我们释放至少两个信号:

  1. 缓存注解确实开启而且生效了(若注解完全不生效,就不会报错)
  2. 缓存注解使用时,key为null而报错

从异常信息一眼就能看出,key为null了。但是我们确实写了key = "#id"为何还会为null呢?根据异常栈去到源码处:

因为前面有详细分析过缓存注解的处理过程、原理,因此此处只关心关键代码即可

代码语言:javascript
复制
public abstract class CacheAspectSupport extends AbstractCacheInvoker implements BeanFactoryAware, InitializingBean, SmartInitializingSingleton {
	
	...
	private Object generateKey(CacheOperationContext context, @Nullable Object result) {
		Object key = context.generateKey(result);
		if (key == null) {
			throw new IllegalArgumentException("Null key returned for cache operation (maybe you are " + "using named params on classes without debug info?) " + context.metadata.operation);
		}
		return key;
	}
	...
}

就是调用这个方法生成key的时候,context.generateKey(result);这一句返回了null才抛出了异常,罪魁祸首就是它了。继续看本类generateKey()这个方法:

代码语言:javascript
复制
		@Nullable
		protected Object generateKey(@Nullable Object result) {
			if (StringUtils.hasText(this.metadata.operation.getKey())) {
				EvaluationContext evaluationContext = createEvaluationContext(result);
				return evaluator.key(this.metadata.operation.getKey(), this.metadata.methodKey, evaluationContext);
			}
			return this.metadata.keyGenerator.generate(this.target, this.metadata.method, this.args);
		}

从代码中可知,这是因为解析#id这个SpEL表达式的时候返回了null,到这就定位到问题的根本所在了。

若要继续分析下去,那就是和SpEL关系很大了。所以我觉得有必要先了解Spring的SpEL的解析过程和简单原理,若你还不了解,可以参照:【小家Spring】SpEL你感兴趣的实现原理浅析spring-expression~(SpelExpressionParser、EvaluationContext、rootObject)

其实导致SpEL返回null的最初原因,在于MethodBasedEvaluationContext这个类对方法参数的解析上。它解析方法参数时用到了ParameterNameDiscoverer去解析方法入参的名字,而关键在于:实现类**DefaultParameterNameDiscoverer**是拿不到接口参数名的。

DefaultParameterNameDiscoverer获取方法参数名示例

下面给出一个示例,方便更直观的看到DefaultParameterNameDiscoverer的效果:

代码语言:javascript
复制
    public static void main(String[] args) throws NoSuchMethodException {
        DefaultParameterNameDiscoverer discoverer = new DefaultParameterNameDiscoverer();


        // 实现类CacheDemoServiceImpl能正常获取到方法入参的变量名
        Method method = CacheDemoServiceImpl.class.getMethod("getFromDB", Integer.class);
        String[] parameterNames = discoverer.getParameterNames(method);
        System.out.println(ArrayUtils.toString(parameterNames)); //{id}
        // 直接从method里拿  永远都是arg0/arg1...哦~~~需要注意
        Arrays.stream(method.getParameters()).forEach(p -> System.out.println(p.getName())); //arg0


        // 接口CacheDemoService不能获取到方法的入参
        method = CacheDemoService.class.getMethod("getFromDB", Integer.class);
        parameterNames = discoverer.getParameterNames(method);
        System.out.println(ArrayUtils.toString(parameterNames)); //{}

        method = CacheDemoMapper.class.getMethod("getUserById", Integer.class);
        parameterNames = discoverer.getParameterNames(method);
        System.out.println(ArrayUtils.toString(parameterNames)); //{}


        //虽然DefaultParameterNameDiscoverer拿不到,但是method自己是可以拿到的,只是参数名是arg0/arg1... 这样排序的
        Arrays.stream(method.getParameters()).forEach(p -> System.out.println(p.getName())); //arg0
    }

通过此例就不用我再多说了,知道上面的generateKey()方法返回null是肿么回事了吧~

怎么破?

问题已经定位了,我从来不缺解决方案。下面我给小伙伴们介绍三种,任君选择

方案一:使用a0/p0的方式去对方法入参进行引用

说了很多次了,key中使用SpEL表达式,即可用字段名,也可以用a0/p0这种按照顺序的方式去获取,形如这样:

代码语言:javascript
复制
@Cacheable(cacheNames = "demoCache", key = "#a0")

运行一把试试,终于一切正常,并且缓存也生效了

代码语言:javascript
复制
----------验证缓存是否生效----------
org.springframework.cache.concurrent.ConcurrentMapCache@709ed6f3
User(id=1, name=fsx, age=21)

这种方案使用起来相对非常简单(把控好参数顺序),并且得到了源生支持无需额外开发,所以推荐使用~

方案二:自定义注解 + KeyGenerator

从之前的源码分析知道,如果自己不指定key这个属性,会交给KeyGenerator去自动生成,此方案就是以这个原理为理论基础实现的。

但是,难道需要给每个方法都定一个个性化的**KeyGenerator**来解决???

当然这样也是一种解决方案,但是也太麻烦了,现在此方案不可能落地的。所以本文需要结合一个自定义注解,绕开key这个属性然后加上一个**通用的KeyGenerator**来解决问题,下面我直接给出示例代码:

1、自定义一个自己的注解,绕过key,提供一个新属性mykey

代码语言:javascript
复制
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
@Cacheable
public @interface MyCacheable {
    
    @AliasFor("cacheNames")
    String[] value() default {};
    @AliasFor("value")
    String[] cacheNames() default {};
    String key() default "";
    String keyGenerator() default "";
    String cacheManager() default "";
    String cacheResolver() default "";
    String condition() default "";
    String unless() default "";
    boolean sync() default false;

	// 自定义的属性,代替key属性(请不要使用key属性了)
    String myKey() default "";
}

2、准备一个通用的KeyGenerator来可以处理自定义注解的myKey属性:

代码语言:javascript
复制
@EnableCaching // 使用了CacheManager,别忘了开启它  否则无效
@Configuration
public class CacheConfig extends CachingConfigurerSupport {

    @Bean(name = "myMethodParamKeyGenerator")
    public KeyGenerator myMethodParamKeyGenerator() {
        return (target, method, params) -> {
            //获得注解
            MyCacheable myCacheable = AnnotationUtils.findAnnotation(method, MyCacheable.class);
            if (myCacheable != null) {
                String myKey = myCacheable.myKey();
                if (myKey != null && StringUtils.hasText(myKey)) {
                    //获取方法的参数集合
                    Parameter[] parameters = method.getParameters();
                    StandardEvaluationContext context = new StandardEvaluationContext();

                    //遍历参数,以参数名和参数对应的值为组合,放入StandardEvaluationContext中
                    // 注意:若没有java8的编译参数-parameters,参数名都回事arg0,arg1...  若有参数就是具体的参数名了
                    for (int i = 0; i < parameters.length; i++) {
                        context.setVariable(parameters[i].getName(), params[i]);
                    }


                    ExpressionParser parser = new SpelExpressionParser();
                    //根据newKey来解析获得对应值
                    Expression expression = parser.parseExpression(myKey);
                    return expression.getValue(context, String.class);

                }
            }
            return params[0].toString();
        };
    }
    ...
}

3、把新注解使用在我们的Mapper接口上:

代码语言:javascript
复制
// @Repository //备注:这个注解是没有必要的    因为已经被@MapperScan扫进去了
public interface CacheDemoMapper {

    @Select("select * from user where id = #{id}")
    @MyCacheable(cacheNames = "demoCache", /*key = "#a0"*/ keyGenerator = "myMethodParamKeyGenerator", myKey = "#arg0")
    User getUserById(Integer id);

}

运行如上测试用例:缓存生效,正常work

此方案我个人也是比较推荐的,不仅仅能解决问题,而且还能达到炫技的效果。(不要说炫技无用,炫技从另外一方面能反映出你的实力,领导才能看中你嘛~~~当然,在生产环境下不要过度炫技)

方案三:开启Java8的-parameters 编译参数

方案二有个弊端,就是只能使用arg0、arg1这种方式引用到入参的值,使用起来不是特别的方便。原因是Java编译器在编译的时候就已经把Method的形参变量名抹去了。若想保留这个值,Java8提供了-parameters编译参数来实现:

此处处理编译以Idea为例,若是用maven编译的话,方式雷同。 步骤:setting-Java Compiler(如下图):

加入此参数后,编译后再运行。这时,你的myKey就只能这么写了:myKey = "#id"(#变量名x形式) 一切正常~

此种方其实我是并不推荐的,因为它还得强依赖于编译参数,有这种强依赖还是不太好

总结

虽然说程序员最重要的技能ctrl c加ctrl v。拿来主义固然是好,但是我建议还是得活出差异化,否则怎么脱颖而出呢?因此我是支持**狂造**你的代码,只有你的**花招**多了,你才是那个特别的你。

熟悉我写博文的小伙伴应该知道,我很少介绍一种技术的基本使用,而是注重乱造代码,因为我还是比较注重分享稍微高质量一些的知识,也希望前行的路上有你的支持和鼓励

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2019年07月09日,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 缓存注解使用在接口上的场景实用吗?
  • 本文目标
  • Demo示例构造
    • 报错原因分析
    • 怎么破?
      • 总结
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档