本文来自作者 奔跑吧架构师 在 GitChat 上分享 「查询参数缓存从零到一个框架的演进」
编辑 | 哈比
一、页面参数保持
在项目中,查询条件保持是经常使用到的,特别是管理后台。
对于前台页面来说,通常为了访问的方便会使用 get 的方式进行表单提交,这样进行页面分享或者发送给好友时可以直接打开对应的页面。
但是对于管理后台来说,地址栏上的一大串 url 参数是不允许的,不美观也不安全。
比如在用户查询页面,可以根据用户的年龄,姓名,昵称,等等参数进行查询,而且可能客户已经翻到了第 n 页上.
此时点击某个用户详细,页面跳转到用户详细页面对用户信息进行编辑,编辑完成后点击保存,这时候需要返回到用户查询页面上,并且还得回到用户原来页面。
那么可以使用如下的方式:
弹出用户信息页面:好处就是直接可以在页面上编辑,作为弹出层不影响之前查询的条件保持
新开一个页面:这样容易导致页面打开非常多,除非客户强烈要求要这么做。
在当前页面跳转:使用最多的一种方式,因为在管理页面上通常来说是只在一个页面上操作的。但是多级页面跳转后查询条件的保持就是问题了。
前两种方式都比较简单,这里不多赘述了。本文将重点分析第三种需求的实现方式。
1. 保持条件的方法
这里说说可行的几种方法:
将查询页面中的所有参数带到后续所有页面中
这是最简单的方法,也是最累的方法,如果条件少,跳转层级少这种方式是可以使用的,但是如果查询条件一多(通常管理页面查询条件是不少的)或者页面层级跳转多了,这种方式就呵呵了。
保存到 cookie 中
参数少,且安全性不高的数据可以保存到 cookie 中,而且还必须管理好 cookie 的生命周期,其他用户登录时不能获取到之前用户的 cookie 信息。
页面直接跳转的时候也不能将原来的参数带入,只有从查询的页面出去后再回到查询的页面才能使用此 cookie。
将参数缓存到后端,等到返回查询页面时再从缓存中获取
比较推荐这种方式,将信息保存到后端后,生成一个缓存 key,后面的页面只要传递一个 key 值即可。下面详述下这种方式的实现。
我已经分析了几种方式和推荐的实现方式,此时读者可以开始思考该如何实现了。放到 session 还是 cookie,还是其他方式 ….
不管哪种方式,核心都是对查询数据的读取与写入操作,首先整理下流程图如下:
假设有 api:/user/list 表示查询用户信息一览,查询条件有年龄,姓名等等。本文将围绕这个 case 对整个项目结构进行说明。
【简单的做法】,【优化一点的做法】 两章写的是一个简单做法的演进,代码非常简单,并不涉及到本文核心内容,基础稍好点的读者可以根据自己实际情况略过,直接跳到 【设计结构】 这一章节。
2. 简单的做法
程序猿接到需求后就开始写代码,这个功能其实很简单的,只要将查询数据放入 session,在每个需要缓存参数的 Controller 中调用一个共同函数就好。
共同函数的话可以放到共同父类中或者抽取成一个工具类。然后开始编码,代码如下:
Controller 中缓存处理
@RequestMapping("/user/list") public String getUserList(UserListQueryParam param, HttpServletRequest req) { // 参数 useCache 为 true 时,表示需要使用缓存数据 if (param.isUseCache()) { // 从缓存中读取数据 param = QueryParamCacheStoreUtil.getCache(req, "/user/list"); } else { // 将数据放入缓存中 QueryParamCacheStoreUtil.putCache(req, "/user/list", param); } // 其他业务处理 return "/user/list"; }
缓存读取和插入工具类
public class QueryParamCacheStoreUtil { @SuppressWarnings("unchecked") public static <T> T getCache(HttpServletRequest req, String key) { return (T) getSession(req).getAttribute(key); } public static void putCache(HttpServletRequest req, String key, Object param) { getSession(req).setAttribute(key, param); } public static HttpSession getSession(HttpServletRequest req) { return req.getSession(); } }
一个模块完成了,测试 ok 后将这些代码继续复制到其他模块,这样一个缓存功能就这么轻松的实现了。
然后再换个项目,再遇到类似的功能,继续 copy,继续循环。就这样技术水平始终在这个点止步不前,也许 N 个项目后,还会再教新人使用这样的方式继续使用。
长此以往,其他类似的功能也会使用此方式实现,就这样原地踏步。
在这里就是一个分界点了,你是否想过提高下自己?
项目忙,没时间,不要找这些借口,只要有心,时间总会有的。有没有觉得这样使用是不是特别不方便,类似的代码 copy 来 copy 去。
是时候抽取这些代码了,将它们抽象成一个缓存功能的框架。当你走出这一步的时候,就离架构师更近一步了,于是我们开始继续优化。
3. 优化一点的做法
通过简单的做法虽然可以完成需求,但是每个地方都需要写类似这段代码:
// 参数 useCache 为 true 时,表示需要使用缓存数据 if (param.isUseCache()) { // 从缓存中读取数据 param = QueryParamCacheStoreUtil.getCache(req, "/user/list"); } else { // 将数据放入缓存中 QueryParamCacheStoreUtil.putCache(req, "/user/list", param); }
是不是很不优雅,而且后期也不好维护,于是聪明的你就开始优化了。
既然这个代码是共通类似的,那么是否可以将 isUseCache 抽成共通的接口呢?这样的话所有模块的查询都只需要调用一行共通的代码就好了。
于是乎有了如下的代码:
定义好一个共通请求参数父类,所有需要缓存的请求参数都需要继承这个类。
public abstract class CacheableParam { private boolean useCache; //get、set }
用户列表查询参数定义 , 只需要继承 CacheableParam
public class UserListQueryParam extends CacheableParam implements Serializable { private static final long serialVersionUID = 1L; private String name; private Integer age; //get、set }
这样子的话,在 Controller 层中就可以将代码抽出来了,并且将 key 替换为请求的 url,这样在每个 Controller 中只需要完全复制就好了。
@RequestMapping("/user/list") public String getUserList(UserListQueryParam param, HttpServletRequest req) { // 参数 useCache 为 true 时,表示需要使用缓存数据 param = QueryParamCacheStoreUtil.retrieveCacheIfNeed(req, req.getRequestURI().toString(), param); return "/user/list"; } QueryParamCacheStoreUtil 相应的进化为: public class QueryParamCacheStoreUtil { public static <T> T retrieveCacheIfNeed(HttpServletRequest req, String key, T param) { if (param instanceof CacheableParam) { CacheableParam cacheableParam = (CacheableParam) param; if (cacheableParam.isUseCache()) { // 从缓存中读取数据 return QueryParamCacheStoreUtil.getCache(req, key); } // 将数据放入缓存中 QueryParamCacheStoreUtil.putCache(req, key, param); } return param; } @SuppressWarnings("unchecked") public static <T> T getCache(HttpServletRequest req, String key) { return (T) getSession(req).getAttribute(key); } public static void putCache(HttpServletRequest req, String key, Object param) { getSession(req).setAttribute(key, param); } public static HttpSession getSession(HttpServletRequest req) { return req.getSession(); } }
是不是感觉又提升了不少,代码冗余减少了,而且使用更加简单。这样的方式的话,对于大多数项目来说,已经够用了。测试一下:
使用的时候,没有使用缓存,所以会将请求参数缓存起来,下次使用的时候只需要传入参数 userCache 为 true 就可以了,这样就可以把上次查询的参数获取出来了
这样是不是就可以非常轻松的实现了缓存参数的功能,然后等待项目上线。突然有一天快要下班时,客户反馈说怎么我的查询条件在修改用户后返回就没了。
客户给出的步骤:
打开用户列表-> 输入条件,点击查询用户-> 显示用户一览-> 点击某个用户编辑-> 在新的 tab 上打开用户列表页面-> 返回到刚才那个用户编辑的页面点击保存。
于是你一脸黑点地表示客户这个使用不按照常理出牌,但是人家是上帝啊。改改改 … 于是开始查找 bug。一查原来是缓存被覆盖了,目前的功能只能实现页面一次打开缓存,开启多个一样的就出问题了。
既然是缓存被覆盖了,那么每次请求分配一个 key 不就可以了?但是如何保障 key 的唯一呢?使用 UUID 的方式生成不就非常方便了?于是可以把 useCache 参数改为字 cacheKey。
public abstract class CacheableParam { private String cacheKey; public String getCacheKey() { return cacheKey; } public void setCacheKey(String cacheKey) { this.cacheKey = cacheKey; } }
当客户从编辑页面返回的时候将 key 带回来就可以了。如果 cacheKey 为空则 UUID 一个 key 返回。
就这样,客户反馈的问题轻轻松松的解决了,顺手就将项目中的所有使用到此功能的 Controller 都改了,可能是 10 个,也可能是 100 个,没办法,都得改。
接着开始复制代码,一个个修改,代码提交发布。当然还得保证没有改漏掉。
吃过这次亏后,是不是开始反思了。要如何才能更快的修改好呢?于是你开始思考,开始设计一个更好,更简便的框架。
二、设计结构
根据之前的经验得出:
缓存 key 需要全局唯一,使用 UUID 生成
使用要简单,以后修改要简便
数据多的时候缓存到 session 是不是不太好,需要可以拓展缓存实现
key 的生成也需要能够很快的修改,以应变以后变更
用户登出后需要清除缓存
用户自己重新输入 url 后不能使用缓存
最好将功能抽取成一个 lib,方便其他项目使用
根据以上的要求,可以设计出缓存接口 ISearchCache,提供缓存获取,添加与清除的功能。
public interface ISearchCache { /** * 添加如缓存 * * @param key * @param value */ void put(String key, Object value); Object get(String key); void clear(String key); }
key 生成接口,用于缓存 key 的生成:
public interface KeyGenerator { String generateKey(); }
我们只需要拦截需要使用到缓存参数的 Controller 而不是全部的 Controller,所以这里添加注解 @SearchCache 来标注此 Controller 需要缓存参数。
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.PARAMETER) @Documented public @interface SearchCache { Class<? extends ISearchCache> cacheImpl() default SessionSearchCache.class; Class<? extends KeyGenerator> keyGenerator() default UUIDKeyGenerator.class; /** * 请求 key * * @return */ String value() default "cacheToken"; }
cacheImpl:指定缓存的实现类,默认使用的是 SessionSearchCache 作为缓存,可以在使用注解的时候自定义设定缓存实现类,自定义缓存实现类需要实现 ISearchCache 接口。
keyGenerator:缓存 key 生成策略,默认使用的是 UUIDKeyGenerator 即使用 UUID 的方式生成缓存 key,开发者可以自定义 key 生成的方式。
value:缓存的 key,指定请求参数中哪个字段作为缓存 key,并且生成的 key 将保存到 model 中对应的 key。
既然可以缓存参数,就要设计出一定的缓存清理机制,否则的话缓存数量不断累积可能直接拖垮服务器。
本文中将以 SessionId 作为用户全部缓存数据的 key 为例,当用户退出登录或者关闭浏览器后自动清除该用户的缓存。
SessionSearchCache:
@Configuration public class SessionSearchCache implements ISearchCache { private ConcurrentHashMap<String, Map<String, Object>> cacheContainer = new ConcurrentHashMap<>(124); private String getSessionId() { HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); return request.getSession().getId(); } @Override public void put(String key, Object value) { String sessionId = getSessionId(); Map<String, Object> cacheValue = cacheContainer.get(sessionId); if (null == cacheValue) { synchronized (sessionId) { cacheValue = cacheContainer.get(sessionId); if (null == cacheValue) { cacheValue = new ConcurrentHashMap<String, Object>(); cacheContainer.put(sessionId, cacheValue); } } } cacheValue.put(key, value); cacheContainer.put(sessionId, cacheValue); } @Override public Object get(String key) { String sessionId = getSessionId(); Map<String, Object> cacheValue = cacheContainer.get(sessionId); if (null != cacheValue) { return cacheValue.get(key); } return null; } @Override public void clear(String key) { cacheContainer.remove(key); } }
SessionCacheListener 类实现 Session 开启和销毁监听,当 Session 销毁时自动清除对应的用户缓存。
public class SessionCacheListener implements HttpSessionListener { private SessionSearchCache sessionCache; public SessionCacheListener(SessionSearchCache sessionCache) { super(); this.sessionCache = sessionCache; } @Override public void sessionCreated(HttpSessionEvent se) { } @Override public void sessionDestroyed(HttpSessionEvent se) { sessionCache.clear(se.getSession().getId()); } }
对应的 UML 图如下:
缓存的管理和 key 的配置已经设计完成,接下来该设计缓存数据的入口了。
扫描下方二维码
阅读完整原文
并与作者交流