基于注解的声明式缓存

对于缓存声明,Infra 缓存抽象提供了一组 Java 注解:

  • @Cacheable:触发缓存填充。

  • @CacheEvict:触发缓存驱逐。

  • @CachePut:在不干扰方法执行的情况下更新缓存。

  • @Caching:重新组合要应用于方法的多个缓存操作。

  • @CacheConfig:在类级别共享一些常见的缓存相关设置。

@Cacheable 注解

顾名思义,您可以使用 @Cacheable 来划分可缓存的方法——即结果存储在缓存中的方法,以便在随后的调用(使用相同的参数)中,直接返回缓存中的值,而无需实际调用该方法。在最简单的形式中,注解声明需要与注解方法关联的缓存名称,如下例所示:

@Cacheable("books")
public Book findBook(ISBN isbn) {...}

在前面的片段中,findBook 方法与名为 books 的缓存相关联。每次调用该方法时,都会检查缓存以查看该调用是否已经运行过且无需重复。虽然在大多数情况下只声明一个缓存,但该注解允许指定多个名称,以便使用多个缓存。在这种情况下,在调用方法之前会检查每个缓存——如果至少有一个缓存命中,则返回关联的值。

所有其他不包含该值的缓存也会被更新,即使缓存的方法实际上没有被调用。

以下示例在 findBook 方法上使用 @Cacheable 并指定多个缓存:

@Cacheable({"books", "isbns"})
public Book findBook(ISBN isbn) {...}

默认键生成

由于缓存本质上是键值存储,因此缓存方法的每次调用都需要转换为合适的键以进行缓存访问。缓存抽象使用基于以下算法的简单 KeyGenerator

  • 如果没有给出参数,则返回 SimpleKey.EMPTY

  • 如果只给出一个参数,则返回该实例。

  • 如果给出多个参数,则返回包含所有参数的 SimpleKey

这种方法适用于大多数用例,只要参数具有自然键并实现有效的 hashCode()equals() 方法即可。如果情况并非如此,则需要更改策略。

要提供不同的默认键生成器,您需要实现 infra.cache.interceptor.KeyGenerator 接口。

自定义键生成声明

由于缓存是通用的,目标方法很可能具有各种无法直接映射到缓存结构之上的签名。当目标方法有多个参数,其中只有部分参数适合缓存(而其余参数仅由方法逻辑使用)时,这一点往往变得显而易见。考虑以下示例:

@Cacheable("books")
public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed);

乍一看,虽然这两个 boolean 参数影响查找书籍的方式,但它们对缓存没有用处。此外,如果两个参数中只有一个重要而另一个不重要怎么办?

对于此类情况,@Cacheable 注解允许您通过其 key 属性指定如何生成键。您可以使用 SpEL 来选取感兴趣的参数(或其嵌套属性)、执行操作,甚至调用任意方法,而无需编写任何代码或实现任何接口。这是推荐的方法,优于 默认生成器, 因为随着代码库的增长,方法的签名往往会有很大差异。虽然默认策略可能适用于某些方法,但它很少适用于所有方法。

以下示例使用各种 SpEL 声明(如果您不熟悉 SpEL,请务必阅读 Infra 表达式语言):

@Cacheable(cacheNames="books", key="#isbn")
public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)

@Cacheable(cacheNames="books", key="#isbn.rawNumber")
public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)

@Cacheable(cacheNames="books", key="T(someType).hash(#isbn)")
public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)

前面的片段展示了选择特定参数、其属性之一甚至任意(静态)方法是多么容易。

如果负责生成键的算法过于特定,或者如果需要共享该算法,则可以在操作上定义自定义 keyGenerator。为此,请指定要使用的 KeyGenerator bean 实现的名称,如下例所示:

@Cacheable(cacheNames="books", keyGenerator="myKeyGenerator")
public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)
keykeyGenerator 参数是互斥的,同时指定两者的操作会导致异常。

默认缓存解析

缓存抽象使用一个简单的 CacheResolver,它通过使用配置的 CacheManager 来检索在操作级别定义的缓存。

要提供不同的默认缓存解析器,您需要实现 infra.cache.interceptor.CacheResolver 接口。

自定义缓存解析

默认的缓存解析非常适合使用单个 CacheManager 且没有复杂缓存解析要求的应用程序。

对于使用多个缓存管理器的应用程序,您可以设置每个操作要使用的 cacheManager,如下例所示:

@Cacheable(cacheNames="books", cacheManager="anotherCacheManager") (1)
public Book findBook(ISBN isbn) {...}
1 指定 anotherCacheManager

您还可以以类似于替换 键生成 的方式完全替换 CacheResolver。 每个缓存操作都会请求解析,从而允许实现根据运行时参数实际解析要使用的缓存。以下示例显示了如何指定 CacheResolver

@Cacheable(cacheResolver="runtimeCacheResolver") (1)
public Book findBook(ISBN isbn) {...}
1 指定 CacheResolver

从 Infra 4.1 开始,缓存注解的 value 属性不再是强制性的,因为无论注解的内容如何,此特定信息都可以由 CacheResolver 提供。

keykeyGenerator 类似,cacheManagercacheResolver 参数是互斥的,同时指定两者的操作会导致异常,因为自定义 CacheManager 会被 CacheResolver 实现忽略。这可能不是您所期望的。

同步缓存

在多线程环境中,某些操作可能会针对相同的参数并发调用(通常在启动时)。默认情况下,缓存抽象不锁定任何内容,并且相同的值可能会被计算多次,从而破坏了缓存的目的。

对于这些特定情况,您可以使用 sync 属性来指示底层缓存提供程序在计算值时锁定缓存条目。结果是,只有一个线程忙于计算值,而其他线程则被阻塞,直到缓存中的条目更新。以下示例显示了如何使用 sync 属性:

@Cacheable(cacheNames="foos", sync=true) (1)
public Foo executeExpensiveOperation(String id) {...}
1 使用 sync 属性。
这是一个可选功能,您喜欢的缓存库可能不支持它。核心框架提供的所有 CacheManager 实现都支持它。有关更多详细信息,请参阅缓存提供程序的文档。

使用 CompletableFuture 和响应式返回类型进行缓存

从 6.1 开始,缓存注解将 CompletableFuture 和响应式返回类型纳入考量,自动相应地调整缓存交互。

对于返回 CompletableFuture 的方法,该 future 生成的对象将在完成时被缓存,并且缓存命中的缓存查找将通过 CompletableFuture 检索:

@Cacheable("books")
public CompletableFuture<Book> findBook(ISBN isbn) {...}

对于返回 Reactor Mono 的方法,该 Reactive Streams 发布者发出的对象将在可用时被缓存,并且缓存命中的缓存查找将作为 Mono(由 CompletableFuture 支持)检索:

@Cacheable("books")
public Mono<Book> findBook(ISBN isbn) {...}

对于返回 Reactor Flux 的方法,该 Reactive Streams 发布者发出的对象将被收集到 List 中,并在该列表完成时被缓存,并且缓存命中的缓存查找将作为 Flux(由缓存 List 值的 CompletableFuture 支持)检索:

@Cacheable("books")
public Flux<Book> findBooks(String author) {...}

这种 CompletableFuture 和响应式适配也适用于同步缓存,在并发缓存未命中的情况下仅计算一次值:

@Cacheable(cacheNames="foos", sync=true) (1)
public CompletableFuture<Foo> executeExpensiveOperation(String id) {...}
1 使用 sync 属性。
为了使这种安排在运行时起作用,配置的缓存需要能够进行基于 CompletableFuture 的检索。Infra 提供的 ConcurrentMapCacheManager 自动适应这种检索风格,而 CaffeineCacheManager 在启用异步缓存模式时原生支持它:在您的 CaffeineCacheManager 实例上设置 setAsyncCacheMode(true)
@Bean
CacheManager cacheManager() {
  CaffeineCacheManager cacheManager = new CaffeineCacheManager();
  cacheManager.setCacheSpecification(...);
  cacheManager.setAsyncCacheMode(true);
  return cacheManager;
}

最后但同样重要的一点是,请注意,注解驱动的缓存不适用于涉及组合和背压的复杂响应式交互。如果您选择在特定响应式方法上声明 @Cacheable,请考虑相当粗粒度的缓存交互的影响,该交互仅存储 Mono 发出的对象,甚至存储 Flux 的预收集对象列表。

条件缓存

有时,方法可能并不总是适合缓存(例如,它可能取决于给定的参数)。缓存注解通过 condition 参数支持此类用例,该参数接受一个 SpEL 表达式,该表达式的计算结果为 truefalse。如果是 true,则缓存该方法。如果不是,则其行为就像该方法未缓存一样(即,无论缓存中有什么值或使用什么参数,每次都会调用该方法)。例如,仅当参数 name 的长度小于 32 时,才会缓存以下方法:

@Cacheable(cacheNames="book", condition="#name.length() < 32") (1)
public Book findBook(String name)
1 @Cacheable 上设置条件。

除了 condition 参数之外,您还可以使用 unless 参数来否决将值添加到缓存中。与 condition 不同,unless 表达式在方法调用后进行计算。为了扩展前面的示例,也许我们只想缓存平装书,如下例所示:

@Cacheable(cacheNames="book", condition="#name.length() < 32", unless="#result.hardback") (1)
public Book findBook(String name)
1 使用 unless 属性阻止精装书。

缓存抽象支持 java.util.Optional 返回类型。如果 Optional 值_存在_,它将存储在关联的缓存中。如果 Optional 值不存在,则 null 将存储在关联的缓存中。#result 始终指代业务实体,而绝不是支持的包装器,因此前面的示例可以重写如下:

@Cacheable(cacheNames="book", condition="#name.length() < 32", unless="#result?.hardback")
public Optional<Book> findBook(String name)

请注意,#result 仍然指代 Book 而不是 Optional<Book>。因为它可能是 null,所以我们使用 SpEL 的 安全导航运算符

可用的缓存 SpEL 评估上下文

每个 SpEL 表达式都针对专用的 context 进行评估。除了内置参数外,框架还提供专用的缓存相关元数据,例如参数名称。下表描述了上下文中可用的项目,以便您可以使用它们进行键和条件计算:

Table 1. 缓存 SpEL 可用元数据
名称 位置 描述 示例

methodName

根对象

被调用的方法名称

#root.methodName

method

根对象

被调用的方法

#root.method.name

target

根对象

被调用的目标对象

#root.target

targetClass

根对象

被调用的目标类

#root.targetClass

args

根对象

用于调用目标的参数(作为数组)

#root.args[0]

caches

根对象

当前方法针对其运行的缓存集合

#root.caches[0].name

参数名称

评估上下文

任何方法参数的名称。如果名称不可用(可能是由于没有调试信息),则参数名称也可以在 #a<#arg> 下获得,其中 #arg 代表参数索引(从 0 开始)。

#iban#a0 (您也可以使用 #p0#p<#arg> 表示法作为别名)。

result

评估上下文

方法调用的结果(要缓存的值)。仅在 unless 表达式、cache put 表达式(用于计算 key)或 cache evict 表达式(当 beforeInvocationfalse 时)中可用。对于支持的包装器(如 Optional),#result 指的是实际对象,而不是包装器。

#result

@CachePut 注解

当需要在不干扰方法执行的情况下更新缓存时,可以使用 @CachePut 注解。即,始终调用该方法,并将其结果放入缓存中(根据 @CachePut 选项)。它支持与 @Cacheable 相同的选项,应用于缓存填充而不是方法流优化。以下示例使用 @CachePut 注解:

@CachePut(cacheNames="book", key="#isbn")
public Book updateBook(ISBN isbn, BookDescriptor descriptor)
通常强烈建议不要在同一方法上使用 @CachePut@Cacheable 注解,因为它们具有不同的行为。后者通过使用缓存导致跳过方法调用,而前者为了运行缓存更新而强制调用。这会导致意想不到的行为,除了特定的边缘情况(例如具有将它们彼此排除的条件的注解)之外,应避免此类声明。另请注意,此类条件不应依赖于结果对象(即 #result 变量),因为这些是在前期验证以确认排除的。

从 6.1 开始,@CachePutCompletableFuture 和响应式返回类型纳入考量,只要生成的对象可用,就执行放置操作。

@CacheEvict 注解

缓存抽象不仅允许填充缓存存储,还允许驱逐。此过程对于从缓存中删除过时或未使用的数据很有用。与 @Cacheable 相反,@CacheEvict 划分了执行缓存驱逐的方法(即,充当从缓存中删除数据的触发器的方法)。与其兄弟类似,@CacheEvict 需要指定受操作影响的一个或多个缓存,允许指定自定义缓存和键解析或条件,并具有一个额外的参数 (allEntries),该参数指示是否需要执行范围广泛的缓存驱逐,而不仅仅是条目驱逐(基于键)。以下示例驱逐 books 缓存中的所有条目:

@CacheEvict(cacheNames="books", allEntries=true) (1)
public void loadBooks(InputStream batch)
1 使用 allEntries 属性驱逐缓存中的所有条目。

当需要清除整个缓存区域时,此选项非常方便。如前面的示例所示,所有条目都在一个操作中删除,而不是驱逐每个条目(这会花费很长时间,因为它效率低下)。请注意,框架会忽略在此场景中指定的任何键,因为它不适用(整个缓存被驱逐,而不仅仅是一个条目)。

您还可以通过使用 beforeInvocation 属性指示驱逐是在方法调用之后(默认)还是之前发生。前者提供与其余注解相同的语义:一旦方法成功完成,就会运行缓存上的操作(在本例中为驱逐)。如果方法不运行(因为它可能已被缓存)或抛出异常,则不会发生驱逐。后者(beforeInvocation=true)导致驱逐始终在方法调用之前发生。这在驱逐不需要与方法结果相关联的情况下很有用。

请注意,void 方法可以与 @CacheEvict 一起使用 - 由于方法充当触发器,因此返回值被忽略(因为它们不与缓存交互)。@Cacheable 的情况并非如此,它将数据添加到缓存或更新缓存中的数据,因此需要结果。

从 6.1 开始,@CacheEvictCompletableFuture 和响应式返回类型纳入考量,只要处理完成,就执行调用后驱逐操作。

@Caching 注解

有时,需要指定相同类型的多个注解(例如 @CacheEvict@CachePut)——例如,因为不同缓存之间的条件或键表达式不同。@Caching 允许在同一方法上使用多个嵌套的 @Cacheable@CachePut@CacheEvict 注解。以下示例使用两个 @CacheEvict 注解:

@Caching(evict = { @CacheEvict("primary"), @CacheEvict(cacheNames="secondary", key="#p0") })
public Book importBooks(String deposit, Date date)

@CacheConfig 注解

到目前为止,我们已经看到缓存操作提供了许多自定义选项,并且您可以为每个操作设置这些选项。但是,如果某些自定义选项适用于类的所有操作,则配置起来可能会很繁琐。例如,为类的每个缓存操作指定要使用的缓存名称可以用单个类级别定义替换。这就是 @CacheConfig 发挥作用的地方。以下示例使用 @CacheConfig 设置缓存名称:

@CacheConfig("books") (1)
public class BookRepositoryImpl implements BookRepository {

  @Cacheable
  public Book findBook(ISBN isbn) {...}
}
1 使用 @CacheConfig 设置缓存名称。

@CacheConfig 是一个类级注解,允许共享缓存名称、自定义 KeyGenerator、自定义 CacheManager 和自定义 CacheResolver。将此注解放在类上不会开启任何缓存操作。

操作级别的自定义始终覆盖 @CacheConfig 上设置的自定义。因此,这为每个缓存操作提供了三个级别的自定义:

  • 全局配置,例如通过 CachingConfigurer:见下一节。

  • 在类级别,使用 @CacheConfig

  • 在操作级别。

特定于提供程序的设置通常在 CacheManager bean 上可用,例如在 CaffeineCacheManager 上。这些实际上也是全局的。

启用缓存注解

重要的是要注意,即使声明了缓存注解也不会自动触发它们的操作 - 就像 Infra 中的许多事情一样,该功能必须以声明方式启用(这意味着如果您怀疑缓存是罪魁祸首,您可以通过仅删除一行配置而不是代码中的所有注解来禁用它)。

要启用缓存注解,请将注解 @EnableCaching 添加到您的一个 @Configuration 类中:

@Configuration
@EnableCaching
public class AppConfig {

  @Bean
  CacheManager cacheManager() {
    CaffeineCacheManager cacheManager = new CaffeineCacheManager();
    cacheManager.setCacheSpecification(...);
    return cacheManager;
  }
}

或者,对于 XML 配置,您可以使用 cache:annotation-driven 元素:

<beans xmlns="http://www.springframework.org/schema/beans"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xmlns:cache="http://www.springframework.org/schema/cache"
  xsi:schemaLocation="
    http://www.springframework.org/schema/beans https://www.springframework.org/schema/beans/spring-beans.xsd
    http://www.springframework.org/schema/cache https://www.springframework.org/schema/cache/spring-cache.xsd">

  <cache:annotation-driven/>

  <bean id="cacheManager" class="infra.cache.caffeine.CaffeineCacheManager">
    <property name="cacheSpecification" value="..."/>
  </bean>
</beans>

cache:annotation-driven 元素和 @EnableCaching 注解都允许您指定各种选项,这些选项会影响通过 AOP 添加到应用程序的缓存行为的方式。该配置故意与 @Transactional 相似。

处理缓存注解的默认建议模式是 proxy,它仅允许通过代理拦截调用。同一类中的本地调用无法以这种方式被拦截。对于更高级的拦截模式,请考虑切换到 aspectj 模式并结合编译时或加载时编织。
有关实现 CachingConfigurer 所需的高级自定义(使用 Java 配置)的更多详细信息,请参阅 javadoc
Table 2. 缓存注解设置
XML 属性 注解属性 默认值 描述

cache-manager

N/A (see the CachingConfigurer javadoc)

cacheManager

要使用的缓存管理器的名称。默认的 CacheResolver 在幕后使用此缓存管理器(如果未设置,则为 cacheManager)进行初始化。为了对缓存解析进行更细粒度的管理,请考虑设置 'cache-resolver' 属性。

cache-resolver

N/A (see the CachingConfigurer javadoc)

使用配置的 cacheManagerSimpleCacheResolver

用于解析支持缓存的 CacheResolver 的 bean 名称。此属性不是必需的,仅在作为 'cache-manager' 属性的替代方案时才需要指定。

key-generator

N/A (see the CachingConfigurer javadoc)

SimpleKeyGenerator

要使用的自定义键生成器的名称。

error-handler

N/A (see the CachingConfigurer javadoc)

SimpleCacheErrorHandler

要使用的自定义缓存错误处理程序的名称。默认情况下,在缓存相关操作期间引发的任何异常都会抛回给客户端。

mode

mode

proxy

默认模式 (proxy) 使用 Infra AOP 框架处理要代理的注解 bean(遵循代理语义,如前所述,仅适用于通过代理传入的方法调用)。替代模式 (aspectj) 改为使用 Infra AspectJ 缓存切面编织受影响的类,修改目标类字节码以应用于任何类型的方法调用。AspectJ 编织需要类路径中有 spring-aspects.jar,并且启用了加载时编织(或编译时编织)。(有关如何设置加载时编织的详细信息,请参阅 Infra 配置。)

proxy-target-class

proxyTargetClass

false

仅适用于代理模式。控制为使用 @Cacheable@CacheEvict 注解的类创建什么类型的缓存代理。如果 proxy-target-class 属性设置为 true,则创建基于类的代理。如果 proxy-target-classfalse 或如果省略该属性,则创建标准 JDK 基于接口的代理。(有关不同代理类型的详细检查,请参阅 代理机制。)

order

order

Ordered.LOWEST_PRECEDENCE

定义应用于使用 @Cacheable@CacheEvict 注解的 bean 的缓存建议的顺序。(有关与排序 AOP 建议相关的规则的更多信息,请参阅 建议排序。)未指定排序意味着 AOP 子系统确定建议的顺序。

<cache:annotation-driven/> 仅在定义它的同一个应用程序上下文中查找 @Cacheable/@CachePut/@CacheEvict/@Caching。这意味着,如果您将 <cache:annotation-driven/> 放在 MockDispatcherHandlerWebApplicationContext 中,它仅检查控制器中的 bean,而不检查服务中的 bean。有关更多信息,请参阅 MVC 部分
方法可见性和缓存注解

使用代理时,应仅将缓存注解应用于具有公共可见性的方法。如果您使用这些注解来注解受保护、私有或包可见的方法,则不会引发错误,但注解的方法不会表现出配置的缓存设置。如果需要注解非公共方法,请考虑使用 AspectJ(请参阅本节的其余部分),因为它会更改字节码本身。

Infra 建议您仅使用 @Cache* 注解来注解具体类(和具体类的方法),而不是注解接口。您当然可以在接口(或接口方法)上放置 @Cache* 注解,但这仅在使用代理模式 (mode="proxy") 时有效。如果您使用基于编织的切面 (mode="aspectj"),则编织基础设施无法识别接口级声明上的缓存设置。
在代理模式(默认)下,仅拦截通过代理进入的外部方法调用。这意味着即使调用的方法标记为 @Cacheable,自调用(实际上是目标对象内的方法调用目标对象的另一个方法)也不会导致运行时的实际缓存。在这种情况下,请考虑使用 aspectj 模式。此外,代理必须完全初始化才能提供预期的行为,因此您不应在初始化代码(即 @PostConstruct)中依赖此功能。

使用自定义注解

自定义注解和 AspectJ

此功能仅适用于基于代理的方法,但可以通过使用 AspectJ 稍加努力来启用。

spring-aspects 模块仅为标准注解定义了一个切面。如果您定义了自己的注解,还需要为这些注解定义一个切面。查看 AnnotationCacheAspect 以获取示例。

缓存抽象允许您使用自己的注解来标识什么方法触发缓存填充或驱逐。这作为一种模板机制非常方便,因为它消除了重复缓存注解声明的需要,如果指定了键或条件,或者如果您的代码库中不允许外部导入(infra),这尤其有用。与其 构造型 注解类似,您可以将 @Cacheable@CachePut@CacheEvict@CacheConfig 用作 元注解(即可以注解其他注解的注解)。在以下示例中,我们将常见的 @Cacheable 声明替换为我们自己的自定义注解:

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Cacheable(cacheNames="books", key="#isbn")
public @interface SlowService {
}

在前面的示例中,我们定义了自己的 SlowService 注解,该注解本身使用 @Cacheable 进行了注解。现在我们可以替换以下代码:

@Cacheable(cacheNames="books", key="#isbn")
public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)

以下示例显示了我们可以用来替换前面代码的自定义注解:

@SlowService
public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)

即使 @SlowService 不是 Infra 注解,容器也会在运行时自动获取其声明并理解其含义。请注意,如 前文 所述,需要启用注解驱动的行为。