diff --git a/spring-boot-modules/spring-caching-2/pom.xml b/spring-boot-modules/spring-caching-2/pom.xml index ec9215aa32..95fae4a0ef 100644 --- a/spring-boot-modules/spring-caching-2/pom.xml +++ b/spring-boot-modules/spring-caching-2/pom.xml @@ -51,7 +51,7 @@ ${caffeine.version} - it.ozimov + com.github.codemonstur embedded-redis ${embedded.redis.version} @@ -65,7 +65,7 @@ - 0.7.3 + 1.4.0 3.1.8 com.baeldung.caching.ttl.CachingTTLApplication diff --git a/spring-boot-modules/spring-caching-2/src/main/java/com/baeldung/caching/twolevelcache/CacheConfig.java b/spring-boot-modules/spring-caching-2/src/main/java/com/baeldung/caching/twolevelcache/CacheConfig.java new file mode 100644 index 0000000000..576bcd97ab --- /dev/null +++ b/spring-boot-modules/spring-caching-2/src/main/java/com/baeldung/caching/twolevelcache/CacheConfig.java @@ -0,0 +1,71 @@ +package com.baeldung.caching.twolevelcache; + +import com.github.benmanes.caffeine.cache.Caffeine; + +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.AnnotationCacheOperationSource; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCache; +import org.springframework.cache.interceptor.CacheInterceptor; +import org.springframework.cache.interceptor.CacheOperationSource; +import org.springframework.cache.support.SimpleCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; + +import java.time.Duration; +import java.util.Arrays; + +@Configuration +@EnableCaching +public class CacheConfig { + + @Bean + @Primary + public CacheManager caffeineCacheManager(CaffeineCache caffeineCache) { + SimpleCacheManager manager = new SimpleCacheManager(); + manager.setCaches(Arrays.asList(caffeineCache)); + return manager; + } + + @Bean + public CaffeineCache caffeineCacheConfig() { + return new CaffeineCache("customerCache", Caffeine.newBuilder() + .expireAfterWrite(Duration.ofSeconds(3)) + .initialCapacity(1) + .maximumSize(2000) + .build()); + } + + @Bean + public CacheManager redisCacheManager(RedisConnectionFactory connectionFactory, RedisCacheConfiguration redisCacheConfiguration) { + return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(connectionFactory) + .withCacheConfiguration("customerCache", redisCacheConfiguration) + .build(); + } + + @Bean + public RedisCacheConfiguration cacheConfiguration() { + return RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofMinutes(5)) + .disableCachingNullValues() + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); + } + + @Bean + public CacheInterceptor cacheInterceptor(CacheManager caffeineCacheManager, CacheOperationSource cacheOperationSource) { + CacheInterceptor interceptor = new CustomerCacheInterceptor(caffeineCacheManager); + interceptor.setCacheOperationSources(cacheOperationSource); + return interceptor; + } + + @Bean + public CacheOperationSource cacheOperationSource() { + return new AnnotationCacheOperationSource(); + } +} \ No newline at end of file diff --git a/spring-boot-modules/spring-caching-2/src/main/java/com/baeldung/caching/twolevelcache/Customer.java b/spring-boot-modules/spring-caching-2/src/main/java/com/baeldung/caching/twolevelcache/Customer.java new file mode 100644 index 0000000000..0b985d35f3 --- /dev/null +++ b/spring-boot-modules/spring-caching-2/src/main/java/com/baeldung/caching/twolevelcache/Customer.java @@ -0,0 +1,25 @@ +package com.baeldung.caching.twolevelcache; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.io.Serializable; + +@Data +@Entity +@AllArgsConstructor +@NoArgsConstructor +@Setter +public class Customer implements Serializable { + + @Id + private String id; + + private String name; + + private String email; +} \ No newline at end of file diff --git a/spring-boot-modules/spring-caching-2/src/main/java/com/baeldung/caching/twolevelcache/CustomerCacheInterceptor.java b/spring-boot-modules/spring-caching-2/src/main/java/com/baeldung/caching/twolevelcache/CustomerCacheInterceptor.java new file mode 100644 index 0000000000..f1a8dca0db --- /dev/null +++ b/spring-boot-modules/spring-caching-2/src/main/java/com/baeldung/caching/twolevelcache/CustomerCacheInterceptor.java @@ -0,0 +1,29 @@ +package com.baeldung.caching.twolevelcache; + +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.interceptor.CacheInterceptor; +import org.springframework.data.redis.cache.RedisCache; + +public class CustomerCacheInterceptor extends CacheInterceptor { + + private final CacheManager caffeineCacheManager; + + public CustomerCacheInterceptor(CacheManager caffeineCacheManager) { + this.caffeineCacheManager = caffeineCacheManager; + } + + @Override + protected Cache.ValueWrapper doGet(Cache cache, Object key) { + Cache.ValueWrapper existingCacheValue = super.doGet(cache, key); + + if (existingCacheValue != null && cache.getClass() == RedisCache.class) { + Cache caffeineCache = caffeineCacheManager.getCache(cache.getName()); + if (caffeineCache != null) { + caffeineCache.putIfAbsent(key, existingCacheValue.get()); + } + } + + return existingCacheValue; + } +} diff --git a/spring-boot-modules/spring-caching-2/src/main/java/com/baeldung/caching/twolevelcache/CustomerRepository.java b/spring-boot-modules/spring-caching-2/src/main/java/com/baeldung/caching/twolevelcache/CustomerRepository.java new file mode 100644 index 0000000000..098112f152 --- /dev/null +++ b/spring-boot-modules/spring-caching-2/src/main/java/com/baeldung/caching/twolevelcache/CustomerRepository.java @@ -0,0 +1,6 @@ +package com.baeldung.caching.twolevelcache; + +import org.springframework.data.repository.CrudRepository; + +public interface CustomerRepository extends CrudRepository { +} diff --git a/spring-boot-modules/spring-caching-2/src/main/java/com/baeldung/caching/twolevelcache/CustomerService.java b/spring-boot-modules/spring-caching-2/src/main/java/com/baeldung/caching/twolevelcache/CustomerService.java new file mode 100644 index 0000000000..088b66919c --- /dev/null +++ b/spring-boot-modules/spring-caching-2/src/main/java/com/baeldung/caching/twolevelcache/CustomerService.java @@ -0,0 +1,26 @@ +package com.baeldung.caching.twolevelcache; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.Caching; +import org.springframework.stereotype.Service; + +@Service +public class CustomerService { + + private final CustomerRepository customerRepository; + + @Autowired + public CustomerService(CustomerRepository customerRepository) { + this.customerRepository = customerRepository; + } + + @Caching(cacheable = { + @Cacheable(cacheNames = "customerCache", cacheManager = "caffeineCacheManager"), + @Cacheable(cacheNames = "customerCache", cacheManager = "redisCacheManager") + }) + public Customer getCustomer(String id) { + return customerRepository.findById(id) + .orElseThrow(RuntimeException::new); + } +} \ No newline at end of file diff --git a/spring-boot-modules/spring-caching-2/src/main/java/com/baeldung/caching/twolevelcache/TwoLevelCacheApplication.java b/spring-boot-modules/spring-caching-2/src/main/java/com/baeldung/caching/twolevelcache/TwoLevelCacheApplication.java new file mode 100644 index 0000000000..9bc2c65e6e --- /dev/null +++ b/spring-boot-modules/spring-caching-2/src/main/java/com/baeldung/caching/twolevelcache/TwoLevelCacheApplication.java @@ -0,0 +1,12 @@ +package com.baeldung.caching.twolevelcache; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class TwoLevelCacheApplication { + + public static void main(String[] args) { + SpringApplication.run(TwoLevelCacheApplication.class, args); + } +} \ No newline at end of file diff --git a/spring-boot-modules/spring-caching-2/src/main/resources/application.properties b/spring-boot-modules/spring-caching-2/src/main/resources/application.properties index 38f3537d01..49bd715e43 100644 --- a/spring-boot-modules/spring-caching-2/src/main/resources/application.properties +++ b/spring-boot-modules/spring-caching-2/src/main/resources/application.properties @@ -11,3 +11,4 @@ caching.spring.hotelListTTL=43200 # Connection details #spring.redis.host=localhost #spring.redis.port=6379 +spring.main.allow-bean-definition-overriding=true diff --git a/spring-boot-modules/spring-caching-2/src/test/java/com/baeldung/caching/redis/ItemServiceCachingIntegrationTest.java b/spring-boot-modules/spring-caching-2/src/test/java/com/baeldung/caching/redis/ItemServiceCachingIntegrationTest.java index 8868edb74f..01740ba780 100644 --- a/spring-boot-modules/spring-caching-2/src/test/java/com/baeldung/caching/redis/ItemServiceCachingIntegrationTest.java +++ b/spring-boot-modules/spring-caching-2/src/test/java/com/baeldung/caching/redis/ItemServiceCachingIntegrationTest.java @@ -5,8 +5,6 @@ import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; -import java.util.Optional; - import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -20,10 +18,12 @@ import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Import; import org.springframework.test.context.junit.jupiter.SpringExtension; +import redis.embedded.RedisServer; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; -import redis.embedded.RedisServer; +import java.io.IOException; +import java.util.Optional; @Import({ CacheConfig.class, ItemService.class }) @ExtendWith(SpringExtension.class) @@ -69,17 +69,17 @@ class ItemServiceCachingIntegrationTest { private final RedisServer redisServer; - public EmbeddedRedisConfiguration() { + public EmbeddedRedisConfiguration() throws IOException { this.redisServer = new RedisServer(); } @PostConstruct - public void startRedis() { + public void startRedis() throws IOException { redisServer.start(); } @PreDestroy - public void stopRedis() { + public void stopRedis() throws IOException { this.redisServer.stop(); } } diff --git a/spring-boot-modules/spring-caching-2/src/test/java/com/baeldung/caching/twolevelcache/CustomerServiceCachingIntegrationTest.java b/spring-boot-modules/spring-caching-2/src/test/java/com/baeldung/caching/twolevelcache/CustomerServiceCachingIntegrationTest.java new file mode 100644 index 0000000000..3db53198fc --- /dev/null +++ b/spring-boot-modules/spring-caching-2/src/test/java/com/baeldung/caching/twolevelcache/CustomerServiceCachingIntegrationTest.java @@ -0,0 +1,124 @@ +package com.baeldung.caching.twolevelcache; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration; +import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import redis.embedded.RedisServer; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import java.io.IOException; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +@Import({ CacheConfig.class,CustomerService.class }) +@ExtendWith(SpringExtension.class) +@ImportAutoConfiguration(classes = { CacheAutoConfiguration.class, RedisAutoConfiguration.class }) +@EnableCaching +class CustomerServiceCachingIntegrationTest { + + @MockBean + private CustomerRepository customerRepository; + + @Autowired + private CustomerService customerService; + + @Autowired + private CacheManager redisCacheManager; + + @Autowired + private CacheManager caffeineCacheManager; + + @Test + void givenCustomerIsPresent_whenGetCustomerCalled_thenReturnCustomerAndCacheIt() { + String CUSTOMER_ID = "100"; + Customer customer = new Customer(CUSTOMER_ID, "test", "test@mail.com"); + + given(customerRepository.findById(CUSTOMER_ID)).willReturn(Optional.of(customer)); + + Customer customerCacheMiss = customerService.getCustomer(CUSTOMER_ID); + + assertThat(customerCacheMiss).isEqualTo(customer); + verify(customerRepository, times(1)).findById(CUSTOMER_ID); + assertThat(customerFromCaffeineCache(CUSTOMER_ID)).isEqualTo(customer); + assertThat(customerFromRedisCache(CUSTOMER_ID)).isEqualTo(customer); + } + + @Test + void givenCustomerIsPresent_whenGetCustomerCalledTwice_thenReturnCustomerAndCacheIt() { + String CUSTOMER_ID = "101"; + Customer customer = new Customer(CUSTOMER_ID, "test", "test@mail.com"); + given(customerRepository.findById(CUSTOMER_ID)).willReturn(Optional.of(customer)); + + Customer customerCacheMiss = customerService.getCustomer(CUSTOMER_ID); + Customer customerCacheHit = customerService.getCustomer(CUSTOMER_ID); + + assertThat(customerCacheMiss).isEqualTo(customer); + assertThat(customerCacheHit).isEqualTo(customer); + verify(customerRepository, times(1)).findById(CUSTOMER_ID); + assertThat(customerFromCaffeineCache(CUSTOMER_ID)).isEqualTo(customer); + assertThat(customerFromRedisCache(CUSTOMER_ID)).isEqualTo(customer); + } + + @Test + void givenCustomerIsPresent_whenGetCustomerCalledTwiceAndFirstCacheExpired_thenReturnCustomerAndCacheIt() throws InterruptedException { + String CUSTOMER_ID = "102"; + Customer customer = new Customer(CUSTOMER_ID, "test", "test@mail.com"); + given(customerRepository.findById(CUSTOMER_ID)).willReturn(Optional.of(customer)); + + Customer customerCacheMiss = customerService.getCustomer(CUSTOMER_ID); + TimeUnit.SECONDS.sleep(3); + assertThat(customerFromCaffeineCache(CUSTOMER_ID)).isEqualTo(null); + Customer customerCacheHit = customerService.getCustomer(CUSTOMER_ID); + + verify(customerRepository, times(1)).findById(CUSTOMER_ID); + assertThat(customerCacheMiss).isEqualTo(customer); + assertThat(customerCacheHit).isEqualTo(customer); + assertThat(customerFromCaffeineCache(CUSTOMER_ID)).isEqualTo(customer); + assertThat(customerFromRedisCache(CUSTOMER_ID)).isEqualTo(customer); + } + + private Object customerFromRedisCache(String key) { + return redisCacheManager.getCache("customerCache").get(key) != null ? + redisCacheManager.getCache("customerCache").get(key).get() : null; + } + + private Object customerFromCaffeineCache(String key) { + return caffeineCacheManager.getCache("customerCache").get(key) != null ? + caffeineCacheManager.getCache("customerCache").get(key).get() : null; + } + + @TestConfiguration + static class EmbeddedRedisConfiguration { + + private final RedisServer redisServer; + + public EmbeddedRedisConfiguration() throws IOException { + this.redisServer = new RedisServer(); + } + + @PostConstruct + public void startRedis() throws IOException { + redisServer.start(); + } + + @PreDestroy + public void stopRedis() throws IOException { + this.redisServer.stop(); + } + } +} \ No newline at end of file