1. 概述

Spring 提供了一种基于注解的方法来在Spring管理的bean上启用缓存。利用面向切面(AOP)技术,只需在方法上添加@Cacheable注解,就能轻松使方法可缓存。然而,当在同一个类内部调用时,缓存将被忽略。本文将解释这一现象并提供解决方案。

2. 复现问题

首先,我们创建一个启用缓存的 Spring Boot 应用程序,其中创建了一个带有@Cacheable注解的MathService,其square方法如下:

@Service
@CacheConfig(cacheNames = "square")
public class MathService {
    private final AtomicInteger counter = new AtomicInteger();

    @CacheEvict(allEntries = true)
    public AtomicInteger resetCounter() {
        counter.set(0);
        return counter;
    }

    @Cacheable(key = "#n")
    public double square(double n) {
        counter.incrementAndGet();
        return n * n;
    }
}

其次,在MathService中创建一个sumOfSquareOf2方法,它两次调用square方法:

public double sumOfSquareOf2() {
    return this.square(2) + this.square(2);
}

然后,我们为sumOfSquareOf2方法编写测试,检查square方法被调用了多少次:

@SpringBootTest(classes = Application.class)
class MathServiceIntegrationTest {

    @Resource
    private MathService mathService;

    @Test
    void givenCacheableMethod_whenInvokingByInternalCall_thenCacheIsNotTriggered() {
        AtomicInteger counter = mathService.resetCounter();

        assertThat(mathService.sumOfSquareOf2()).isEqualTo(8);
        assertThat(counter.get()).isEqualTo(2);
    }

}

由于同一类内部的调用不触发缓存,计数器的值为2,表明带有参数2的square方法被调用了两次,且缓存被忽略了。这不是我们的预期,因此我们需要找出这种行为的原因。

3. 分析问题

@Cacheable方法的缓存行为由Spring AOP支持。如果使用IDE调试代码,我们可以找到一些线索。MathServiceIntegrationTest中的mathService引用的是MathService$$EnhancerBySpringCGLIB$$5cdf8ec8的代理实例,而MathService中的this引用的是MathService实例本身。

MathService$$EnhancerBySpringCGLIB$$5cdf8ec8是Spring生成的代理类,它拦截MathService的所有@Cacheable方法请求,并返回缓存中的值。另一方面,MathService本身不具备缓存能力,所以同一类内的内部调用不会获取到缓存值。

了解了机制后,让我们寻找解决这个问题的方法。显然,最简单的方法是将@Cacheable方法移到另一个bean。但如果出于某些原因必须将方法保留在同一个bean中,我们有三种可能的解决方案:

  • 自我注入
  • 编译时织入
  • 加载时织入

在我们的入门AspectJ文章中详细介绍了面向切面编程(AOP)以及不同的编织方法。编织是指在编译源代码为*.class*文件时插入的代码过程,包括编译时编织、后编译编织和加载时编织。因为后编译编织用于对第三方库进行编织,这不在我们的考虑范围内,所以我们只关注编译时编织和加载时编织。

4. 解决方案1:自我注入

自我注入是绕过Spring AOP限制的常用解决方案。它允许我们获取Spring增强的bean的引用,通过这个bean调用方法。在我们的例子中,我们可以为成员变量self自动注入mathService bean,并通过self而不是this引用调用square方法:

@Service
@CacheConfig(cacheNames = "square")
@Scope(proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MathService {

    @Autowired
    private MathService self;

    // other code

    public double sumOfSquareOf3() {
        return self.square(3) + self.square(3);
    }
}

@Scope注解帮助创建并注入一个循环引用的占位代理给self,稍后会填充相同的MathService实例。测试显示square方法仅执行一次:

@Test
void givenCacheableMethod_whenInvokingByExternalCall_thenCacheIsTriggered() {
    AtomicInteger counter = mathService.resetCounter();

    assertThat(mathService.sumOfSquareOf3()).isEqualTo(18);
    assertThat(counter.get()).isEqualTo(1);
}

5. 解决方案2:编译时织入

启用编译时编织需要三个步骤。首先,在任何配置类上添加@EnableCaching注解以启用缓存:

接下来,为compile目标定义AspectJ Maven插件:

<plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>aspectj-maven-plugin</artifactId>
    <version>${aspectj-plugin.version}</version>
    <configuration>
        <source>${java.version}</source>
        <target>${java.version}</target>
        <complianceLevel>${java.version}</complianceLevel>
        <Xlint>ignore</Xlint>
        <encoding>UTF-8</encoding>
        <aspectLibraries>
            <aspectLibrary>
                <groupId>org.springframework</groupId>
                <artifactId>spring-aspects</artifactId>
            </aspectLibrary>
        </aspectLibraries>
    </configuration>
    <executions>
        <execution>
            <goals>
                <goal>compile</goal>
            </goals>
        </execution>
    </executions>
</plugin>

如上所示的AspectJ Maven插件会在执行mvn clean compile时编织切面。使用编译时编织,我们无需修改代码,square方法只会被执行一次:

@Test
void givenCacheableMethod_whenInvokingByInternalCall_thenCacheIsTriggered() {
    AtomicInteger counter = mathService.resetCounter();

    assertThat(mathService.sumOfSquareOf2()).isEqualTo(8);
    assertThat(counter.get()).isEqualTo(1);
}

6. 解决方案3:加载时织入

加载时编织是将二进制编织推迟到类加载器加载类文件并将类定义到JVM时。使用AspectJ的加载时编织,可以在类加载到JVM之前参与到类加载过程中,并对任何类型进行编织。

启用加载时编织也需要三个步骤。首先,在任何配置类上添加两个注解以启用缓存和加载时织入器:

最后,我们在JVM启动时指定Java代理选项-javaagent:path/to/aspectjweaver.jar,或使用Maven插件配置javaagent

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>${maven-surefire-plugin.version}</version>
            <configuration>
                <argLine>
                    --add-opens java.base/java.lang=ALL-UNNAMED
                    --add-opens java.base/java.util=ALL-UNNAMED
                    -javaagent:"${settings.localRepository}"/org/aspectj/aspectjweaver/${aspectjweaver.version}/aspectjweaver-${aspectjweaver.version}.jar
                    -javaagent:"${settings.localRepository}"/org/springframework/spring-instrument/${spring.version}/spring-instrument-${spring.version}.jar
                </argLine>
                <useSystemClassLoader>true</useSystemClassLoader>
                <forkMode>always</forkMode>
                <includes>
                    <include>com.baeldung.selfinvocation.LoadTimeWeavingIntegrationTest</include>
                </includes>
            </configuration>
        </plugin>
    </plugins>
</build>

对于加载时编织,测试givenCacheableMethod_whenInvokingByInternalCall_thenCacheIsTriggered也会通过。

7. 总结

本文探讨了Spring中同一类内部调用@Cacheable方法导致缓存失效的问题,以及如何通过自我注入、编译时编织和加载时编织三种方式解决这一问题。理解这些解决方案可以帮助开发者在实际项目中更有效地管理和利用缓存功能。