查询参数缓存从零到一个框架的演进

查询参数缓存从零到一个框架的演进

本文来自作者 奔跑吧架构师  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 的配置已经设计完成,接下来该设计缓存数据的入口了。

扫描下方二维码

阅读完整原文

并与作者交流

查询参数缓存从零到一个框架的演进