前言
面试时问到用没用过 AOP,很多回答都是用 AOP 做过日志统一处理。,给人感觉就是没做过啊
今天介绍一个用注解封装 redis 缓存的 AOP 实战
redis 缓存加速的基本逻辑
用 redis 加速数据库访问,一般会写出如下代码
@Service
public class UserServiceImpl implements UserService {
private final UserMapper userMapper;
private final RedisClient redisClient;
@Autowired
public UserServiceImpl(UserMapper userMapper, RedisClient redisClient) {
this.userMapper = userMapper;
this.redisClient = redisClient;
}
@Override
public User get(Long id) {
String key = String.format("USER:%d", id);
String value = redisClient.get(key);
if (StringUtils.isNotEmpty(value)) {
return JSON.parseObject(value, User.class);
}
User user = userMapper.get(id);
if (user != null) {
redisClient.set(key, JSON.toJSONString(user));
}
return user;
}
}
其实现逻辑如下
- 根据输入参数构造 redis 的 key
- 从 redis 里获取该 key 的值
- 如果值不为空,则命中缓存,直接返回
- redis 未命中
- 穿透到数据库查询
- 如果数据库查询到该值,缓存到 redis
- 返回
实际上用 redis 缓存加速数据库查询基本都是这样的套路,如果在每个 service 都要这样写一遍,太繁琐了,用注解来封装一下吧
思路
通过 Spring 的 AOP 技术,拦截从数据库查询的方法,在从数据库获取结果之前,先从 redis 获取,如果 redis 命中,则直接返回;否则就继续执行从数据库获取的方法,将返回值缓存到 reids 并返回
实际上不限于从数据库获取结果,如果是从远程服务获取值,也可以采用同样的思路
实战步骤
step1:标记要拦截的方法
很显然用注解是个不错的主义,定义如下注解
/**
* 这个标注用来为redis的<b>通用化</b>存取设定参数
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Redis {
/** 非null值 默认过期时间 **/
int DEFAULT_TTL = 4 * 60 * 60 + 10 * 60;
/** null值 默认过期时间 **/
int NULL_TTL = 5 * 60;
/** redsi key,见 {@link RedisKeys} **/
String value();
/**
* <pre>
* 指示方法的哪些参数用来构造key,及其顺序(编号由0开始)
*
* 示例
* keyArgs = {1,0,2},表示用方法的第二,第一,第三个参数,按顺序来构造key
*
* 默认值的意思是方法的前 n 个参数来构造key,n 最大为10
* 这样如果构造 key 的参数不多于 10 个且顺序也和方法参数一致,则可以用默认值
* </pre>
*/
int[] keyArgs() default { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
/** 执行何种操作,默认是先访问 redis **/
RedisAction action() default RedisAction.REDIS_FIRST;
/** 过期时间,默认250分钟 **/
int ttl() default DEFAULT_TTL;
/** 是否以同步的方式操作redis,默认是false **/
boolean sync() default false;
/** 是否要缓存 null 值,默认为true **/
boolean cacheNull() default true;
/** 如果要缓存null值,过期时间是多少,默认5分钟 **/
int nullTtl() default NULL_TTL;
}
public enum RedisAction {
// 优先从 redis 获取
REDIS_FIRST,
// 穿透 redis 到 db 获取
STAB_REDIS;
}
这个注解用在需要拦截的方法上,还附带了一些元信息
接下来是在目标方法上使用注解
@Service
public class UserServiceImpl2 implements UserService {
private final UserMapper userMapper;
@Autowired
public UserServiceImpl2(UserMapper userMapper) {
this.userMapper = userMapper;
}
@Redis("USER:%d")
@Override
public User get(Long id) {
return userMapper.get(id);
}
}
现在代码就很简洁明了
step2:编写拦截器
@Aspect
@Component
public class RedisInterceptor {
private static final Logger LOG = LoggerFactory.getLogger(RedisInterceptor.class);
@Resource
private RedisClient redisClient;
@Around("@annotation(redis)")
public Object doAround(ProceedingJoinPoint pjp, Redis redis) throws Throwable {
MethodSignature signature = (MethodSignature) pjp.getSignature();
Method method = signature.getMethod();
// 是否穿透 redis
boolean stab = redis.action() == RedisAction.STAB_REDIS;
Object[] keyArgs = getKeyArgs(pjp.getArgs(), redis.keyArgs());
String key = keyArgs == null ? redis.value() : String.format(redis.value(), keyArgs);
Class<?> returnType = method.getReturnType();
Object result = stab ? null : get(key, returnType);
if (result == null) {
result = pjp.proceed();
if (result != null) {
setex(key, redis.ttl(), result, redis.sync());
} else if (redis.cacheNull()) {
setex(key, redis.nullTtl(), result, redis.sync());
}
}
return result;
}
/** 获取构造 redis 的 key 的参数数组 */
private Object[] getKeyArgs(Object[] args, int[] keyArgs) {
Object[] redisKeyArgs;
int len = keyArgs.length;
if (len == 0) {
return null;
} else {
len = min(len, args.length);
redisKeyArgs = new Object[len];
int i = 0;
for (int n : keyArgs) {
redisKeyArgs[i++] = args[n];
if (i >= len) {
break;
}
}
return redisKeyArgs;
}
}
private int min(int i, int j) {
return i > j ? j : i;
}
private <T> void setex(final String key, final int ttl, final T data, boolean sync) {
try {
redisClient.setex(key, ttl, data, sync);
} catch (Exception e) {
LOG.error("redis set error:{}", e.getMessage(), e);
}
}
private <T> T get(String key, Class<T> clazz) {
try {
return redisClient.get(key, clazz);
} catch (Exception e) {
LOG.error("redis get error:{}", e.getMessage(), e);
return null;
}
}
}
step3:配置拦截器
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:aop="http://www.springframework.org/schema/aop"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-4.0.xsd
http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/aop
http://www.springframework.org/schema/aop/spring-aop.xsd">
<!-- 开启注解式 AOP -->
<aop:aspectj-autoproxy proxy-target-class="false" />
</beans>
这样就 ok 了,在你想要使用 redis 缓存加速的方法上加上 redis 注解吧
关于 redis 穿透
通常情况下,都是优先从 redis 里查询结果。但也有时候需要穿透 redis,到 db 里去获取结果的。
@Redis
注解也支持这种操作,只需要设置 action
属性为 STAB_REDIS
即可
但是,同一个方法,action
要么是 STAB_REDIS
,要么是 REDIS_FIRST
,拦截器只能实现其中一种操作,如何才能让拦截器拦截同一个方法时,实现不同的 redis 操作呢?
有以下几个办法
- 方法的参数里添加一个专门的变量,用来告诉拦截器做何种操作
- 复制该方法为另一个方法,2个方法作用一样,注解也一样,区别是注解的 action 属性不同
- 通过方法名来区分,比如方法名里包含
stab
表示要穿透到 db
经过考虑,最终采用了第2种办法
总结
在 Spring 配置文件里开启注解式 AOP,使用如下配置
<aop:aspectj-autoproxy proxy-target-class="false" />
自定义注解,如下 2 个元注解必须
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
针对自定义的注解编写拦截器代码:拦截器要用到反射,泛型啥的,一切皆有可能
拦截器配置,注意如下几个注解的使用
@Aspect
@Component
@Around("@annotation(******)")