Caching ist ein essenzielles Thema, wenn es um die Laufzeitoptimierung der eigenen Anwendung geht. Fachlich ist die größte Herausforderung sicherlich der Spagat zwischen Datenkonsistenz (niemand möchte veraltete Daten), Speicherbedarf (seinen gesamten Datenbestand in-Memory zu halten ist doch sehr fraglich) und sinnvollem Caching (Daten die häufig gelesen und selten geschrieben werden). Technisch ist der Schritt hin zum Caching, dank Spring Boot nur ein Kleiner.
Eine handvoll Annotationen reichen aus, um Spring Boot in die Lage zu versetzen Daten die durch einen Methodenaufruf bereit gestellt werden zu Cachen und somit bei den folgenden Aufrufen die Ausführung dieser Methode zu überspringen. Global wird das Caching über folgende Annotation aktiviert:
@EnableCaching
@SpringBootApplication
public class CachingRestApplication {....}
Nun können wir beliebige Methoden mit einer Caching Annotation versehen:
@Cacheable("avgAge")
public Double avgAge(){
return userRepository.findAll().stream()
.collect(Collectors.averagingInt(User::getAge));
}
Dabei vergeben wir einen eindeutigen Namen für den Cache, zusätzlich werden die Methodenparameter (hier keine) als Schlüssel verwendet, um Cache-Einträge zu identifizieren. Zusätzlich bietet diese Annotation eine ganze Reihe von weiteren Parametern, über die sich das Caching steuern lässt, z.B. folgende EL-Expressions:
- condition – wann soll gecacht werden?
- key – Schlüssel für den Cache-Eintrag, basierend auf den Methodenparametern (default: equals)
- unless – wann soll nicht gecacht werden?
Alles Neu
In aller Regel werden wir ein solches Caching wie anfangs bereits erwähnt vorzugsweise für Methoden machen dessen Datengrundlage sich nur sehr selten ändert, z.B. Konfigurationswerte oder Benutzereinstellung. Aber auch solche Daten können sich natürlich ändern. Hierfür existieren 2 weitere Annotationen die dazu dienen Einträge im Cache ab zu legen (@CachePut) oder bestehende Cache Einträge zu entfernen (@CacheEvict). Hier ein Beispiel für die Verwendung von @CacheEvict, dabei werden gleich zwei Caches aktualisiert:
@Override
@Caching(evict={
@CacheEvict(cacheNames = "users", key = "#entity.getId()"),
@CacheEvict(cacheNames = "avgAge", allEntries = true),
})
<S extends User> S save(S entity);
- users – entferne einen einzelnen Cache Eintrag dessen Key die ID des Users ist (default wäre erneut “equals”)
- avgAge – invalidiert den gesamten Cache, führt also beim nächsten Aufruf der Methoden oben zu einem “normalen” Durchlauf
Im Hintergrund
Bleibt die Frage, wo diese Daten denn nun landen? Im Standardfall ist die Implementierung des Cachings denkbar einfach: eine ConcurrentHashMap. Das erlaubt uns eine schnelle und einfache Implementierung des Cachings, lässt jedoch natürlich kaum Spielraum für weitere Anforderungen wie die Definition von Cache-Größen, TTL Zeiten oder erweitertes Monitoring. Hier kommen konkrete Cache-Implementierungen (JCache, Infinispan, Caffein, Cache2k…) ins Spiel, von denen sich viele nahtlos in die Anwendung einbinden lassen. In aller Regel reicht es die entsprechende Bibliothek im Classpath zu haben und ggf. Spring Boot auf die entsprechende Implementierung hinzuweisen. Anschließend können wir (abhängig von der Bibliothek) entsprechende Konfigurationen anwenden. Hier ein Beispiel mit Cache2k:
<dependency>
<groupId>org.cache2k</groupId>
<artifactId>cache2k-api</artifactId>
<version>2.6.1.Final</version>
</dependency>
<dependency>
<groupId>org.cache2k</groupId>
<artifactId>cache2k-core</artifactId>
<version>2.6.1.Final</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.cache2k</groupId>
<artifactId>cache2k-spring</artifactId>
<version>2.6.1.Final</version>
</dependency>
spring.cache.type=cache2k
Aktivierung von Cache2k.
Beispielhaft einige Konfigurationen für den Cache
@Configuration(proxyBeanMethods = false)
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
SpringCache2kCacheManager springCache2kCacheManager = new SpringCache2kCacheManager();
springCache2kCacheManager.setAllowUnknownCache(false);
springCache2kCacheManager.addCaches(
c -> c.name("avgAge").timerLag(1, TimeUnit.MINUTES),
c -> c.name("users").entryCapacity(2)
.addAsyncListener(new CacheLogListener()));
return springCache2kCacheManager;
}
private static class CacheLogListener implements CacheEntryCreatedListener, CacheEntryRemovedListener {
@Override
public void onEntryCreated(Cache cache, CacheEntry cacheEntry) {
System.out.println(cache.getName() + " created " + " :: " + cacheEntry.getKey());
}
@Override
public void onEntryRemoved(Cache cache, CacheEntry cacheEntry) {
System.out.println(cache.getName() + " removed " + " :: " + cacheEntry.getKey());
}
}
}
06 – wir registrieren den entsprechenden CacheManager als Bean
08 – wir deaktivieren das beliebige Verwenden von Cache-Namen, erwzingen also hier eine bewusste Deklaration der Caches ( folgen )
11 – Caches registrieren
12 – “avgAge” wird für 1 Minute gecacht
13 – “users” Cache behält nur die letzten beiden abgefragten User Objekte
14 – wir registrieren einen Listener, um auf Cache-Events zu reagieren
Die konkreten Möglichkeiten und Vorgehensweise der Konfiguration unterscheiden sich hier natürlich bei den unterschiedlichen Bibliotheken. Zentral dabei bleibt aber das unsere Anwendung hiervon nicht betroffen ist.