theme: vue-pro
highlight: atom-one-dark-reasonable
先来看看业务场景
首先咱们来思考一下为什么会有Top热榜这个功能,这个业务给搜索带来了哪些好处,想想如果是我们该怎么实现Top榜单这个业务?
假如,我们现在做的是一个搜索相关的项目,在项目初期数据量寥寥无几。一方面可以手动导入数据,但是这样并不能非常精准的将用户所需要的数据给入库,如果有一个地方,我们可以看到用户搜索的内容,那么按需入库这样问题不就迎刃而解了嘛!(说的那么容易,你到底是来写呀)相信你一定使用过Spring-AOP动态代理技术吧。哪像这样不就是一个环绕切面,存入数据库吗?
于是乎,一号选手就位,开始coding。扒拉扒拉的一顿猛敲,什么install、update,噼里啪啦,最终把上面说提及到的AOP写完。emmmm...经过一段比较长的时间用户访问量不断增加数据库终于不堪重负,歇菜...好家伙,这月绩效奖都要被扣完了。
二号选手看见一号选手写的什么辣鸡代码,把库给搞炸了,于是便有了缓存操作。咸蛋少扯,啊呸你以为我是那个咸蛋超人那
下面直接贴代码教他怎么做人
Hutool缓存工具类TimedCache
我这里主要提供一种通用的思路,你可以通过该思路来完成如上所说的业务。当然你可以把他看成一个轻量级的缓存计数器实现,以及在框架不能满足特定要求的情况下,怎么利用继承重写的方式去扩展框架之外不能完成的事情。说了那么多,还不把你代码贴出来,渣男!渣男!渣男!哈呸...
程序的入口-Spring-AOP
/**
* 搜索日志Aop
*
* @author youyongkun
* @date 2021/5/17 14:47
*/
@Aspect
@Component
public class SearchLogAspect {
/**
* 日志缓存
*
* @author youyongkun
* @date 2021/5/17 14:55
*/
@Autowired
private SearchLogTimedCache searchLogTimedCache;
/**
* 选择题搜索切点
*/
@Pointcut("execution(* cn.akwangl.service.question.IChoiceQuestionService.match(..))")
public void questionSearch() {
}
/**
* 4.方法执行前后调用
*
* @param joinPoint
* @return
* @throws Throwable
*/
@Around("questionSearch()")
public Object doBeforeAdvice(ProceedingJoinPoint joinPoint) throws Throwable {
//获取目标方法的参数信息
Object[] args = joinPoint.getArgs();
String key = (String) args[0];
SearchLog searchLog = searchLogTimedCache.get(key);
if (searchLog == null) {
searchLog = new SearchLog();
searchLog.setKeyWorld(key);
searchLog.setSearchNum(1L);
searchLogTimedCache.put(key, searchLog);
} else {
searchLog.setSearchNum(searchLog.getSearchNum() + 1);
}
Object result = joinPoint.proceed(args);
if (result != null && ((ArrayList) result).size() > 0) {
searchLog.setIsHit(YesOrNO.YES.getType());
} else {
searchLog.setIsHit(YesOrNO.NO.getType());
}
return result;
}
}
SearchLogAspect
类主要在questionSearch
切点进行业务keyworld
关键字记数以及根据返回接口来标记搜索是否命中。在这个SearchLogAspect中有一个searchLogTimedCache
成员变量,今天就是基于hutool#TimedCache
这个类来实现前端用户搜索框keyworld
记数
为什么一定要用TimedCache来完成这个业务
首先,通过Spring-AOP直接访问数据库是可以完成计数功能。但是这样频繁读写数据库,而业务本身只是一个类似计数器的功能,像这样频繁读写数据库完全没必要,我们可以把100次的写入操作变成一次,来减少数据库压力。那么使用缓存来做计数,那问题又来了,那JVM缓存写满了怎么办?正如TimedCache
这个类名字,我们可以设定在什么时候自动的让缓存的数据入库并清除缓存。
感兴趣的小伙伴可以cn.hutool.cache.impl.TimedCache
查看这个类的源码,代码的主程序是在cn.hutool.cache.impl.AbstractCache
抽象类中。
首先我们来分析一下TimedCache什么时候会清除缓存
在TimedCache
的父类(cn.hutool.cache.impl.AbstractCache)中可以看到有一个抽象方法,这个抽象方法的实现就是用来清除缓存数据,他的调用在cn.hutool.cache.impl.AbstractCache#prune
方法中,该方法是线程安全的
/**
* 清理实现<br>
* 子类实现此方法时无需加锁
*
* @return 清理数
*/
protected abstract int pruneCache();
怎样才能在清除的时候将数据入库呢?
找了找cn.hutool.cache.impl.TimedCache#pruneCache()
实现,并没有看到有相关的回调函数,那么我们只能自己继承cn.hutool.cache.impl.TimedCache
类,重写pruneCache()
方法加入自己的逻辑
根据Java方法重载的就近原则,当有两个
pruneCache()
一样的方法时,优先使用自己的
于是乎代码就是下面这个样子
/**
* 对hutool 缓存清除功能扩展
*
* @author youyongkun
* @date 2021/5/16 9:49 下午
*/
@Slf4j
public abstract class AbstractTimedCache<K, V> extends cn.hutool.cache.impl.TimedCache<K, V> {
/**
* 默认过期时间 30分钟
*/
protected static long DEFAULT_TIMEOUT = (60 * 1000) * 30;
public AbstractTimedCache() {
super((long) ((DEFAULT_TIMEOUT * 0.7) + DEFAULT_TIMEOUT));
}
public AbstractTimedCache(long timeout) {
super(timeout);
}
public AbstractTimedCache(long timeout, Map map) {
super(timeout, map);
}
/**
* 保存日志信息
*
* @param logsMap 日志集合
* @return true:成功 false:失败
* @author youyongkun
* @date 2021/5/17 13:56
*/
protected abstract boolean save(Map<K, V> logsMap);
@Override
protected int pruneCache() {
Map<K, V> logsMap = new HashMap();
int count = 0;
Iterator<CacheObj<K, V>> values = cacheMap.values().iterator();
CacheObj<K, V> co;
while (values.hasNext()) {
co = values.next();
try {
Method isExpired = co.getClass().getDeclaredMethod("isExpired");
isExpired.setAccessible(true);
boolean flag = (boolean) isExpired.invoke(co, null);
if (flag) {
logsMap.put(co.getKey(), co.getValue());
values.remove();
onRemove(co.getKey(), co.getValue());
count++;
}
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (Exception e) {
e.printStackTrace();
}
}
// 回调入库
if (logsMap.size() > 0) {
boolean saveFlag = save(logsMap);
if (saveFlag) {
log.debug("缓存日志入库成功,受影响{}条数,完整数据{}", logsMap.size(), logsMap);
} else {
log.debug("缓存日志入库失败,完整数据{}", logsMap);
}
}
return count;
}
}
在这里我并没有把入库的业务逻辑直接写入到pruneCache()
方法中,而是在pruneCache()
添加了一个save()
的抽象方法,这样做的目的是提高代码的复用性。这里需要注意的是在判断CacheObj
是否过期时,cn.hutool.cache.impl.TimedCache#pruneCache()
源码中是直接使用cn.hutool.cache.impl.CacheObj#isExpired()
方法进行判断,而这个方法的访问权限是默认的,因此我使用Java 反射来调用cn.hutool.cache.impl.CacheObj#isExpired()
方法该问题已反馈给作者
像很多优秀的框架在实际编码过程中发现不能满足自身要求的时候,可以使用继承重写的方式来解决实际业务中的问题
下面是业务相关的具体实现,你可以参考我的源码,
/**
* 搜索日志缓存
*
* @author youyongkun
* @date 2021/5/17 14:05
*/
@Slf4j
@Component
public class SearchLogTimedCache extends AbstractTimedCache<String, SearchLog> {
@PostConstruct
public void init() {
this.schedulePrune(DEFAULT_TIMEOUT);
}
@PreDestroy
public void destroy() {
this.clean();
}
@Autowired
private ISearchLogService iSearchLogService;
@Autowired
private Sid sid;
@Override
protected boolean save(Map<String, SearchLog> logsMap) {
Set<String> keys = logsMap.keySet();
Map<String, SearchLog> keywordMap = iSearchLogService.getByKeywordToMap(keys);
List<SearchLog> saveList = new ArrayList();
List<SearchLog> updateList = new ArrayList();
keys.forEach(key -> {
SearchLog searchLog = keywordMap == null ? null : keywordMap.get(key);
SearchLog cacheSearchLog = logsMap.get(key);
if (searchLog == null) {
searchLog = new SearchLog();
searchLog.setId(sid.nextShort());
searchLog.setKeyWorld(key);
searchLog.setSearchNum(cacheSearchLog.getSearchNum());
searchLog.setIsHit(cacheSearchLog.getIsHit());
searchLog.setUpdatedTime(LocalDateTime.now());
searchLog.setCreatedTime(LocalDateTime.now());
saveList.add(searchLog);
} else {
searchLog.setSearchNum(searchLog.getSearchNum() + cacheSearchLog.getSearchNum());
searchLog.setIsHit(cacheSearchLog.getIsHit());
searchLog.setUpdatedTime(LocalDateTime.now());
updateList.add(searchLog);
}
});
// 入库
int result = 0;
if (saveList != null && saveList.size() > 0) {
if (iSearchLogService.saveBatch(saveList)) {
result += 1;
}
} else {
result += 1;
}
if (updateList != null && updateList.size() > 0) {
if (iSearchLogService.updateBatchById(updateList)) {
result += 1;
}
} else {
result += 1;
}
return result == 2;
}
/**
* 清除日志
*
* @author youyongkun
* @date 2021/5/17 16:20
*/
public void clean() {
Map<String, SearchLog> logsMap = new HashMap(cacheMap.size() + 1);
synchronized (super.cacheMap){
Iterator<CacheObj<String, SearchLog>> iterator = super.cacheMap.values().iterator();
while (iterator.hasNext()) {
CacheObj<String, SearchLog> cacheObj = iterator.next();
logsMap.put(cacheObj.getKey(), cacheObj.getValue());
iterator.remove();
}
}
if (logsMap.size() > 0) {
if (this.save(logsMap)) {
log.info("缓存日志入库成功,受影响{}条数,完整数据{}", logsMap.size(), logsMap);
} else {
log.info("缓存日志入库失败,完整数据{}", logsMap);
}
}
}
}
这里的clean()
方法是通过synchronized
关键字来保证线程安全问题。在实际生产环境中,你无法保证在key的有效时间内你的缓存不被塞满,所以需要一个WEB API来使用清除。这就是要使用synchronized
关键字的原因。
在使用synchronized
关键字来确保线程安全之前,我尝试过使用Mybatis反射工具类org.apache.ibatis.reflection.SystemMetaObject#forObject()
来直接获取cn.hutool.cache.impl.AbstractCache#lock
锁,当然这是失败的。为什么会失败呢?是因为lock
字段没有提供getter
方法,而MyBatis反射工具类只允许属性字段有getter/setter
方法才能被访问,那为什么MyBatis会有这样的限制呢?是因为当你使用Java反射直接去拿字段的Filed对象,你是无法将Filed对象转换为原本字段对应的数据类型。所以JavaBean
开发规范就是这么来的。