diff --git a/spring-context/src/main/java/org/springframework/cache/interceptor/CacheAspectSupport.java b/spring-context/src/main/java/org/springframework/cache/interceptor/CacheAspectSupport.java index 2b2bd0110ad7..c67fd1f2ca42 100644 --- a/spring-context/src/main/java/org/springframework/cache/interceptor/CacheAspectSupport.java +++ b/spring-context/src/main/java/org/springframework/cache/interceptor/CacheAspectSupport.java @@ -49,6 +49,11 @@ import org.springframework.beans.factory.annotation.BeanFactoryAnnotationUtils; import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; +import org.springframework.cache.interceptor.events.CacheClearEvent; +import org.springframework.cache.interceptor.events.CacheEvictEvent; +import org.springframework.cache.interceptor.events.CacheOperationEvent; +import org.springframework.cache.interceptor.events.CachePutEvent; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.expression.AnnotatedElementKey; import org.springframework.context.expression.BeanFactoryResolver; import org.springframework.core.BridgeMethodResolver; @@ -139,6 +144,8 @@ public abstract class CacheAspectSupport extends AbstractCacheInvoker private @Nullable SingletonSupplier cacheResolver; + private @Nullable ApplicationEventPublisher applicationEventPublisher; + private @Nullable BeanFactory beanFactory; private boolean initialized = false; @@ -165,6 +172,15 @@ public void configure( () -> SimpleCacheResolver.of(SupplierUtils.resolve(cacheManager))); } + /** + * Set the {@link ApplicationEventPublisher} to use for publishing cache operation events. + * @since 7.1 + * @see CacheInterceptor#setApplicationEventPublisher(ApplicationEventPublisher) + */ + public void setApplicationEventPublisher(final ApplicationEventPublisher applicationEventPublisher) { + this.applicationEventPublisher = applicationEventPublisher; + } + /** * Set one or more cache operation sources which are used to find the cache @@ -678,6 +694,7 @@ private void performCacheEvicts(List contexts, @Nullable if (operation.isCacheWide()) { logInvalidating(context, operation, null); doClear(cache, operation.isBeforeInvocation()); + publishEvent(new CacheClearEvent(context.metadata.method, cache)); } else { if (key == null) { @@ -685,12 +702,26 @@ private void performCacheEvicts(List contexts, @Nullable } logInvalidating(context, operation, key); doEvict(cache, key, operation.isBeforeInvocation()); + publishEvent(new CacheEvictEvent(context.metadata.method, cache, key)); } } } } } + private void publishEvent(final CacheOperationEvent event) { + if (this.applicationEventPublisher != null) { + try { + this.applicationEventPublisher.publishEvent(event); + } + catch (Throwable ex) { + if (logger.isWarnEnabled()) { + logger.warn("Failed to publish " + event, ex); + } + } + } + } + private void logInvalidating(CacheOperationContext context, CacheEvictOperation operation, @Nullable Object key) { if (logger.isTraceEnabled()) { logger.trace("Invalidating " + (key != null ? "cache key [" + key + "]" : "entire cache") + @@ -1049,6 +1080,7 @@ public void performCachePut(@Nullable Object value) { } for (Cache cache : this.context.getCaches()) { doPut(cache, key, value); + publishEvent(new CachePutEvent(this.context.metadata.method, cache, key, value)); } } } diff --git a/spring-context/src/main/java/org/springframework/cache/interceptor/CacheInterceptor.java b/spring-context/src/main/java/org/springframework/cache/interceptor/CacheInterceptor.java index c67b309132a4..66e0be7a3995 100644 --- a/spring-context/src/main/java/org/springframework/cache/interceptor/CacheInterceptor.java +++ b/spring-context/src/main/java/org/springframework/cache/interceptor/CacheInterceptor.java @@ -23,6 +23,8 @@ import org.aopalliance.intercept.MethodInvocation; import org.jspecify.annotations.Nullable; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationEventPublisherAware; import org.springframework.util.Assert; /** @@ -43,7 +45,11 @@ * @since 3.1 */ @SuppressWarnings("serial") -public class CacheInterceptor extends CacheAspectSupport implements MethodInterceptor, Serializable { +public class CacheInterceptor extends CacheAspectSupport implements MethodInterceptor, ApplicationEventPublisherAware, Serializable { + @Override + public void setApplicationEventPublisher(final ApplicationEventPublisher applicationEventPublisher) { + super.setApplicationEventPublisher(applicationEventPublisher); + } @Override public @Nullable Object invoke(final MethodInvocation invocation) throws Throwable { diff --git a/spring-context/src/main/java/org/springframework/cache/interceptor/events/CacheClearEvent.java b/spring-context/src/main/java/org/springframework/cache/interceptor/events/CacheClearEvent.java new file mode 100644 index 000000000000..4aed2254f482 --- /dev/null +++ b/spring-context/src/main/java/org/springframework/cache/interceptor/events/CacheClearEvent.java @@ -0,0 +1,45 @@ +/* + * Copyright 2002-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cache.interceptor.events; + +import java.lang.reflect.Method; + +import org.springframework.cache.Cache; +import org.springframework.cache.interceptor.CacheAspectSupport; + +/** + * Event published after a cache has been entirely cleared. + * + * @author Anıl Şenocak + * @since 7.1 + * @see CacheOperationEvent + * @see CacheAspectSupport + */ +@SuppressWarnings("serial") +public class CacheClearEvent extends CacheOperationEvent { + + /** + * Create a new {@code CacheClearEvent}. + * + * @param method the method that triggered the cache operation + * @param cache the cache that was cleared + */ + public CacheClearEvent(Method method, Cache cache) { + super(method, cache, null); + } + +} diff --git a/spring-context/src/main/java/org/springframework/cache/interceptor/events/CacheEvictEvent.java b/spring-context/src/main/java/org/springframework/cache/interceptor/events/CacheEvictEvent.java new file mode 100644 index 000000000000..14f74fa69695 --- /dev/null +++ b/spring-context/src/main/java/org/springframework/cache/interceptor/events/CacheEvictEvent.java @@ -0,0 +1,46 @@ +/* + * Copyright 2002-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cache.interceptor.events; + +import java.lang.reflect.Method; + +import org.springframework.cache.Cache; +import org.springframework.cache.interceptor.CacheAspectSupport; + +/** + * Event published after a single cache entry has been evicted. + * + * @author Anıl Şenocak + * @since 7.1 + * @see CacheOperationEvent + * @see CacheAspectSupport + */ +@SuppressWarnings("serial") +public class CacheEvictEvent extends CacheOperationEvent { + + /** + * Create a new {@code CacheEvictEvent}. + * + * @param method the method that triggered the cache operation + * @param cache the cache from which the entry was evicted + * @param key the cache key that was evicted + */ + public CacheEvictEvent(Method method, Cache cache, Object key) { + super(method, cache, key); + } + +} diff --git a/spring-context/src/main/java/org/springframework/cache/interceptor/events/CacheOperationEvent.java b/spring-context/src/main/java/org/springframework/cache/interceptor/events/CacheOperationEvent.java new file mode 100644 index 000000000000..8d1ec61c3549 --- /dev/null +++ b/spring-context/src/main/java/org/springframework/cache/interceptor/events/CacheOperationEvent.java @@ -0,0 +1,102 @@ +/* + * Copyright 2002-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cache.interceptor.events; + +import java.lang.reflect.Method; + +import org.jspecify.annotations.Nullable; + +import org.springframework.cache.Cache; +import org.springframework.cache.interceptor.CacheAspectSupport; +import org.springframework.context.ApplicationEvent; +import org.springframework.util.ClassUtils; + +/** + * Base class for cache operation events that are published when a cache entry is created, updated, or evicted. + * + * @author Anıl Şenocak + * @since 7.1 + * @see CachePutEvent + * @see CacheEvictEvent + * @see CacheClearEvent + * @see CacheAspectSupport + */ +@SuppressWarnings("serial") +public abstract class CacheOperationEvent extends ApplicationEvent { + + private final Method method; + + private final Cache cache; + + private final @Nullable Object key; + + /** + * Create a new {@code CacheOperationEvent}. + * + * @param method the method that triggered the cache operation + * @param cache the cache on which the operation was performed + * @param key the cache key, or {@code null} for cache-wide clear operations + */ + protected CacheOperationEvent(Method method, Cache cache, @Nullable Object key) { + super(method); + this.method = method; + this.cache = cache; + this.key = key; + } + + /** + * Return the method that triggered the cache operation. + */ + public Method getMethod() { + return this.method; + } + + /** + * Return the cache on which the operation was performed. + */ + public Cache getCache() { + return this.cache; + } + + /** + * Return the cache key that was affected, or {@code null} + * for cache-wide clear operations. + */ + public @Nullable Object getKey() { + return this.key; + } + + /** + * Return the cache name the operation was performed on. + */ + public String getCacheName() { + return this.cache.getName(); + } + + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(getClass().getSimpleName()); + sb.append(": ").append(ClassUtils.getQualifiedMethodName(this.method)); + sb.append(" on cache '").append(getCacheName()).append("'"); + if (this.key != null) { + sb.append(" [").append(this.key).append("]"); + } + return sb.toString(); + } + +} diff --git a/spring-context/src/main/java/org/springframework/cache/interceptor/events/CachePutEvent.java b/spring-context/src/main/java/org/springframework/cache/interceptor/events/CachePutEvent.java new file mode 100644 index 000000000000..cb3114edad96 --- /dev/null +++ b/spring-context/src/main/java/org/springframework/cache/interceptor/events/CachePutEvent.java @@ -0,0 +1,60 @@ +/* + * Copyright 2002-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cache.interceptor.events; + +import java.lang.reflect.Method; + +import org.jspecify.annotations.Nullable; + +import org.springframework.cache.Cache; +import org.springframework.cache.interceptor.CacheAspectSupport; + +/** + * Event published after a cache entry has been created or updated. + * + * @author Anıl Şenocak + * @since 7.1 + * @see CacheOperationEvent + * @see CacheAspectSupport + */ +@SuppressWarnings("serial") +public class CachePutEvent extends CacheOperationEvent { + private final @Nullable Object value; + + + /** + * Create a new {@code CachePutEvent}. + * + * @param method the method that triggered the cache operation + * @param cache the cache on which the entry was put + * @param key the cache key + * @param value the value that was cached + */ + public CachePutEvent(Method method, Cache cache, Object key, @Nullable Object value) { + super(method, cache, key); + this.value = value; + } + + + /** + * Return the value that was placed into the cache. + */ + public @Nullable Object getValue() { + return this.value; + } + +} diff --git a/spring-context/src/main/java/org/springframework/cache/interceptor/events/package-info.java b/spring-context/src/main/java/org/springframework/cache/interceptor/events/package-info.java new file mode 100644 index 000000000000..7d3dd24cea1e --- /dev/null +++ b/spring-context/src/main/java/org/springframework/cache/interceptor/events/package-info.java @@ -0,0 +1,9 @@ +/** + * AOP-based solution for declarative caching demarcation. + * Builds on the AOP infrastructure in org.springframework.aop.framework. + * Any POJO can be cache-advised with Spring. + */ +@NullMarked +package org.springframework.cache.interceptor.events; + +import org.jspecify.annotations.NullMarked; diff --git a/spring-context/src/test/java/org/springframework/cache/interceptor/CacheOperationEventTests.java b/spring-context/src/test/java/org/springframework/cache/interceptor/CacheOperationEventTests.java new file mode 100644 index 000000000000..0518eea042f4 --- /dev/null +++ b/spring-context/src/test/java/org/springframework/cache/interceptor/CacheOperationEventTests.java @@ -0,0 +1,264 @@ +/* + * Copyright 2002-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cache.interceptor; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicLong; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.CacheConfig; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.interceptor.events.CacheClearEvent; +import org.springframework.cache.interceptor.events.CacheEvictEvent; +import org.springframework.cache.interceptor.events.CacheOperationEvent; +import org.springframework.cache.interceptor.events.CachePutEvent; +import org.springframework.cache.support.SimpleCacheManager; +import org.springframework.cache.support.SimpleValueWrapper; +import org.springframework.context.ApplicationListener; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for cache operation events published by the caching infrastructure. + * + * @author Anıl Şenocak + * @since 7.1 + */ +class CacheOperationEventTests { + + private AnnotationConfigApplicationContext context; + + private Cache cache; + + private SimpleService simpleService; + + private CacheEventListener listener; + + private Method getMethod; + + private Method putMethod; + + private Method evictMethod; + + private Method clearMethod; + + private Method getFutureMethod; + + @BeforeEach + void setup() throws Exception { + this.context = new AnnotationConfigApplicationContext(Config.class); + this.cache = context.getBean("mockCache", Cache.class); + this.simpleService = context.getBean(SimpleService.class); + this.listener = context.getBean(CacheEventListener.class); + this.getMethod = SimpleService.class.getMethod("get", long.class); + this.putMethod = SimpleService.class.getMethod("put", long.class); + this.evictMethod = SimpleService.class.getMethod("evict", long.class); + this.clearMethod = SimpleService.class.getMethod("clear"); + this.getFutureMethod = SimpleService.class.getMethod("getFuture", long.class); + } + + @Test + void cacheableHit_noEvent() { + given(this.cache.get(0L)).willReturn(new SimpleValueWrapper(42L)); + + this.simpleService.get(0L); + + assertThat(this.listener.events).isEmpty(); + } + + @Test + void cacheableMiss_publishesPutEvent() { + given(this.cache.get(0L)).willReturn(null); + + this.simpleService.get(0L); + + assertThat(this.listener.events).hasSize(1); + assertThat(this.listener.events.get(0)) + .isExactlyInstanceOf(CachePutEvent.class) + .satisfies(event -> assertThat(event.getMethod()).isEqualTo(this.getMethod)) + .satisfies(event -> assertThat(event.getCacheName()).isEqualTo("test")) + .satisfies(event -> assertThat(event.getKey()).isEqualTo(0L)); + assertThat(this.listener.events.get(0)) + .satisfies(event -> assertThat(((CachePutEvent) event).getValue()).isEqualTo(0L)); + } + + @Test + void cachePut_publishesPutEvent() { + given(this.cache.get(0L)).willReturn(null); + + this.simpleService.put(0L); + + assertThat(this.listener.events).hasSize(1); + assertThat(this.listener.events.get(0)) + .isExactlyInstanceOf(CachePutEvent.class) + .satisfies(event -> assertThat(event.getMethod()).isEqualTo(this.putMethod)) + .satisfies(event -> assertThat(event.getCacheName()).isEqualTo("test")) + .satisfies(event -> assertThat(event.getKey()).isEqualTo(0L)); + assertThat(this.listener.events.get(0)) + .satisfies(event -> assertThat(((CachePutEvent) event).getValue()).isEqualTo(0L)); + } + + @Test + void cacheEvict_publishesEvictEvent() { + this.simpleService.evict(0L); + + assertThat(this.listener.events).hasSize(1); + assertThat(this.listener.events.get(0)) + .isExactlyInstanceOf(CacheEvictEvent.class) + .satisfies(event -> assertThat(event.getMethod()).isEqualTo(this.evictMethod)) + .satisfies(event -> assertThat(event.getCacheName()).isEqualTo("test")) + .satisfies(event -> assertThat(event.getKey()).isEqualTo(0L)); + } + + @Test + void cacheEvictAll_publishesClearEvent() { + this.simpleService.clear(); + + assertThat(this.listener.events).hasSize(1); + assertThat(this.listener.events.get(0)) + .isExactlyInstanceOf(CacheClearEvent.class) + .satisfies(event -> assertThat(event.getMethod()).isEqualTo(this.clearMethod)) + .satisfies(event -> assertThat(event.getCacheName()).isEqualTo("test")) + .satisfies(event -> assertThat(event.getKey()).isNull()); + } + + @Test + void cacheableMissWithMultipleCaches_publishesMultiplePutEvents() { + this.simpleService.getMultiple(0L); + + assertThat(this.listener.events).hasSize(2); + assertThat(this.listener.events.get(0)) + .isExactlyInstanceOf(CachePutEvent.class) + .satisfies(event -> assertThat(event.getCacheName()).isEqualTo("primary")); + assertThat(this.listener.events.get(1)) + .isExactlyInstanceOf(CachePutEvent.class) + .satisfies(event -> assertThat(event.getCacheName()).isEqualTo("secondary")); + } + + @Test + void cacheableFuture_publishesEventOnCompletion() throws Exception { + given(this.cache.retrieve(eq(0L))).willReturn(null); + + this.simpleService.getFuture(0L).get(); + + assertThat(this.listener.events).hasSize(1); + assertThat(this.listener.events.get(0)) + .isExactlyInstanceOf(CachePutEvent.class) + .satisfies(event -> assertThat(event.getMethod()).isEqualTo(this.getFutureMethod)) + .satisfies(event -> assertThat(event.getCacheName()).isEqualTo("test")) + .satisfies(event -> assertThat(event.getKey()).isEqualTo(0L)); + } + + @Configuration + @EnableCaching + static class Config { + + @Bean + public SimpleService simpleService() { + return new SimpleService(); + } + + @Bean + public CacheEventListener cacheEventListener() { + return new CacheEventListener(); + } + + @Bean + public CacheManager cacheManager(Cache mockCache) { + SimpleCacheManager cacheManager = new SimpleCacheManager(); + Cache primary = createMockCache(); + given(primary.getName()).willReturn("primary"); + Cache secondary = createMockCache(); + given(secondary.getName()).willReturn("secondary"); + cacheManager.setCaches(List.of(mockCache, primary, secondary)); + return cacheManager; + } + + @Bean + public Cache mockCache() { + Cache cache = createMockCache(); + given(cache.getName()).willReturn("test"); + return cache; + } + + private static Cache createMockCache() { + return mock(Cache.class); + } + + } + + @CacheConfig(cacheNames = "test") + public static class SimpleService { + + private AtomicLong counter = new AtomicLong(); + + @Cacheable + public Object get(long id) { + return this.counter.getAndIncrement(); + } + + @CachePut + public Object put(long id) { + return this.counter.getAndIncrement(); + } + + @CacheEvict + public void evict(long id) { + } + + @CacheEvict(allEntries = true) + public void clear() { + } + + @Cacheable(cacheNames = {"primary", "secondary"}) + public Object getMultiple(long id) { + return this.counter.getAndIncrement(); + } + + @Cacheable + public CompletableFuture getFuture(long id) { + return CompletableFuture.completedFuture(this.counter.getAndIncrement()); + } + } + + public static class CacheEventListener implements ApplicationListener { + + public final List events = new ArrayList<>(); + + @Override + public void onApplicationEvent(CacheOperationEvent event) { + this.events.add(event); + } + + } +}