转载

spring cache 接口层缓存的演进过程

在spring 体系中,使用spring cache并结合redis来进行数据缓存是很常见的做法。不过,针对于具体的业务场景,可能会有不同的处理方法。

像以下的1个业务场景,即有不同的处理方式。

前端访问后端的指定请求路径(GET类请求), 针对特定的条件下(对应cache condition),希望这个结果能够被缓存.同时,支持当资源修改之后,让此缓存失效掉。

此场景的典型方案就是使用spring cache的 @Cacheable 注解 和 @CacheEvict 注解,并结合实际场景进行混合处理。

在这个实际的场景当中,经历了 service层缓存,Controller层缓存,和Filter层缓存三个阶段,最终达到业务的需求,并且在性能上更接近于实际的需要。

本文就里面碰到的一些实际问题以及解决方式进行了简单描述,从复杂的框架层修改到简单的拦截处理,在思考思路上进行一个分享。

前提

本文中使用的序列化为jackson,即使用jackson将对象序列化为 json 字符串,再getBytes为 字节数组.

Service层缓存

service层cache即通过标准的spring cache 注解,在相应的方法上追加上注解信息,然后利用CacheInterceptor的拦截能力,拦截调用方法,当满足缓存条件时,将相应的结果对象序列化。在这个场景中需要处理的主要问题就是与业务之间的链接问题。

从理论上看,调用一个service,不管结果是从代码执行出来的还是从缓存出来的,那么如果结果内容是相同的,则并没有相应的区别。但如果内容不一样,则这个缓存本身就会有相应的问题。在这个场景中,结果对象中的对象体系由于之前对前端的特殊处理,导致按照正常的序列化情况,可能有些数据就不能正常的反序列化回来。如下的例子:

class VersionedObj {
    @JsonIgnore
    @Version
    long version;
}

字段 version 在对于前端的响应中是不需要的,因此这里将其设定为version,但对于后端来说(用于JPA的版本化支持),这个字段又是需要的。在调用相应的方法时,期望的结果中是需要此字段值。如果使用标准的jackson objectMapper,这个字段的序列化即是一个问题。在实际处理中,可以使用 Jackson Mixin 概念来解决这个问题。

回头看一下应用场景,因为service方法可以在业务中的另1个service调用,也可以由controller来调用。但在应用场景中,期望仅通过controller来调用的方法走缓存,而是内部的调用则仍然调用原始的实现。即缓存的应用场景是基于前端访问的,在这里service的缓存控制仍然显得靠后了一点。

Controller层缓存

前移之后,需要处理2个问题。

1个是有一些公共的Controller是不可简单的在方法上加注解的。如类 RepositoryEntityController(spring data rest中)。这个类由spring 体系提供,但在这个类中的findById场景中,受缓存的影响最大,我们期望这个接口的结果是可缓存的,但不能在方法上加注解,因此cache切面不能正常工作。

此问题的处理方式是调整相应的 RepositoryRestHandlerAdapter 处理,在标准的spring mvc 中,调用controller均是通过类 ServletInvocableHandlerMethod#invokeAndHandle 来完成。因此可以通过 子类化 RepositoryRestHandlerAdapter,然后提供一个新的 ServletInvocableHandlerMethod 实例来完成相应的controller调用来处理。在 ServletInvocableHandlerMethod 子类中,可以手动来实现 cacheInterceptor的能力,即先 condition判定,cacheKey生成,原方法调用,写入redis 这几个步骤. 在具体实现上,相应的api尽量与原生cache 相一致。比如,写入redis这一步,仍然可以通过 获取到一个有效的 Cache 实例,然后再调用 cache#put 来完成相应数据的写入.

另1个问题则是反序列化问题,使用spring data (或hateos) 时,针对结果如类 ResponseEntity Resources, 其反序列化简直是噩梦。大量的final字段,以及不提供setter/getter。同时,官方实现也没有deserializer,整个过程真是麻烦。同时,在controller层的结果还并不是最终结果,可能还会在后续的处理流程中进行调整。这就导致在controller层作的缓存数据并不能直接落到前端,还要再处理一次。此问题的解决可以通过将前面写入redis的操作后移,通过注入一个 ResponseBodyAdvice 来完成此操作。在前一步中,记一个相应的标记。在advice中,如果判定需要作cache 处理,则在这里才将相应的数据(已经经过最终处理后)落到redis中。

controller层缓存的另1个问题则是当读取redis数据之后,将其反序列化为结果对象,但马上此数据即要重新被jackson序列化为 字符串返回给前端。在这个过程中,中间的一个反序列化和序列化是完全没有必要的。并且在前面的步骤中,为解决序列化的问题对框架层改动太多。

Filter层缓存

从应用场景来看,其实并不完全需要spring-cache的整个功能,而是仅仅需要一个类似nginx cache的一个模块。 同时,支持缓存evict的能力即可。

因此,相应的实现方案即通过一个filter,拦截调用请求,针对需要作缓存的请求,尝试拦截后端对响应的处理。如果需要缓存,则将响应的结果(即outputStream中的数据)写入缓存。下次,相同的请求即直接从缓存中读取数据,直接写回response即可。这样在下一次请求时,整个spring的servlet层直接全部跳过,从时序上来看,请求在 filter 层即已经完成整个处理,都不需要spring的mapping过程。

在具体实现过程中,condition的处理需要从请求中重新进行设计,因为在这一层中,只能拿到 路径,请求信息,请求头这些标准的数据信息,还不能到达对象体系的目的。不过对于业务来说,不同的路径,其在业务层的condition条件是已知的,可以通过 spring el 在这一层作一些脚本化的判断,也是可以工作的。

总结

整个过程,其实是对需求的不断调整和改进,如果一开始就套用技术,可能会初期满足业务需求,但总会有一定的限制和缺陷。不断地改进过程,对应用技术本身的了解就会越清晰。

原文  https://www.iflym.com/index.php/code/201905260001.html
正文到此结束
Loading...