Spring AOP 实战经验
最近把项目里用到 redis 的部分用 aop 进行重构,记录下来以后回顾
重构前
先看下重构前的代码
/**
* 获取栏目下的专辑
*
* @return
*/
@RequestMapping("category_albums.do")
@ResponseBody
public Result listCategoryAlbums(@RequestParam int categoryId, @RequestParam int page) {
//防止客户端忘记传page的值,默认1
if (page <= 0) {
page = Constant.DEFAULT_PAGE;
}
//准备数据
String redisKey = RedisKeys.TV_VIDEO_CATEGORY_ALBUM_DETAIL;
int expireTime = CommonConstants.API_CACHE_EXPIRE_INTERVAL;
// 从redis中获得对象实体
AlbumsVo albumData = redisManager.getDataFromRedis(AlbumsVo.class, redisKey, categoryId,
page);
if (null == albumData) {
albumData = albumService.getCatagoryAlbumsData(categoryId, page,
CommonConstants.PAGENUM);
redisManager.setDataToRedisAsync(albumData, expireTime, redisKey, categoryId, page);
} else {
albumBlockedCache.clearBlocked(albumData.getAlbumList());
}
return new Result(albumData);
}
解析
这段代码很简单,向客户端返回某个栏目下的专辑,处理逻辑
首先到 redis 里获取结果,如果命中的话,直接返回给客户端
如果未命中,则到数据库里获取结果,将数据库里获取到的结果缓存到 redis,并返回给客户端
很多接口都是类似的处理逻辑,再看一个例子
/**
* 通过热词获取搜索结果
*
* @param hotword
* @param type
* @param page
*/
@RequestMapping("hotword_search.do")
@ResponseBody
public Result hotwordSearch(@RequestParam String hotword, @RequestParam int type,
@RequestParam int page) {
//防止客户端忘记传page的值,默认1
if (page <= 0) {
page = Constant.DEFAULT_PAGE;
}
//准备数据
String redisKey = RedisKeys.TV_VIDEO_HOTWORD_SREACH;
int expireTime = CommonConstants.API_CACHE_EXPIRE_INTERVAL;
HotwordSearchVo searchData = redisManager.getDataFromRedis(HotwordSearchVo.class,
redisKey, hotword, type, page);
if (null == searchData) {
searchData = hotwordService.getKeywordSearchResult(hotword, type, page);
// 创建一个单个数据存入redis的异步任务
redisManager.setDataToRedisAsync(searchData, expireTime, redisKey, hotword, type, page);
}
return new Result(searchData);
}
重构思路
通过 spring 的 aop 技术,拦截从数据库获取结果的方法,在从数据库获取结果之前,先从 redis 获取,如果 redis 命中,则直接返回;否则就继续执行从数据库获取的方法,将返回值缓存到 reids 并返回
实际上不限于从数据库获取结果,例如上例的hotwordSearch()
,实际上并非从数据库获取结果,是调用的 cp 的接口获取的结果
重构步骤
标记要拦截的方法
自定义注解
显然用注解是个很不错的想法,定义如下的注解
/**
* <pre>
* 这个标注用来为redis的<b>通用化</b>存取设定参数
* </pre>
*
* @author zhangyan
* @date 2016年3月29日
*/
@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;
}
这个注解用在需要拦截的方法上,还附带了一些元信息
在目标方法上使用注解
@Override
@Redis(value = RedisKeys.TV_VIDEO_FILTER_RESULT, keyArgs = { 0, 5, 4, 1 })
public AlbumsVo filterChannelAlbums(int channelId, int page, int pageNum,
List<Integer> optionList, int orderBy, String optionStr) {
return _filterChannelAlbums(channelId, page, pageNum, optionList, orderBy, optionStr);
}
编写拦截器
/**
* <pre>
* 拦截db操作,并到redis里进行同样的操作
* </pre>
*
* @author zhangyan
* @date 2016年4月13日
*/
@Aspect
@Component
public class RedisInterceptor {
private static final Logger LOG = Logger.getLogger(RedisInterceptor.class);
@Resource
private RedisHelper redisHelper;
@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(method, pjp.getArgs(), redis.keyArgs());
String key = keyArgs == null ? redis.value() : String.format(redis.value(), keyArgs);
if (keyArgs == null || keyArgs.length == 0 || keyArgs[0] == null) {
LOG.warn("key args is empty,key=" + key);
}
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的参数数组
*
* @param method
* @param args
* @param keyArgs
* @return
*/
private Object[] getKeyArgs(Method method, 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 {
redisHelper.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 redisHelper.get(key, clazz);
} catch (Exception e) {
LOG.error("redis get error:" + e.getMessage(), e);
return null;
}
}
}
配置 spring 使拦截生效
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:aop="http://www.springframework.org/schema/aop" xmlns:task="http://www.springframework.org/schema/task"
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-4.0.xsd
http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop-4.0.xsd
http://www.springframework.org/schema/task http://www.springframework.org/schema/task/spring-task-4.0.xsd">
<context:component-scan base-package="com.meizu.flymetv.video" />
<!-- 开启注解式 AOP -->
<aop:aspectj-autoproxy proxy-target-class="false" />
<import resource="classpath*:config/video-redis.xml" />
<import resource="classpath*:config/video-metaq.xml" />
<task:executor id="executor" pool-size="5-50"
queue-capacity="6000" />
</beans>
重构原代码
现在,拦截器承包了对 redis 的操作......,业务代码里就不需要那些相关的代码了
/**
* 获取栏目下的专辑
*
* @return
*/
@RequestMapping("category_albums.do")
@ResponseBody
public Result listCategoryAlbums(@RequestParam int categoryId, @RequestParam int page) {
//防止客户端忘记传page的值,默认1
if (page <= 0) {
page = Constant.DEFAULT_PAGE;
}
AlbumsVo albumData = albumService.getCatagoryAlbumsData(categoryId, page,
CommonConstants.PAGE_SIZE);
albumBlockedCache.clearBlocked(albumData.getAlbumList());
return new Result(albumData);
}
......
/**
* 通过热词获取搜索结果
*
* @param hotword
* @param type
* @param page
*/
@RequestMapping("hotword_search.do")
@ResponseBody
public Result hotwordSearch(@RequestParam String hotword, @RequestParam int type,
@RequestParam int page) {
//防止客户端忘记传page的值,默认1
if (page <= 0) {
page = Constant.DEFAULT_PAGE;
}
return new Result(hotwordService.getKeywordSearchResult(hotword, type, page));
}
和重构前的代码相比,简洁多了,整个世界都干净了......
redis 穿透
通常情况下,都是优先从 redis 里查询结果。但也有时候需要穿透 redis,到 db 里去获取结果的。
例如,在运营后台修改了栏目的配置条件,这样栏目下的内容就会发生变化,此时数据库里的数据和缓存就不同步了,这时的逻辑应该是从数据库查询数据,并刷新缓存里的数据
@Redis
注解也支持这种操作,只需要设置 action 属性为 STAB_REDIS
即可
但是,同一个方法,action 要么是 STAB_REDIS
,要么是 REDIS_FIRST
,拦截器只能实现其中一种操作,如何才能让拦截器拦截同一个方法时,实现不同的 redis 操作呢?
有以下几个办法
方法的参数里添加一个专门的变量,用来告诉拦截器做何种操作
复制该方法为另一个方法,2 个方法作用一样,注解也一样,区别是注解的 action 属性不同
同上,但是通过方法名来区别
经过考虑,最终采用了第 2 种办法,如下
public interface AlbumService {
AlbumsVo getCatagoryAlbumsData(int categoryId, int page, int pageNum);
/** 穿透到 db 获取栏目下专辑 **/
AlbumsVo getCatagoryAlbumsDataDirect(int categoryId, int page, int pageNum);
AlbumDetailsData getAlbumDetailsData(int albumId);
/** 穿透到 db 获取专辑详情 **/
AlbumDetailsData getAlbumDetailsDataDirect(int albumId);
}
该接口的实现类如下
@Service("albumService")
public class AlbumServiceImpl implements AlbumService {
@Resource
private AlbumMapper albumMapper;
@Resource
private MetaqMessageSender metaqMessageSender;
private AlbumsVo _getCatagoryAlbumsData(int categoryId, int page, int pageNum) {
int count = albumMapper.getCategoryAlbumsCount(categoryId);
int startIndex = (page - 1) * pageNum;
int offset = pageNum;
List<AlbumListVo> albums = albumMapper.listCategoryAlbums(categoryId, startIndex, offset);
AlbumsVo data = new AlbumsVo();
data.setAlbumList(albums);
data.setPage(page);
if (page * pageNum >= count) {
data.setLast(true);
}
return data;
}
@Override
@Redis(RedisKeys.TV_VIDEO_ALBUM_DETAIL)
public AlbumDetailsData getAlbumDetailsData(int albumId) {
return _getAlbumDetailsData(albumId);
}
@Override
@Redis(action = RedisAction.STAB_REDIS, sync = true, cacheNull = false, value = RedisKeys.TV_VIDEO_ALBUM_DETAIL)
public AlbumDetailsData getAlbumDetailsDataDirect(int albumId) {
return _getAlbumDetailsData(albumId);
}
@Override
@Redis(RedisKeys.TV_VIDEO_CATEGORY_ALBUM_DETAIL)
public AlbumsVo getCatagoryAlbumsData(int categoryId, int page, int pageNum) {
return _getCatagoryAlbumsData(categoryId, page, pageNum);
}
@Override
@Redis(action = RedisAction.STAB_REDIS, sync = true, cacheNull = false, value = RedisKeys.TV_VIDEO_CATEGORY_ALBUM_DETAIL)
public AlbumsVo getCatagoryAlbumsDataDirect(int categoryId, int page, int pageNum) {
return _getCatagoryAlbumsData(categoryId, page, pageNum);
}
}
2 个方法,都是从数据库里查询栏目下的专辑,但其注解的 action 属性不同,就导致一个用于从缓存查询数据,加快访问速度;另一个则适合于用 db 里的数据刷新 redis 的场合
总结
在 spring 配置文件里开启注解式 aop,使用如下配置
<aop:aspectj-autoproxy proxy-target-class="false" />
自定义注解,如下2个元注解必须
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
针对自定义的注解编写拦截器代码并配置拦截器,注意如下几个注解的使用
@Aspect
@Component
@Around("@annotation(******)")
Last updated