JCache con Spring Boot e Hazelcast

La specifica JCache (JSR-107) non è riuscita ad entrare nell’ultima versione di Java EE (7) ma è già viva e supportata nel mondo circostante, per esempio da Spring. In realtà Spring aveva già una sua implementazione del sistema di caching che poi ha esteso per supportare la specifica (o quest’ultima ha preso ispirazione da Spring? Non sarebbe la prima volta…)

Una cache applicativa

Immaginiamo di avere un metodo della nostra logica di business che esegue operazioni onerose e che sono idempotenti (cioè rendono sempre lo stesso risultato e non hanno side-effect) a parità di input: sarebbe uno spreco di risorse rieseguire sempre le stesse operazioni per vedere sempre gli stessi risultati! Perché quindi non salvare temporaneamente in memoria questi risultati per velocizzare gli accessi successivi? Ecco che abbiamo bisogno di un sistema di caching, magari tramite AOP per non impattare il codice che già abbiamo: la specifica JCache serve proprio a questo!

Spring Cache vs JCache

La documentazione relativa alla gestione della cache è piuttosto esaustiva e mette a confronto l’implementazione di Spring con la specifica JCache: di fatto si somigliano molto, ma come spesso accade la specifica è leggermente indietro rispetto ai “vendor”, probabilmente per essere più generica e applicabile possibile. I due sistemi sono sostanzialmente uguali, tranne per il fatto che l’implementazione di Spring permette di usare Spring Expression Language (SpEL) per definire, per esempio, delle condizioni di attivabilità della cache.

Se non se ne ha necessità, si può tranquillamente rimanere sulla specifica, in modo da avere codice facilmente riusabile (non che sia difficile eventualmente “tradurlo”).

Conosciamo JCache

La specifica JCache definisce una serie di annotazioni da applicare ai metodi delle classi della nostra logica di business che determinano in che modo l’output del metodo debba essere “cachato” (o tolto dalla cache), vediamole nel dettaglio:

  • @CacheResult Applicata ad un metodo, inserisce in cache il suo risultato, usando come chiave i parametri di input del metodo stesso.

    @CacheResult(cacheName = "people")
    public Person getPersonBy(String username) { ... }
    

    Se non si vogliono usare tutti i parametri, è possibile marcare quelli interessati con @CacheKey.

    @CacheResult(cacheName = "people")
    public Person getPersonBy(@CacheKey String username, String email) { ... }
    

    A parità di username, il metodo verrà eseguito solo una volta. La durata degli elementi in cache e la logica di scadenza dipende tutta dall’implementazione JCache che si usa e non è coperta dalla specifica.

    Con questa annotazione è possibile anche “cachare” le eccezioni (valorizzando l’attributo exceptionCacheName) o forzare l’esecuzione del metodo (saltando la lettura dalla cache – con skipGet) e aggiornare la cache con il risultato.

  • @CachePutIl metodo annotato viene sempre eseguito (a differenza del caso precedente), e serve per aggiornare la cache in base ai parametri di ingresso: quello annotato con @CacheValue rappresenterà il nuovo valore, gli altri la chiave (a meno che anche qua non si usi @CacheKey).

    @CachePut(cacheName = "people")
    public void updatePersonBy(String username, @CacheValue Person updatedPerson) { ... }
    

    Oltre che mettere in cache anche le eccezioni (come nel caso precedente), si può anche decidere se applicare l’aggiornamento prima o dopo l’esecuzione del metodo (default).

  • @CacheRemove e @CacheRemoveAll Rimuovono rispettivamente dalla cache un valore (in base alla chiave) o tutti (ignorando la chiave se passata).

    @CacheRemove(cacheName = "people")
    public void removePersonBy(String username) { ... }
    
    @CacheRemoveAll(cacheName = "people")
    public void removePeople() { ... }
    

Infine, ci sono una serie di configurazioni a livello di singola annotazione (applicate come abbiamo detto a livello di metodo) che spesso vanno ripetute, come il nome della cache per esempio: possiamo ridurre il boilerplate grazie all’annotazione a livello di classe @CacheDefaults che applica i valori a tutti i metodi della classe interessati dalla cache.

@CacheDefaults(cacheName = "people")
public class PersonService {
   @CacheResult
   public Person getPersonBy(String username) { ... }
   ...
}

Ulteriori approfondimenti e confronti con Spring Cache possono essere trovati sul blog di Spring.

JCache e il supporto di Spring

Usare JCache con Spring, e in particolare con Spring Boot, è molto facile, soprattutto per la varietà di provider di cache che supporta. Tutti sono documentati piuttosto bene, a parte Hazelcast, che è proprio quella di cui parleremo! Perché Hazelcast? Se avete bisogno di una cache in memoria distribuita, per esempio in una architettura a microservizi, è una delle migliori scelte. La stessa scelta l’hanno fatta nomi come , MuleSoft e Alfresco.

Un po’ di codice

Se non avete un progetto Spring Boot, il miglior modo per cominciare è Spring Initializr. A questo punto, per usare JCache, sono necessarie tre dipendenze:

  1. l’API di specifica

    
       javax.cache
       cache-api
       1.0.0
    
    

  2. l’autoconfigurazione delle funzionalità di caching per Spring Boot

    
       org.springframework.boot
       spring-boot-starter-cache
    
    

  3. e infine il provider, Hazelcast

    
       com.hazelcast
       hazelcast
       3.8
    
    

A questo punto, in pieno stile Spring Boot, basta abilitare esplicitamente la cache tra i bean di configurazione con l’annotazione @EnableCaching, per esempio:

@EnableCaching
@SpringBootApplication
public class MyApplication {
   public static void main(String[] args) {
      SpringApplication.run(MyApplication.class, args);
   }
}

che attiva anche le annotazioni JCache se presenti nel classpath.

Spring JCache e Hazelcast

In linea generale basta questa configurazione, ma ogni provider ha bisogno di ulteriori parametri affinché tutto funzioni correttamente. La prima configurazione di Hazelcast con JCache però non è stata proprio banale perché il provider di cache veniva istanziato due volte! Perché?

Dal momento che Hazelcast è nel classpath, l’autoconfigurazione di Spring Boot (HazelcastAutoConfiguration) si attiva e inizializza un’istanza di Hazelcast, indipendentemente dal fatto che esista o meno JCache.
L’annotazione @EnableCaching a sua volta attiva JCache e se non stiamo attenti genera una nuova istanza del provider che ha a disposizione, ovvero Hazelcast. Ci ritroviamo quindi con una applicazione che ha due istanze di Hazelcast (che comunicano tramite tcp)! Come fare ad evitarlo? Sono necessarie due cose:

  1. creare una configurazione per Hazelcast, per esempio tramite il file di configurazione standard hazelcast.xml, dove è necessario specificare almeno due tag:

    
    
       my-app-cache 
       
        
       ...
    
    

    E’ necessario definire un instance-name alla configurazione definita da questo file. Inoltre, con il tag cache possiamo definire il nome della cache da usare (necessario al cacheName delle annotazioni) e tutta la logica di scadenza. E’ possibile anche definire la modalità di salvataggio dei dati in cache: di default è “BINARY”, questo comporta che chiavi e valori devono essere serializzabili.

  2. dire a Spring dove si trova il file di configurazione tramite la property apposita nel file application.yml (o sue varianti):
    spring.hazelcast.config=classpath:hazelcast.xml
    

    in modo che non si attivi l’inizializzazione con la configurazione di default

Con queste due accortezze la vostra cache funzionerà alla perfezione! Sul nostro GitHub è disponibile un progetto di prova (con diversi tag che evidenziano in che situazione accade la doppia istanza).

Ultime considerazioni

Di fatto cos’è una cache? Semplificando, non è altro che una “mappa” chiave-valore: è importante quindi capire come viene generata questa chiave, per non ritrovarsi dei valori inattesi.

Il comportamento di default della generazione delle chiavi in Spring è definito dalla classe SimpleKeyGenerator che si basa sui parametri di input del metodo annotato:

  • se non ha parametri, la chiave è SimpleKey.EMPTY
  • se ne ha uno, la chiave è l’istanza del parametro
  • se più di uno, viene istanziato l’oggetto SimpleKey contenente tutte le istanze di input

Va da se che diventa fondamentale implementare i metodi equals() e hashCode() degli input, altrimenti si rischia di non recuperare gli oggetti dalla cache, proprio come succede nelle Collections.

Personalmente trovo questo approccio troppo semplice, perché basta avere per esempio due metodi diversi senza argomenti di input associati alla stessa cache per avere collisione di chiave! Le accortezze sono due: o usare cache diverse o reimplementare il generatore delle chiavi. Sia Spring Cache che JCache prevedono questo scenario: basta implementare rispettivamente l’interfaccia org.springframework.cache.interceptor.KeyGenerator oppure javax.cache.annotation.CacheKeyGenerator. Se decidiamo di implementare CacheKeyGenerator, è necessario specificare l’istanza in ogni @CacheDefaults:

@CacheDefaults(cacheName = "my-cache", cacheKeyGenerator = MyKeyGenerator.class)
public class CachedService {...}

Visto che la questione è tutta legata a Spring, possiamo invece implementare KeyGenerator e centralizzare tutto in un bean di configurazione che estende CachingConfigurerSupport:

import org.springframework.cache.annotation.CachingConfigurerSupport;

@Configuration
@EnableCaching
public class CacheConfig extends CachingConfigurerSupport {

    @Override
    public KeyGenerator keyGenerator() {
        // new key generator
    }
}

Una nuova implementazione della chiave potrebbe per esempio prendere in considerazione almeno il nome del metodo (o anche della classe) oltre che degli input, in modo da avere una chiave univoca riferita a quel metodo di quella classe con particolari input. La firma dell’unico metodo da implementare di KeyGenerator ci permette infatti di risalire a tutto ciò.

Conclusioni

Abbiamo quindi visto come JCache permette di gestire la cache dei dati di ritorno da un metodo in modo non intrusivo, con poca configurazione. JCache, grazie all’AOP, separa la logica di business da quella di caching e a livello di specifica maschera tutta la gestione di inserimento, aggiornamento e cancellazione della cache a livello programmatico tramite annotations. La logica di “eviction”, ovvero delle modalità di rimozione automatica o scadenza è demandata del tutto al “vendor” che abbiamo scelto, perché propria delle capacità stesse del framework: è importante quindi scegliere l’implementazione in base alle proprie esigenze. In questo esempio, Hazelcast è un ottimo candidato se si ha bisogno di una cache in memoria distribuita, senza necessità di persistenza.

Andrea Como

Sono un software engineer focalizzato nella progettazione e sviluppo di applicazioni web in Java. Presso OmniaGroup ricopro il ruolo di Tech Leader sulle tecnologie legate alla piattaforma Java EE 5 (come WebSphere 7.0, EJB3, JPA 1 (EclipseLink), JSF 1.2 (Mojarra) e RichFaces 3) e Java EE 6 con JBoss AS 7, in particolare di CDI, JAX-RS, nonché di EJB 3.1, JPA2, JSF2 e RichFaces 4. Al momento mi occupo di ECM, in particolar modo sulla customizzazione di Alfresco 4 e sulla sua installazione con tecnologie da devops come Vagrant e Chef. In passato ho lavorato con la piattaforma alternativa alla enterprise per lo sviluppo web: Java SE 6, Tomcat 6, Hibernate 3 e Spring 2.5. Nei ritagli di tempo sviluppo siti web in PHP e ASP. Per maggiori informazioni consulta il mio . - -