Skip to content

Commit 1c37f3e

Browse files
authored
feat: add cache-spring module (#44)
* feat: add module bridge-spring * chore: update pom description * chore: change editorconfig eol to lf * feat: rename to spring, apply some review suggestions * feat: impl get with valueLoader + tests * fix use await in spring eviction test * fix: use ConcurrentHashMap / ConcurrentHashMap.newKeySet
1 parent 8a51a70 commit 1c37f3e

File tree

7 files changed

+343
-1
lines changed

7 files changed

+343
-1
lines changed

.editorconfig

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ root = true
22

33
[*]
44
charset = utf-8
5-
end_of_line = crlf
5+
end_of_line = lf
66
indent_size = 4
77
indent_style = space
88
insert_final_newline = true

settings.gradle.kts

+2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ include(
55
":api",
66
":core",
77
":kotlin",
8+
":spring",
89
":provider-androidx",
910
":provider-cache2k",
1011
":provider-caffeine",
@@ -19,6 +20,7 @@ project(":bom").name = "cache-bom"
1920
project(":api").name = "cache-api"
2021
project(":core").name = "cache-core"
2122
project(":kotlin").name = "cache-kotlin"
23+
project(":spring").name = "cache-spring"
2224
project(":provider-androidx").name = "cache-provider-androidx"
2325
project(":provider-cache2k").name = "cache-provider-cache2k"
2426
project(":provider-caffeine").name = "cache-provider-caffeine"

spring/build.gradle.kts

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
dependencies {
2+
api(project(":cache-core"))
3+
implementation("org.springframework:spring-context:5.3.25")
4+
testImplementation("org.springframework.boot:spring-boot-starter-test:2.7.8")
5+
testImplementation("org.awaitility:awaitility:4.2.0")
6+
testImplementation(testFixtures(project(":cache-core")))
7+
testImplementation(project(":cache-provider-caffeine"))
8+
}
9+
10+
publishing.publications.withType<MavenPublication> {
11+
pom {
12+
name.set("Xanthic - Spring")
13+
description.set("Xanthic Cache Spring")
14+
}
15+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package io.github.xanthic.cache.spring;
2+
3+
import io.github.xanthic.cache.api.Cache;
4+
import org.jetbrains.annotations.NotNull;
5+
import org.springframework.cache.support.AbstractValueAdaptingCache;
6+
7+
import java.util.concurrent.Callable;
8+
9+
public class XanthicSpringCache extends AbstractValueAdaptingCache {
10+
private final String name;
11+
private final Cache<Object, Object> cache;
12+
13+
public XanthicSpringCache(String name, Cache<Object, Object> cache) {
14+
super(true);
15+
this.name = name;
16+
this.cache = cache;
17+
}
18+
19+
@Override
20+
public @NotNull String getName() {
21+
return name;
22+
}
23+
24+
@Override
25+
public @NotNull Object getNativeCache() {
26+
return cache;
27+
}
28+
29+
@SuppressWarnings("unchecked")
30+
@Override
31+
public <T> T get(@NotNull Object key, @NotNull Callable<T> valueLoader) {
32+
return (T) cache.computeIfAbsent(key, k -> getSynchronized(key, valueLoader));
33+
}
34+
35+
@SuppressWarnings("unchecked")
36+
private synchronized <T> T getSynchronized(Object key, Callable<T> valueLoader) {
37+
ValueWrapper result = get(key);
38+
39+
if (result != null) {
40+
return (T) result.get();
41+
}
42+
43+
T value;
44+
try {
45+
value = valueLoader.call();
46+
} catch (Exception e) {
47+
throw new ValueRetrievalException(key, valueLoader, e);
48+
}
49+
return value;
50+
}
51+
52+
@Override
53+
public void put(@NotNull Object key, Object value) {
54+
cache.put(key, toStoreValue(value));
55+
}
56+
57+
@Override
58+
public void evict(@NotNull Object key) {
59+
cache.remove(key);
60+
}
61+
62+
@Override
63+
public void clear() {
64+
cache.clear();
65+
}
66+
67+
@Override
68+
protected Object lookup(@NotNull Object key) {
69+
return cache.get(key);
70+
}
71+
}
72+
73+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package io.github.xanthic.cache.spring;
2+
3+
import io.github.xanthic.cache.core.CacheApi;
4+
import io.github.xanthic.cache.core.CacheApiSpec;
5+
import lombok.Getter;
6+
import org.jetbrains.annotations.NotNull;
7+
import org.springframework.cache.Cache;
8+
import org.springframework.cache.CacheManager;
9+
import org.springframework.lang.Nullable;
10+
11+
import java.util.Collection;
12+
import java.util.Map;
13+
import java.util.Set;
14+
import java.util.concurrent.ConcurrentHashMap;
15+
import java.util.function.Consumer;
16+
17+
/**
18+
* CacheManager implementation that lazily builds XanthicCache instances for each getCache(java.lang.String) request.
19+
* Also supports a 'static' mode where the set of cache names is pre-defined through cacheNames in the constructor, with no dynamic creation of further cache regions at runtime.
20+
* The configuration of the underlying cache can be fine-tuned through the CacheApiSpec, passed into this CacheManager in the constructor.
21+
*/
22+
public class XanthicSpringCacheManager implements CacheManager {
23+
24+
private final Map<String, Cache> cacheMap = new ConcurrentHashMap<>();
25+
@Getter
26+
private final Set<String> customCacheNames = ConcurrentHashMap.newKeySet();
27+
private final Consumer<CacheApiSpec<Object, Object>> spec;
28+
private final boolean dynamic;
29+
30+
/**
31+
* XanthicSpringCacheManager will manage all xanthic cache instances for spring.
32+
*
33+
* @param spec the default CacheApiSpec used to create a new cache instances
34+
*/
35+
public XanthicSpringCacheManager(Consumer<CacheApiSpec<Object, Object>> spec) {
36+
this.spec = spec;
37+
this.dynamic = true;
38+
}
39+
40+
/**
41+
* XanthicSpringCacheManager will manage all xanthic cache instances for spring.
42+
*
43+
* @param spec the default CacheApiSpec used to create a new cache instances
44+
* @param cacheNames If not null, the number of caches and their names will be fixed, with no creation of further cache keys at runtime.
45+
*/
46+
public XanthicSpringCacheManager(Consumer<CacheApiSpec<Object, Object>> spec, @Nullable Collection<String> cacheNames) {
47+
this.spec = spec;
48+
49+
if (cacheNames != null) {
50+
this.dynamic = false;
51+
for (String name : cacheNames) {
52+
this.cacheMap.put(name, createCache(name, this.spec));
53+
}
54+
} else {
55+
this.dynamic = true;
56+
}
57+
}
58+
59+
@Override
60+
@Nullable
61+
public Cache getCache(@NotNull String name) {
62+
return this.cacheMap.computeIfAbsent(name, cacheName -> this.dynamic ? createCache(cacheName, this.spec) : null);
63+
}
64+
65+
@Override
66+
public @NotNull Collection<String> getCacheNames() {
67+
return this.cacheMap.keySet();
68+
}
69+
70+
/**
71+
* Register a custom xanthic cache by customizing the CacheApiSpec.
72+
*
73+
* @param name the name of the cache
74+
* @param spec configuration for the specified cache
75+
*/
76+
public void registerCache(String name, Consumer<CacheApiSpec<Object, Object>> spec) {
77+
if (!this.dynamic) throw new IllegalStateException("CacheManager has a fixed set of cache keys and does not allow creation of new caches.");
78+
79+
this.cacheMap.put(name, createCache(name, spec));
80+
this.customCacheNames.add(name);
81+
}
82+
83+
private Cache createCache(String name, Consumer<CacheApiSpec<Object, Object>> spec) {
84+
return new XanthicSpringCache(name, CacheApi.create(spec));
85+
}
86+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package io.github.xanthic.cache.spring;
2+
3+
import io.github.xanthic.cache.spring.config.CacheConfiguration;
4+
import org.junit.jupiter.api.Assertions;
5+
import org.junit.jupiter.api.DisplayName;
6+
import org.junit.jupiter.api.Test;
7+
import org.junit.jupiter.api.extension.ExtendWith;
8+
import org.springframework.beans.factory.annotation.Autowired;
9+
import org.springframework.cache.Cache;
10+
import org.springframework.cache.CacheManager;
11+
import org.springframework.test.context.ContextConfiguration;
12+
import org.springframework.test.context.junit.jupiter.SpringExtension;
13+
14+
import java.util.Objects;
15+
import java.util.concurrent.Callable;
16+
import java.util.concurrent.ExecutorService;
17+
import java.util.concurrent.Executors;
18+
import java.util.concurrent.TimeUnit;
19+
import java.util.concurrent.atomic.AtomicInteger;
20+
21+
import static org.awaitility.Awaitility.await;
22+
23+
@ExtendWith(SpringExtension.class)
24+
@ContextConfiguration(classes = { CacheConfiguration.class })
25+
public class SpringCacheTest {
26+
27+
@Autowired
28+
CacheManager cacheManager;
29+
30+
@Test
31+
@DisplayName("Tests cache get, put, putIfAbsent, clear")
32+
public void putGetClearTest() {
33+
Cache cache = Objects.requireNonNull(cacheManager.getCache("dev"));
34+
35+
// cache should be listed in cacheManager
36+
Assertions.assertTrue(cacheManager.getCacheNames().contains("dev"));
37+
38+
// Test put/get
39+
cache.put("4/20", 420);
40+
Assertions.assertEquals(420, Objects.requireNonNull(cache.get("4/20")).get());
41+
42+
// Test putIfAbsent
43+
Assertions.assertEquals(420, Objects.requireNonNull(cache.putIfAbsent("4/20", 69)).get());
44+
Assertions.assertEquals(420, Objects.requireNonNull(cache.get("4/20")).get());
45+
46+
// Test clear
47+
cache.clear();
48+
Assertions.assertNull(cache.get("4/20"));
49+
}
50+
51+
@Test
52+
@DisplayName("Tests the registration and usage of a custom cache")
53+
public void registerCustomCacheTest() {
54+
XanthicSpringCacheManager xanthicSpringCacheManager = (XanthicSpringCacheManager) cacheManager;
55+
xanthicSpringCacheManager.registerCache("my-custom-cache", spec -> {
56+
spec.maxSize(1L);
57+
});
58+
59+
// registration check
60+
Assertions.assertTrue(xanthicSpringCacheManager.getCustomCacheNames().contains("my-custom-cache"), "getCustomCacheNames should contain my-custom-cache");
61+
62+
// cache available
63+
Cache cache = cacheManager.getCache("my-custom-cache");
64+
Assertions.assertNotNull(cache, "my-custom-cache should not be null");
65+
}
66+
67+
@Test
68+
@DisplayName("Tests the eviction of entries based on max size")
69+
public void evictionTest() {
70+
XanthicSpringCacheManager xanthicSpringCacheManager = (XanthicSpringCacheManager) cacheManager;
71+
xanthicSpringCacheManager.registerCache("small-cache", spec -> {
72+
spec.maxSize(2L);
73+
});
74+
75+
Cache cache = Objects.requireNonNull(cacheManager.getCache("small-cache"));
76+
cache.put("first", 1);
77+
cache.put("second", 2);
78+
cache.put("third", 3);
79+
80+
Assertions.assertEquals(2, Objects.requireNonNull(cache.get("second")).get());
81+
Assertions.assertEquals(3, Objects.requireNonNull(cache.get("third")).get());
82+
await().atLeast(100, TimeUnit.MILLISECONDS)
83+
.atMost(5, TimeUnit.SECONDS)
84+
.until(() -> cache.get("first") == null);
85+
}
86+
87+
@Test
88+
@DisplayName("Tests the eviction of entries based on max size")
89+
public void valueLoaderTest() {
90+
XanthicSpringCacheManager xanthicSpringCacheManager = (XanthicSpringCacheManager) cacheManager;
91+
xanthicSpringCacheManager.registerCache("value-cache", spec -> {
92+
spec.maxSize(100L);
93+
});
94+
Cache cache = Objects.requireNonNull(cacheManager.getCache("value-cache"));
95+
96+
AtomicInteger callCounter = new AtomicInteger(0);
97+
Callable<String> valueLoader = () -> {
98+
callCounter.incrementAndGet();
99+
return "value-loaded";
100+
};
101+
102+
String value = cache.get("key", valueLoader);
103+
Assertions.assertEquals("value-loaded", value);
104+
Assertions.assertEquals(1, callCounter.get(), "Value loader should only be called once");
105+
106+
// Check that the valueLoader is not called again
107+
value = cache.get("key", valueLoader);
108+
Assertions.assertEquals("value-loaded", value);
109+
Assertions.assertEquals(1, callCounter.get(), "Value loader should still be called only once");
110+
}
111+
112+
@Test
113+
@DisplayName("Tests the eviction of entries based on max size")
114+
public void valueLoaderConcurrentTest() throws InterruptedException {
115+
XanthicSpringCacheManager xanthicSpringCacheManager = (XanthicSpringCacheManager) cacheManager;
116+
xanthicSpringCacheManager.registerCache("value-cache", spec -> {
117+
spec.maxSize(100L);
118+
});
119+
Cache cache = Objects.requireNonNull(cacheManager.getCache("value-cache"));
120+
121+
AtomicInteger callCounter = new AtomicInteger(0);
122+
Callable<String> valueLoader = () -> {
123+
callCounter.incrementAndGet();
124+
return "value-loaded";
125+
};
126+
127+
int numThreads = 10;
128+
ExecutorService executorService = Executors.newFixedThreadPool(numThreads);
129+
for (int i = 0; i < numThreads; i++) {
130+
executorService.submit(() -> {
131+
String value = cache.get("key", valueLoader);
132+
Assertions.assertEquals("value-loaded", value);
133+
});
134+
}
135+
executorService.awaitTermination(5, TimeUnit.SECONDS);
136+
137+
String value = cache.get("key", valueLoader);
138+
Assertions.assertEquals("value-loaded", value);
139+
Assertions.assertEquals(1, callCounter.get(), "Value loader should only be called once");
140+
}
141+
142+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package io.github.xanthic.cache.spring.config;
2+
3+
import io.github.xanthic.cache.api.domain.ExpiryType;
4+
import io.github.xanthic.cache.spring.XanthicSpringCacheManager;
5+
import org.springframework.cache.CacheManager;
6+
import org.springframework.context.annotation.Bean;
7+
import org.springframework.context.annotation.Configuration;
8+
9+
@Configuration
10+
public class CacheConfiguration {
11+
12+
@Bean
13+
public CacheManager cacheManager() {
14+
XanthicSpringCacheManager cacheManager = new XanthicSpringCacheManager(spec -> {
15+
spec.expiryType(ExpiryType.POST_ACCESS);
16+
});
17+
cacheManager.registerCache("my-custom-cache", spec -> {
18+
spec.maxSize(10L);
19+
});
20+
21+
return cacheManager;
22+
}
23+
24+
}

0 commit comments

Comments
 (0)