前言
据我观察,很多小伙伴学习一门技术一般都是度娘 + ctrl v的模式。比如本文的知识点,从网络的世界里你能找到有人介绍说:@Cacheable不仅仅能标注在实例方法上,也能标注在接口方法上。
so,你回来试了试把它标注在自己的MyBatis的Mapper接口上,希望它能帮助分摊DB的压力。想法非常正派且看似可行,但一经实操却发现发现报错如下:
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环境
@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查询)
// @Repository //备注:这个注解是没有必要的 因为已经被@MapperScan扫进去了 public interface CacheDemoMapper { @Select("select * from user where id = #{id}") @Cacheable(cacheNames = "demoCache", key = "#id") User getUserById(Integer id); }
单元测试:
@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)); } }
看似一切操作自如,风平浪静,但运行后报错如下:
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"难道程序瞎吗?
报错原因分析
要相信:所有人都可能骗人,但程序不会骗人。
其实报错能给我们释放至少两个信号:
- 缓存注解确实开启而且生效了(若注解完全不生效,就不会报错)
- 缓存注解使用时,key为null而报错
从异常信息一眼就能看出,key为null了。但是我们确实写了key = "#id"为何还会为null呢?根据异常栈去到源码处:
因为前面有详细分析过缓存注解的处理过程、原理,因此此处只关心关键代码即可
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()这个方法:
@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是拿不到接口参数名的。
从代码中可知,这是因为解析#id这个SpEL表达式的时候返回了null,到这就定位到问题的根本所在了。
若要继续分析下去,那就是和SpEL关系很大了。所以我觉得有必要先了解Spring的SpEL的解析过程和简单原理,若你还不了解,可以参照:【小家Spring】SpEL你感兴趣的实现原理浅析spring-expression~(SpelExpressionParser、EvaluationContext、rootObject)
其实导致SpEL返回null的最初原因,在于MethodBasedEvaluationContext这个类对方法参数的解析上。它解析方法参数时用到了ParameterNameDiscoverer去解析方法入参的名字,而关键在于:实现类DefaultParameterNameDiscoverer是拿不到接口参数名的。
DefaultParameterNameDiscoverer获取方法参数名示例
下面给出一个示例,方便更直观的看到DefaultParameterNameDiscoverer
的效果:
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是肿么回事了吧~