Suche
Eine Einführung in das Java Caching API (JSR 107), Teil 2

Java in the Enterprise: Caching in Java EE

Anatole Tresch

Anatole Tresch

Anatole Tresch ist zurück mit seiner Kolumne “Java in the Enterprise”. Nachdem im letzten Beitrag eine kurze Einführung in das Thema „Caching“ und die grundsätzliche Funktionsweise des JSR 107 API gegeben wurde, geht es im zweiten Teil um weitere Anwendungsfälle und Funktionen sowie die Grundlagen des SPI.

Atomare Operationen

Im ersten Teil dieser Einführung in das Java Chaching API habe ich das Map-ähnliche Grund-API eines Caches gezeigt. Nun definiert JSR 107 zusätzliche atomare Funktionen, welche auf einem Cache ausgeführt werden können:

  • void remove(K key, V value) entfernt einen vorhandenen Eintrag genau dann, wenn der aktuelle Wert mit dem übergegebenen Wert übereinstimmt.
  • V getAndRemove(K key, V value) entfernt einen vorhandenen Eintrag und gibt den entfernten Wert zurück.
  • boolean replace(K key, V oldValue, V newValue) ersetzt den aktuellen Wert mit newValue, wenn der aktuelle Wert mit dem übergegebenen oldValue übereinstimmt.
  • boolean replace(K key, V newValue) ersetzt den aktuellen Wert mit newValue, wenn ein Eintrag zum Schlüssel key vorhanden ist.
  • V getAndReplace(K key, V value) ersetzt den aktuellen Wert mit newValue, wenn ein Eintrag zum Schlüssel key vorhanden ist, und gibt den zuletzt gültigen Wert zurück.
  • <T> T invoke(K key, EntryProcessor<K,V,T> entryProcessor,
    Object… arguments)
    throws EntryProcessorException

    Diese Methode führt eine EntryProcessor (funktionale Schnittstelle) gegen einen Eintrag mit dem angegebenen Schlüssel aus. Wenn der Eintrag nicht vorhanden ist, wird versucht, mit Hilfe des konfigurierten CacheLoader, den Wert zu laden. Ist der Wert immer noch null, wird ein Surrogat Cache.Entry mit dem Wert null an den Prozessor übergeben. Entry-Prozessoren erlauben, Cache-Einträge atomar zu löschen oder zu aktualisieren.
  • <T> T invokeAll(Set<? extends K> keys,
    EntryProcessor<K,V,T> entryProcessor,
    Object... arguments)
    throws EntryProcessorException

    Diese Methode macht das gleiche, wie die Methode davor, jedoch für mehrere Schlüssel gleichzeitig.
  • void loadall(Set<? extends K> keys,
    boolean replaceExistingValues,
    CompletionListener completionListener)
    Diese Methode ruft den konfigurierten CacheLoader auf, um die angeforderten Schlüssel zu laden. Optional kann hier ein CompletionListener übergeben werden.

Manuelle Konfiguration von Caches

Grundsätzlich können Caches auch mittels Vendor-spezifischen Konfigurationen definiert und mit dem JSR 107 API verwaltet werden. JSR 107 definiert aber auch eine programmatische Schnittstelle, um Caches zu konfigurieren. Nachfolgend werden wir uns die konfigurierbaren Aspekte näher betrachten. Instanzen von Configuration können dann einem CacheManager übergeben werden, der daraus einen neuen Cache erzeugt:

Configuration<K,V> config = …;
CacheManager manager = …;
Cache<K,V> cache = manager.createCache("helloCache", config);

Expiry Policies

Bei der Arbeit mit Caches sprengt die Menge von Daten im Cache womöglich die vorhandenen Ressourcen der aktuellen Laufzeitumgebung. Um Fehler aufgrund von fehlenden Ressourcen zu vermeiden, können gecachte Einträge automatisch wieder entfernt werden. Dies wird mit Hilfe einer ExpiryPolicy konfiguriert, die definiert, nach welcher Zeitdauer Cache-Einträge in Abhängigkeit von Erstellung, Zugriff und/oder einer Änderung wieder entfernt werden können. Die ExpiryPolicy kann beim Erstellen eines Caches konfiguriert werden und ist wie folgt definiert:

public interface ExpiryPolicy{
  Duration getExpiryForCreation(); // duration after creation until expiry
  Duration getExpiryForAccess();   // duration after access until expiry
  Duration getExpiryForUpdate();   // duration after update until expiry
}

Mit Duration.ZERO wird signalisiert, dass der Eintrag sofort entfernt werden kann. Geben die Methoden getExpiryForUpdate oder getExpiryForAccess null zurück, so werden die Standardeinstellungen verwendet (zum Beispiel des verwaltenden CacheManagers). Das Paket javax.cache.expiry stellt dazu verschiedene Klassen zur Verfügung:

  • AccessedExpiryPolicy definiert eine Policy, bei der ein Cache-Eintrag auf Basis seines letzten Zugriffs entfernt wird.
  • CreatedExpiryPolicy definiert eine Policy, bei der ein Cache-Eintrag auf Basis seines Erstellungszeitpunktes entfernt wird.
  • EternalExpiryPolicy definiert eine Policy, bei der ein Cache-Eintrag nie entfernt wird.
  • ModifiedExpiryPolicy definiert eine Policy, bei der ein Cache-Eintrag auf Basis seines Änderungszeitpunktes entfernt wird.
  • TouchedExpiryPolicy definiert eine Policy, bei der ein Cache-Eintrag auf Basis seines Zugriffszeitpunktes entfernt wird.

Integration

JSR 107 definiert weitere Interfaces, welche die Integration eines Caches mit externer Funktionalität vereinfachen sollen. Diese Artefakte können konfiguriert werden, wenn eine neue Cache-Instanz erstellt wird (wird später gezeigt):

  • Der CacheLoader abstrahiert den Mechanismus, wie Cache-Einträge vom Backend geladen werden können.
  • Der CacheWriter abstrahiert den Mechanismus, wie Cache-Einträge, welche geändert oder gelöscht werden, in das Backend repliziert werden.

Read-through and Write-through

Read-through und Write-Through sind weitere Aspekte, die für einen Cache konfiguriert werden können. Read-Through löst dabei jedes Mal einen Aufruf des CacheLoader aus, wenn ein Eintrag gelesen wird. Write-Through hingegen ruft den CacheWriter jedes Mal auf, wenn Werte hinzugefügt, geändert oder gelöscht werden.

Service Provider Interface (SPI)

Das wichtigste Interface ist der CachingProvider. Diese Schnittstelle modelliert eine Factory, über die auf Instanzen von CacheManager zugegriffen werden, welche mit den folgenden Parametern konfiguriert werden:

  • Den zu verwendeten ClassLoader
  • Einen providerabhängigen Cache-URI. Als Beispiel kann eine Konfigurationsdatei im Klassenpfad referenziert werden: classpath://META-INF/cacheconfig.xml. Die unterstützten URIs sind Vendor-abhängig.
  • Weitere Properties sind Vendor-abhängig.

Die CachingProvider-Schnittstelle definiert auch Methoden, um gewisse Standardwerte zu definieren, die immer dann zum Zug kommen, wenn diese als Parameter nicht explizit übergeben werden:

 

public interface CachingProvider extends Closeable {
  CacheManager getCacheManager(URI uri, ClassLoader classLoader,
                           	Properties properties);
  CacheManager getCacheManager(URI uri, ClassLoader classLoader);
  CacheManager getCacheManager();

  ClassLoader getDefaultClassLoader();
  URI getDefaultURI();
  Properties getDefaultProperties();

  void close();
  void close(ClassLoader classLoader);
  void close(URI uri, ClassLoader classLoader);

  boolean isSupported(OptionalFeature optionalFeature);
}

Die Aufzählung OptionalFeature ist wie folgt definiert:

 

public enum OptionalFeature {
  STORE_BY_REFERENCE
}

 

Implementierungen von CachingProvider werden mit dem java.util.ServiceLoader registriert. Der zu verwendende Default CachingProvider kann mit dem System Property javax.cache.CachingProvider gesetzt werden.

Ein Beispiel

Im folgenden Beispiel benutzen wir einen einfachen Cache, um die Antwort für eine bestimmte Servlet-Anfrage für zehn Sekunden zu cachen. Um unseren Cache entsprechend zu konfigurieren, benötigen wir eine Instanz von Configuration<K,V>, die sowohl Schlüssel- und Wertetyp, als auch ein Boolean-Flag für die Eigenschaft storeByValue definiert. Für das Erstellen einer Configuration bedienen wir uns der Klasse MutableConfiguration<K,V>, welche CompleteConfiguration<K,V> erweitert und am Ende dem CacheManager zum Erstellen des Caches übergeben wird.

In der GET-Methode des Servlet schließlich wird geprüft, ob ein gecacheder Wert vorhanden ist. Ist dies der Fall, so wird dieser in die Antwort integriert. Andernfalls wird ein neuer Wert erzeugt und für die nächsten 10 Sekunden im Cache abgelegt. Als Resultat wird das Servlet immer für jeweils 10 Sekunden die Antwort aus dem Cache zurückliefern:

 

import javax.cache.Cache;
import javax.cache.CacheBuilder;
import javax.cache.CacheManager;
import javax.cache.Caching;
...

@WebServlet("/helloDemo")
public class HelloServlet extends HttpServlet {

  private volatile Cache cache;

  public Cache cache(){
    if (cache == null){
      synchronized(this){
        if (cache == null){ 
          //building a cache
           CacheManager manager = 
                 Caching.getCachingProvider().getCacheManager();

           MutableConfiguration<String, String> config =
                         new MutableConfiguration<String, String>();
           config.setTypes(String.class, String.class)
                 .setStoreByValue(false)
                 .setStatisticsEnabled(true)
                 .setExpiryPolicyFactory(
                   FactoryBuilder.factoryOf(
                     new AccessedExpiryPolicy(new Duration(
                                                    TimeUnit.HOURS, 1))));

           cache = manager.createCache("helloCache", config);
        }
      }
    }
    return cache;
  }

  protected void doGet(HttpServletRequest request, 
                       HttpServletResponse response)
  throws ServletException, IOException {

    response.setContentType("text/plain");
    response.getWriter().append("\n");

    String message = cache().get("message");

    if (message == null) {
      message = "HELLO WORLD at POSIX/ms: " + System.currentTimeMillis();
      cache().put("message", message);
    }
    response.getWriter().append(message);
    response.getWriter().append("\n");
  }
}

Caching Annotations

JSR107 definiert einen Satz von Caching-Annotationen, welche es erlauben, Cache-Operationen auf Methoden-Ebene deskriptiv zu deklarieren. Grundsätzlich bilden diese Annotationen die zugrunde liegenden Cache-Operationen ab. Es sind folgende Annotationen verfügbar:

●     @CacheDefaults – Annotation auf Ebene der Klasse, um Standardeigenschaften zu definieren, welche von den Methodenannotationen genutzt werden. Die Annotation besitzt folgende Eigenschaften:

○     cacheName konfiguriert den benutzenden Cache. Wenn nichts festgelegt wurde, wird ein Default Cache-Name generiert.

○     cacheResolverFactory erlaubt es, eine CacheResolverFactory zu konfigurieren, um den zu benutzenden Cache dynamisch aufzulösen.

○     cacheKeyGenerator ermöglicht es, einen CacheKeyGenerator zu registrieren, der es erlaubt, den Schlüssel des zu lesenden/schreibenden Eintrages aufgrund der aktuellen Parameter dynamisch zu evaluieren. Ein möglicher Anwendungsfall wäre zum Beispiel die transparente Berücksichtigung des authentifizierten Benutzers, um einen Cache pro Benutzer zu isolieren.

    • @CacheResult – erlaubt es, Methoden zu annotieren, deren Rückgabewert gecached werden soll. Standardmäßig wird dabei ein Schlüssel erzeugt, welcher alle übergebenen Methodenparameter berücksichtigt. Die Annotation besitzt folgende Eigenschaften: cacheName, cacheResolverFactory, cacheKeyGenerator
    • cachedExceptions und nonCachedExceptions erlauben es zu definieren, ob und welche geworfenen Exceptions gecached werden sollen.
    • exceptionCacheName erlaubt es, für den Fehlerfall einen alternativen Cache zu benutzen.
    • skipGet überspringt das Lesen des Caches beim Methodeneintritt, das heißt, die Methode schreibt immer nur die Werte in den Cache.
    • Mit @CacheKey können auch lediglich Teilmengen der Methodenparameter für die Schlüsselgenerierung berücksichtigt werden.
    • @CachePut – kennzeichnet einen Methodenparameter, der im Cache gespeichert werden soll.
    • @CacheRemove – markiert eine Methode, die Einträge aus dem Cache entfernt. Die Annotation besitzt die folgenden Eigenschaften: cacheName, cacheResolverFactory, cacheKeyGenerator.
    • afterInvocation ermöglicht zu kontrollieren, ob der Eintrag nach Ausführung der Methode entfernt werden soll (default) oder vor der Ausführung.
    • evictFor erlaubt es, Ausnahmen zu definieren, welche zu einem Entfernen des Eintrages führen.
    • noEvictFor erlaubt es, Ausnahmen zu definieren, welche nicht zu einem Entfernen des Eintrages führen.
    • @CacheRemoveAll – Diese Annotation erlaubt es, Methoden zu annotieren, welche den Cache komplett zurücksetzen. Die Annotation ist analog zu @CacheRemove.

 

Bei der Implementierung der Interfaces CacheResolverFactory oder CacheKeyGenerator wird man auch auf eine Metadaten-Schnittstelle treffen, welche wir hier nicht weiter ansehen werden. Wer sich aber mit Reflection oder CDI etwas näher auskennt, wird sich sofort zurechtfinden.

Nachfolgend ist eine skelettartige Implementierung eines Daten-DAOs wiedergegeben, welche exemplarisch die Benutzung der erwähnten Annotationen zeigt:

 

@CacheDefaults(cacheName=”domainCache”)
public class DomainDao {

  @CacheResult
  public Domain getDomain(String domainId, Locale locale){...}
  
  @CacheRemove
  public void deleteDomain(String domainId, Locale locale){...}

  @CachePut
  public void updateDomain(String domainId, 
                           Locale locale,
                           @CacheValue Domain domain){...}
  
  @CacheResult(cacheName=”allDomains”)
  public List getDomains(Locale locale){...}

  @RemoveAll
  public void deleteAllDomains(){...}

}

Management

Das Java Caching API definiert im Packet javax.cache.management auch einige JMX-Management-Funktionen für die Cache-Verwaltung und -Statistiken:

CacheMXBean erlaubt Zugriff auf folgende Eigenschaften eines Caches:

      • den Schlüssel- und Wertetyp,
      • die Flags für Read-Through und Write-Through,
      • ist Store-by-value aktiv,
      • ist das Sammeln von Statistiken aktiviert,
      • ist Management aktiviert.

CacheStatisticsMXBean bietet folgende Attribute und Operationen:

      • Anzahl cache hits und Cache hit ratio (in Prozent),
      • Anzahl der cache misses und Cache miss ratio (in Prozent),
      • Anzahl der cache reads und puts
      • Anzahl der cache removals und evictions
      • Durchschnittliche Zeit für Zugriff,  das Schreiben und das Entfernen von Einträgen
      • Zurücksetzen der Statistik

Die Management-Funktionen können mit Hilfe des CacheManager für jeden Cache mit den Methoden enableManagement/enableStatistics gestartet oder gestoppt werden.

Kritik

Das JSR 107-Team hat sicher alles daran gelegt, ein umfassendes und einfach zu bedienendes API zu definieren, das auch mächtig genug ist, um alle wichtigen Anwendungsfälle abdecken zu können. Dennoch gibt es meiner Ansicht nach einige Punkte, die ich gerne erwähnen möchte.

      1. Der JSR definiert einige Interfaces, welche es erlauben, ausführbare Funktionalität zu übergeben, also klassische, sogenannte funktionale Interfaces. Leider sind nicht alle Interfaces als functional Interfaces im Sinne von Java 8 umgesetzt, was ich sehr schade finde. Dies trifft zum Beispiel für CompletionListener, CacheLoader und CacheWriter zu.
      2. Die ExpiryPolicy scheint mir ebenfalls für viele reale Anwendungsfälle zu unflexibel modelliert. Statt die Expiry nur an den Ablauf von festen Laufzeiten zu knüpfen, wäre es weitaus flexibler, das Management der Expiry an die ExpiryPolicy zu delegieren. Das könnte in etwa so aussehen:
        public interface ExpiryPolicy<K,V> {
            void entryCreated(Cache.Entry<K,V> entry);
            void entryAccessed(Cache.Entry<K,V> entry);
            void entryUpdated(Cache.Entry<K,V> entry);
            boolean isExpired(Cache.Entry<K,V> entry);
          }
      3. Schließlich könnten meiner Meinung nach auch andere Teile des APIs noch schlanker und minimalistischer ausfallen. Hier sei der CacheManager erwähnt, der unter anderem die folgenden Methoden deklariert: <K,V> Cache<K,V> getCache(String Cachename,
        Class<K> keyType, Class<V> valuetype);
        <K,V> Cache<K,V> getCache(String cachename);
        Im Grunde genommen ist die zweite Methode überflüssig. Ich kann ja der ersten Methode auch Object.class als Schlüssel-/Wertetyp übergeben und würde somit ebenfalls einen (nicht) typ-sicheren Cache erhalten.
      4. Auch aufgefallen ist mir die Art, wie Caches mittels Configuration<K, V> beim Erzeugen konfiguriert werden. Es wird ein minimalistisches Interface Configuration<K,V> definiert, welches nur den Schlüssel-, Wertetyp und die storeByValue Eigenschaft zurückgibt. Alle weiteren Konfigurationseigenschaften sind in einem erweiterten Interface CompleteConfiguration<K,V> definiert. Ich würde dann an dieser Stelle einen ConfigurationBuilder erwarten, welcher sodann Thread-sichere Konfigurationsobjekte (Werttypen) erzeugt. Doch die Expertengruppe hat sich dazu entschieden, eine veränderliche Klasse MutableConfiguration<K,V> zu definieren, welche sich zwar ähnlich wie ein Builder verhält, aber eben (leider) keiner ist.
      5. Ebenso interessant ist die Methode boolean Configuration.isStoreByValue(), welche eigentlich dieselbe Eigenschaft „umgekehrt redundant“ wie OptionalFeature.STORE_BY_REFERENCE modelliert, die in anderen Teilen des API, zum Beispiel im CacheProvider, verwendet wird.
      6. Das Handling von ClassLoaders ist wichtig, unabhängig davon, ob die Anwendung in einem eigenständigen SE-Kontext (zum Beispiel OSGi) oder in einem Java-EE-Applikationsserver läuft. Dabei den zu verwendenden ClassLoader bei der Erstellung von Caches zu übergeben, ergibt für mich einen Sinn. Aber warum muss ich einen Default-Klassenlader setzen können, der sodann (womöglich) global im Caching Singleton gemeinsam genutzt wird. Es braucht wenig Phantasie, um sich Szenarien auszudenken, wo das kräftig ins Auge gehen kann. Kommt noch hinzu, dass das Caching Singleton kein SPI anbietet, um diese Funktionalität sinnvoll anzupassen. Vielleicht ein Aspekt, den man im nächsten Maintenance-Release angehen könnte.
      7. Schließlich scheint mir die doppelte Indirektion von Caching Singleton in Kombination mit CacheManager und CacheProvider ebenfalls etwas unglücklich. Auf dem Caching Singleton existiert gerade mal eine einzige Methode, um auf einen Cache zuzugreifen. Für alle komplexen Anwendungsfälle bin ich gezwungen, die erheblich komplexere SPI-Klasse CacheProvider zu nutzen. Habe ich mehrere Provider registriert, so bin ich sogar gezwungen, einen voll qualifizierten Implementationsklassennamen zu übergeben. Erst dann erhalte ich Zugriff auf den CacheManager, mit dem ich programmatisch neue Cache-Instanzen erzeugen kann. Oder aber ich konfiguriere alles, dann aber eben wieder Vendor-abhängig.

Zusammenfassung

Nachdem ich mich durch die Spezifikation des JSR 107 gearbeitet habe, bleibt ein gemischtes Gefühl zurück. Sicher, das Java Caching API ist in weiten Teilen verständlich und meist einfach zu bedienen. Aber es bleibt auch der Nachgeschmack, dass trotz der langen Entwicklungszeit einige Teile noch knackiger, mit weniger Redundanz, einem flexibleren SPI und mit besserem Support für funktionale Aspekte daherkommen könnten. Dennoch ist das API ein großer Vorteil für das Java-Ökosystem, sowohl für Java EE als auch für Java SE basierte Laufzeitumgebungen.

Das API wird aktuell von allen namhaften Herstellern implementiert [1] und erlaubt es in vielen Fällen ohne herstellerspezifische Abhängigkeiten, Caching in eigenen Lösungen zu nutzen. Für erweiterte Funktionen, speziell im verteilten oder geclusterten Umfeld, ist eine Bindung an herstellerspezifische Artefakte jedoch wohl kaum zu umgehen.

Geschrieben von
Anatole Tresch
Anatole Tresch
A​natole Tresch studierte Wirtschaftsinformatik und war anschließend mehrere Jahre lang als Managing Partner und Berater aktiv. ​Die letzten Jahre war ​Anatole Tresch als technischer Architekt und Koordinator bei der Credit Suisse​ tätig. Aktuell arbeitet Anatole ​als Principal Consultant für die Trivadis AG, ist Specification Lead des JSR 354 (Java Money & Currency) und PPMC Member von Apache Tamaya. Twitter: @atsticks
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu: