feat: Annotation based Caching library similar to spring's Cacheable with WebFlux support (#14416)

* added reactive caching module

Signed-off-by: Sidhant Goel <sidhant@appsmith.com>

* CacheManager and implementation

* updated root level pom.xml

* removed spring boot maven plugin from project reactiveCaching

* Support for key annotation

* moved conditional on annotation

* Added comments

* Update app/server/reactive-caching/src/main/java/com/appsmith/caching/aspects/ReactiveCacheAspect.java

Co-authored-by: Arpit Mohan <mohanarpit@users.noreply.github.com>

* Update app/server/reactive-caching/src/main/java/com/appsmith/caching/aspects/ReactiveCacheAspect.java

Co-authored-by: Arpit Mohan <mohanarpit@users.noreply.github.com>

* Update app/server/reactive-caching/src/main/java/com/appsmith/caching/aspects/ReactiveCacheAspect.java

Co-authored-by: Arpit Mohan <mohanarpit@users.noreply.github.com>

* review changes

* addressed review comments

Co-authored-by: Arpit Mohan <mohanarpit@users.noreply.github.com>
This commit is contained in:
sidhantgoel 2022-07-28 21:45:47 +05:30 committed by GitHub
parent edbfef7d51
commit 4c56cc5bda
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 927 additions and 0 deletions

View File

@ -72,6 +72,7 @@
<module>appsmith-plugins</module>
<module>appsmith-server</module>
<module>appsmith-git</module>
<module>reactive-caching</module>
</modules>
</project>

View File

@ -0,0 +1,125 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<groupId>com.appsmith</groupId>
<artifactId>integrated</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<groupId>com.appsmith</groupId>
<artifactId>reactiveCaching</artifactId>
<version>1.0-SNAPSHOT</version>
<name>reactiveCaching</name>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<java.version>11</java.version>
<org.pf4j.version>3.4.1</org.pf4j.version>
<org.projectlombok.version>1.18.22</org.projectlombok.version>
<org.testcontainers.junit-jupiter.version>1.17.2</org.testcontainers.junit-jupiter.version>
<uk.co.jemos.podam.podam.version>7.2.5.RELEASE</uk.co.jemos.podam.podam.version>
<maven-surefire-plugin.version>2.22.0</maven-surefire-plugin.version>
</properties>
<dependencies>
<dependency>
<groupId>org.pf4j</groupId>
<artifactId>pf4j</artifactId>
<version>${org.pf4j.version}</version>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- junit 5 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${org.projectlombok.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${org.testcontainers.junit-jupiter.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>uk.co.jemos.podam</groupId>
<artifactId>podam</artifactId>
<version>${uk.co.jemos.podam.podam.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>${maven-surefire-plugin.version}</version>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,26 @@
package com.appsmith.caching.annotations;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* This annotation is used to mark a method to cache the result of a method call.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Cache {
/*
* This is the name of the cache.
*/
String cacheName();
/**
* SPEL expression used to generate the key for the method call
* All method arguments can be used in the expression
*/
String key() default "";
}

View File

@ -0,0 +1,31 @@
package com.appsmith.caching.annotations;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* This annotation is used to mark a method as a reactive cache evictor.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface CacheEvict {
/*
* This is the name of the cache.
*/
String cacheName();
/**
* SPEL expression used to generate the key for the method call
* All method arguments can be used in the expression
*/
String key() default "";
/**
* Whether to evict all keys for a given cache name.
*/
boolean all() default false;
}

View File

@ -0,0 +1,204 @@
package com.appsmith.caching.aspects;
import java.lang.reflect.Method;
import java.util.List;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.interceptor.SimpleKey;
import org.springframework.context.annotation.Configuration;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import com.appsmith.caching.annotations.CacheEvict;
import com.appsmith.caching.annotations.Cache;
import com.appsmith.caching.components.CacheManager;
import lombok.extern.slf4j.Slf4j;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* CacheAspect is an aspect that is used to cache the results of a method call annotated with Cache.
* It is also possible to evict the cached result by annotating method with CacheEvict.
*/
@Aspect
@Configuration
@Slf4j
public class CacheAspect {
private final CacheManager cacheManager;
public static final ExpressionParser EXPRESSION_PARSER = new SpelExpressionParser();
@Autowired
public CacheAspect(CacheManager cacheManager) {
this.cacheManager = cacheManager;
}
/**
* This method is used to call original Mono<T> returning method and return the the result after caching it with CacheManager
* @param joinPoint The join point of the method call
* @param cacheName The name of the cache
* @param key The key to be used for caching
* @return The result of the method call
*/
private Mono<Object> callMonoMethodAndCache(ProceedingJoinPoint joinPoint, String cacheName, String key) {
try {
return ((Mono<?>) joinPoint.proceed())
.zipWhen(value -> cacheManager.put(cacheName, key, value)) //Call CacheManager.put() to cache the object
.flatMap(value -> Mono.just(value.getT1())); //Maps to the original object
} catch (Throwable e) {
log.error("Error occurred in saving to cache when invoking function {}", joinPoint.getSignature().getName(), e);
return Mono.error(e);
}
}
/**
* This method is used to call original Flux<T> returning method and return the the result after caching it with CacheManager
* @param joinPoint The join point
* @param cacheName The name of the cache
* @param key The key to be used for caching
* @return The result of the method call after caching
*/
private Flux<?> callFluxMethodAndCache(ProceedingJoinPoint joinPoint, String cacheName, String key) {
try {
return ((Flux<?>) joinPoint.proceed())
.collectList() // Collect Flux<T> into Mono<List<T>>
.zipWhen(value -> cacheManager.put(cacheName, key, value)) //Call CacheManager.put() to cache the list
.flatMap(value -> Mono.just(value.getT1())) //Maps to the original list
.flatMapMany(Flux::fromIterable); //Convert it back to Flux<T>
} catch (Throwable e) {
log.error("Error occurred in saving to cache when invoking function {}", joinPoint.getSignature().getName(), e);
return Flux.error(e);
}
}
/**
* This method is used to derive the key name for caching the result of a method call based on method arguments.
* This uses original strategy used by Spring's Cacheable annotation.
* @param args Arguments of original method call
* @return Key name for caching the result of the method call
*/
private String deriveKeyWithArguments(Object[] args) {
if(args.length == 0) { //If there are no arguments, return SimpleKey.EMPTY
return SimpleKey.EMPTY.toString();
}
if(args.length == 1) { //If there is only one argument, return its toString() value
return args[0].toString();
}
SimpleKey simpleKey = new SimpleKey(args); //Create SimpleKey from arguments and return its toString() value
return simpleKey.toString();
}
/**
* This method is used to derive the key name for caching the result of a method call based on method arguments and expression provided.
* @param expression SPEL Expression to derive the key name
* @param parameterNames Names of the method arguments of original method call
* @param args Arguments of original method call
* @return Key name for caching the result of the method call
*/
private String deriveKeyWithExpression(String expression, String[] parameterNames, Object[] args) {
//Create EvaluationContext for the expression
EvaluationContext evaluationContext = new StandardEvaluationContext();
for (int i = 0; i < args.length; i ++) {
//Add method arguments to evaluation context
evaluationContext.setVariable(parameterNames[i], args[i]);
}
//Parse expression and return the result
return EXPRESSION_PARSER.parseExpression(expression).getValue(evaluationContext, String.class);
}
/**
* This method is used to derive the key name for caching the result of a method call
* @param expression SPEL Expression to derive the key name
* @param parameterNames Names of the method arguments of original method call
* @param args Arguments of original method call
* @return Key name for caching the result of the method call
*/
private String deriveKey(String expression, String[] parameterNames, Object[] args) {
if(expression.isEmpty()) { //If expression is empty, use default strategy
return deriveKeyWithArguments(args);
}
//If expression is not empty, use expression strategy
return deriveKeyWithExpression(expression, parameterNames, args);
}
/**
* This method defines a Aspect to handle method calls annotated with Cache.
* @param joinPoint ProceedingJoinPoint of the method call
* @return Result of the method call, either cached or after calling the original method
* @throws Throwable
*/
@Around("execution(public * *(..)) && @annotation(com.appsmith.caching.annotations.Cache)")
public Object cacheable(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
Cache annotation = method.getAnnotation(Cache.class);
String cacheName = annotation.cacheName();
//derive key
String[] parameterNames = signature.getParameterNames();
Object[] args = joinPoint.getArgs();
String key = deriveKey(annotation.key(), parameterNames, args);
Class<?> returnType = method.getReturnType();
if (returnType.isAssignableFrom(Mono.class)) { //If method returns Mono<T>
return cacheManager.get(cacheName, key)
.switchIfEmpty(Mono.defer(() -> callMonoMethodAndCache(joinPoint, cacheName, key))); //defer the creation of Mono until subscription as it will call original function
}
if (returnType.isAssignableFrom(Flux.class)) { //If method returns Flux<T>
return cacheManager.get(cacheName, key)
.switchIfEmpty(Mono.defer(() -> callFluxMethodAndCache(joinPoint, cacheName, key).collectList())) //defer the creation of Flux until subscription as it will call original function
.map(value -> (List<?>) value)
.flatMapMany(Flux::fromIterable);
}
//If method does not returns Mono<T> or Flux<T> raise exception
throw new RuntimeException("Invalid usage of @Cache annotation. Only reactive objects Mono and Flux are supported for caching.");
}
/**
* This method defines a Aspect to handle method calls annotated with ReactiveEvict.
* Original method should return Mono<?>
* @param joinPoint ProceedingJoinPoint of the method call
* @return Mono<Void> that will complete after evicting the key from the cache
* @throws Throwable
*/
@Around("execution(public * *(..)) && @annotation(com.appsmith.caching.annotations.CacheEvict)")
public Object cacheEvict(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
CacheEvict annotation = method.getAnnotation(CacheEvict.class);
String cacheName = annotation.cacheName();
boolean all = annotation.all();
Class<?> returnType = method.getReturnType();
if (!returnType.isAssignableFrom(Mono.class)) {
throw new RuntimeException("Invalid usage of @CacheEvict for " + method.getName() + ". Only Mono<?> is allowed.");
}
if(all) { //If all is true, evict all keys from the cache
return cacheManager.evictAll(cacheName)
.then((Mono<?>) joinPoint.proceed());
}
//derive key
String[] parameterNames = signature.getParameterNames();
Object[] args = joinPoint.getArgs();
String key = deriveKey(annotation.key(), parameterNames, args);
//Evict key from the cache then call the original method
return cacheManager.evict(cacheName, key)
.then((Mono<?>) joinPoint.proceed());
}
}

View File

@ -0,0 +1,42 @@
package com.appsmith.caching.components;
import reactor.core.publisher.Mono;
public interface CacheManager {
/**
* This will log the cache stats with INFO severity.
*/
void logStats();
/**
* This will get item from the cache, Mono.empty() if not found.
* @param cacheName The name of the cache.
* @param key The key of the item.
* @return The Mono of the item.
*/
Mono<Object> get(String cacheName, String key);
/**
* This will put item into the cache.
* @param cacheName The name of the cache.
* @param key The key of the item.
* @param value The value of the item.
* @return Mono<Boolean> true if put was successful, false otherwise.
*/
Mono<Boolean> put(String cacheName, String key, Object value);
/**
* This will remove item from the cache.
* @param cacheName The name of the cache.
* @param key The key of the item.
* @return Mono<Void> that will complete after the item is removed.
*/
Mono<Void> evict(String cacheName, String key);
/**
* This will remove all items from the cache.
* @param cacheName The name of the cache.
* @return Mono<Void> that will complete after the items are removed.
*/
Mono<Void> evictAll(String cacheName);
}

View File

@ -0,0 +1,109 @@
package com.appsmith.caching.components;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.data.redis.core.ReactiveRedisOperations;
import org.springframework.data.redis.core.ReactiveRedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Component;
import com.appsmith.caching.model.CacheStats;
import lombok.extern.slf4j.Slf4j;
import reactor.core.publisher.Mono;
/**
* RedisCacheManagerImpl is a class that implements the CacheManager interface.
* Used Redis as the cache backend.
*/
@Component
@ConditionalOnClass({ReactiveRedisTemplate.class})
@Slf4j
public class RedisCacheManagerImpl implements CacheManager {
private final ReactiveRedisTemplate<String, Object> reactiveRedisTemplate;
private final ReactiveRedisOperations<String, String> reactiveRedisOperations;
Map<String, CacheStats> statsMap = new ConcurrentHashMap<>();
/**
* Ensures that the key for cacheName is present in statsMap.
* @param cacheName The name of the cache.
*/
private void ensureStats(String cacheName) {
if (!statsMap.containsKey(cacheName)) {
statsMap.put(cacheName, CacheStats.newInstance());
}
}
@Override
public void logStats() {
statsMap.keySet().forEach(key -> {
CacheStats stats = statsMap.get(key);
log.info("Cache {} stats: hits = {}, misses = {}, singleEvictions = {}, completeEvictions = {}", key, stats.getHits(), stats.getMisses(), stats.getSingleEvictions(), stats.getCompleteEvictions());
});
}
/**
* Resets the stats.
*/
public void resetStats() {
statsMap.clear();
}
@Autowired
public RedisCacheManagerImpl(ReactiveRedisTemplate<String, Object> reactiveRedisTemplate,
ReactiveRedisOperations<String, String> reactiveRedisOperations) {
this.reactiveRedisTemplate = reactiveRedisTemplate;
this.reactiveRedisOperations = reactiveRedisOperations;
}
@Override
public Mono<Object> get(String cacheName, String key) {
ensureStats(cacheName);
String path = cacheName + ":" + key;
return reactiveRedisTemplate.opsForValue().get(path)
.map(value -> {
//This is a cache hit, update stats and return value
statsMap.get(cacheName).getHits().incrementAndGet();
return value;
})
.switchIfEmpty(Mono.defer(() -> {
//This is a cache miss, update stats and return empty
statsMap.get(cacheName).getMisses().incrementAndGet();
return Mono.empty();
}));
}
@Override
public Mono<Boolean> put(String cacheName, String key, Object value) {
ensureStats(cacheName);
String path = cacheName + ":" + key;
return reactiveRedisTemplate.opsForValue().set(path, value);
}
@Override
public Mono<Void> evict(String cacheName, String key) {
ensureStats(cacheName);
statsMap.get(cacheName).getSingleEvictions().incrementAndGet();
String path = cacheName + ":" + key;
return reactiveRedisTemplate.delete(path).then();
}
@Override
public Mono<Void> evictAll(String cacheName) {
ensureStats(cacheName);
statsMap.get(cacheName).getCompleteEvictions().incrementAndGet();
String path = cacheName;
//Remove all matching keys with wildcard
final String script =
"for _,k in ipairs(redis.call('keys','" + path + ":*'))" +
" do redis.call('del',k) " +
"end";
return reactiveRedisOperations.execute(RedisScript.of(script)).then();
}
}

View File

@ -0,0 +1,34 @@
package com.appsmith.caching.model;
import java.util.concurrent.atomic.AtomicInteger;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* This is a CacheStats class that is used to store the stats of a cache.
* It is maintained for all cacheNames in the memory
*/
@Data
@NoArgsConstructor(staticName = "newInstance")
public class CacheStats {
/**
* The number of times the cache was hit.
*/
private AtomicInteger hits = new AtomicInteger(0);
/**
* The number of times the cache was missed.
*/
private AtomicInteger misses = new AtomicInteger(0);
/**
* The number of times the cache was evicted (single key).
*/
private AtomicInteger singleEvictions = new AtomicInteger(0);
/**
* The number of times the cache was evicted (all keys).
*/
private AtomicInteger completeEvictions = new AtomicInteger(0);
}

View File

@ -0,0 +1,11 @@
package com.appsmith.caching;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class CacheApplication {
public static void main(String[] args) {
SpringApplication.run(CacheApplication.class, args);
}
}

View File

@ -0,0 +1,58 @@
package com.appsmith.caching.configuration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.ReactiveRedisOperations;
import org.springframework.data.redis.core.ReactiveRedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
@Configuration
@Testcontainers
public class RedisTestContainerConfig {
@Container
public static GenericContainer redisContainer = new GenericContainer(DockerImageName.parse("redis:6.2.6-alpine"))
.withExposedPorts(6379);
@Bean
@Primary
public ReactiveRedisConnectionFactory reactiveRedisConnectionFactory() {
redisContainer.start();
RedisStandaloneConfiguration redisConf = new RedisStandaloneConfiguration(redisContainer.getHost(),
redisContainer.getMappedPort(6379));
return new LettuceConnectionFactory(redisConf);
}
@Primary
@Bean
ReactiveRedisTemplate<String, Object> reactiveRedisTemplate(ReactiveRedisConnectionFactory factory) {
RedisSerializer<String> keySerializer = new StringRedisSerializer();
RedisSerializer<Object> defaultSerializer = new GenericJackson2JsonRedisSerializer();
RedisSerializationContext<String, Object> serializationContext = RedisSerializationContext
.<String, Object>newSerializationContext(defaultSerializer).key(keySerializer).hashKey(keySerializer)
.build();
return new ReactiveRedisTemplate<>(factory, serializationContext);
}
@Primary
@Bean
ReactiveRedisOperations<String, String> reactiveRedisOperations(ReactiveRedisConnectionFactory factory) {
Jackson2JsonRedisSerializer<String> serializer = new Jackson2JsonRedisSerializer<>(String.class);
RedisSerializationContext.RedisSerializationContextBuilder<String, String> builder =
RedisSerializationContext.newSerializationContext(new StringRedisSerializer());
RedisSerializationContext<String, String> context = builder.value(serializer).build();
return new ReactiveRedisTemplate<>(factory, context);
}
}

View File

@ -0,0 +1,10 @@
package com.appsmith.caching.model;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor(staticName = "of")
public class ArgumentModel {
private String name;
}

View File

@ -0,0 +1,10 @@
package com.appsmith.caching.model;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode
public class NestedModel {
private int nestedIntValue;
}

View File

@ -0,0 +1,10 @@
package com.appsmith.caching.model;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode
public class ParentModel {
private int parentIntValue;
}

View File

@ -0,0 +1,19 @@
package com.appsmith.caching.model;
import java.time.Instant;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class TestModel extends ParentModel {
private int intValue;
private String stringValue;
private Integer integerValue;
private Boolean booleanValue;
private Long longValue;
private Double doubleValue;
private NestedModel nestedModel;
private String id;
}

View File

@ -0,0 +1,113 @@
package com.appsmith.caching.service;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Service;
import com.appsmith.caching.annotations.CacheEvict;
import com.appsmith.caching.annotations.Cache;
import com.appsmith.caching.model.ArgumentModel;
import com.appsmith.caching.model.TestModel;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import uk.co.jemos.podam.api.PodamFactory;
import uk.co.jemos.podam.api.PodamFactoryImpl;
@Service
public class CacheTestService {
PodamFactory factory = new PodamFactoryImpl();
/**
* This method is used to test the caching functionality for Mono<T>.
* @param id The id
* @return The Mono<TestModel> object, random every time
*/
@Cache(cacheName = "objectcache")
public Mono<TestModel> getObjectFor(String id) {
TestModel model = factory.manufacturePojo(TestModel.class);
model.setId(id);
return Mono.just(model).delayElement(Duration.ofSeconds(2));
}
/**
* This method is used to test the eviction functionality for Mono<T>.
* @param id The id
* @return Mono<Void> that completes after eviction
*/
@CacheEvict(cacheName = "objectcache")
public Mono<Void> evictObjectFor(String id) {
return Mono.empty();
}
/**
* This method is used to test eviction functionality for Mono<T>, complete cache.
* @return Mono<Void> that completes after eviction
*/
@CacheEvict(cacheName = "objectcache", all = true)
public Mono<Void> evictAllObjects() {
return Mono.empty();
}
/**
* This method is used to test the caching functionality for Flux<T>.
* @param id The id
* @return The Flux<TestModel>, random every time
*/
@Cache(cacheName = "listcache")
public Flux<TestModel> getListFor(String id) {
List<TestModel> testModels = new ArrayList<>();
for(int i = 0;i < 5;i++) {
TestModel model = factory.manufacturePojo(TestModel.class);
model.setId(id);
testModels.add(model);
}
return Flux.fromIterable(testModels).delayElements(Duration.ofMillis(200));
}
/**
* This method is used to test the eviction functionality for Flux<T>.
* @param id The id
* @return Mono<Void> that completes after eviction
*/
@CacheEvict(cacheName = "listcache")
public Mono<Void> evictListFor(String id) {
return Mono.empty();
}
/**
* This method is used to test eviction functionality for Flux<T>, complete cache.
* @return Mono<Void> that completes after eviction
*/
@CacheEvict(cacheName = "listcache", all = true)
public Mono<Void> evictAllLists() {
return Mono.empty();
}
/**
* This method is used to test SPEL expression in the caching annotation.
* @param ArgumentModel The argument model
* @return The Mono<TestModel> object, random every time
*/
@Cache(cacheName = "objectcache1", key = "#argumentModel.name")
public Mono<TestModel> getObjectForWithKey(ArgumentModel argumentModel) {
TestModel model = factory.manufacturePojo(TestModel.class);
model.setId(argumentModel.getName());
return Mono.just(model).delayElement(Duration.ofSeconds(2));
}
/**
* This method is used to test SPEL expression in the caching annotation.
* Key generated will be same as getObjectForWithKey but with different expression
* @param id The id
* @return The Mono<Boolean> that will complete after the item is removed.
*/
@CacheEvict(cacheName = "objectcache1", key = "#id")
public Mono<Void> evictObjectForWithKey(String id) {
return Mono.empty();
}
}

View File

@ -0,0 +1,124 @@
package com.appsmith.caching.test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import java.util.List;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import com.appsmith.caching.components.CacheManager;
import com.appsmith.caching.model.ArgumentModel;
import com.appsmith.caching.model.TestModel;
import com.appsmith.caching.service.CacheTestService;
import lombok.extern.slf4j.Slf4j;
@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@Slf4j
public class TestCachingMethods {
@Autowired
private CacheTestService cacheTestService;
@Autowired
private CacheManager cacheManager;
/**
* This Test is used to test the caching of a method that returns a Mono<T>
*/
@Test
public void testCacheAndEvictMono() {
TestModel model = cacheTestService.getObjectFor("test1").block();
TestModel model2 = cacheTestService.getObjectFor("test1").block();
assertEquals(model, model2);
cacheTestService.evictObjectFor("test1").block();
// If not evicted with above call, this will return the same object
model2 = cacheTestService.getObjectFor("test1").block();
assertNotEquals(model, model2);
}
/**
* This Test is used to test the caching of a method that returns a Flux<T>
*/
@Test
public void testCacheAndEvictFlux() {
List<TestModel> model = cacheTestService.getListFor("test1").collectList().block();
List<TestModel> model2 = cacheTestService.getListFor("test1").collectList().block();
assertArrayEquals(model.toArray(), model2.toArray());
cacheTestService.evictListFor("test1").block();
// If not evicted with above call, this will return the same object
model2 = cacheTestService.getListFor("test1").collectList().block();
for(int i = model.size() - 1; i >= 0; i--) {
assertNotEquals(model.get(i), model2.get(i));
}
}
/**
* This Test is used to test evict all
*/
@Test
public void testEvictAll() {
TestModel model1 = cacheTestService.getObjectFor("test1").block();
TestModel model2 = cacheTestService.getObjectFor("test2").block();
cacheTestService.evictAllObjects().block();
TestModel model1_2 = cacheTestService.getObjectFor("test1").block();
TestModel model2_2 = cacheTestService.getObjectFor("test2").block();
assertNotEquals(model1, model1_2);
assertNotEquals(model2, model2_2);
}
/**
* This Test is used to test SPEL expression in key field.
*/
@Test
public void testExpression() {
TestModel model = cacheTestService.getObjectForWithKey(ArgumentModel.of("test1")).block();
TestModel model2 = cacheTestService.getObjectForWithKey(ArgumentModel.of("test1")).block();
assertEquals(model, model2);
cacheTestService.evictObjectForWithKey("test1").block();
// If not evicted with above call, this will return the same object
model2 = cacheTestService.getObjectForWithKey(ArgumentModel.of("test1")).block();
assertNotEquals(model, model2);
}
/**
* Test to measure performance of caching
*/
@Test
public void measurePerformance() {
// Cache first
TestModel model1 = cacheTestService.getObjectFor("test1").block();
long initialTime = System.nanoTime();
int count = 100;
for(int i = 0; i < count; i++) {
cacheTestService.getObjectFor("test1").block();
}
long finalTime = System.nanoTime();
long timeTaken = finalTime - initialTime;
log.info("Time taken for cache operation " + (timeTaken / count) + " nanos");
}
/**
* Log stats in the end
*/
@AfterAll
public void tearDown() {
cacheManager.logStats();
}
}