Una guida a Infinispan in Java

1. Panoramica

In questa guida, impareremo a conoscere Infinispan, un archivio dati chiave / valore in memoria che viene fornito con un set di funzionalità più robusto rispetto ad altri strumenti della stessa nicchia.

Per capire come funziona, costruiremo un semplice progetto che mostri le caratteristiche più comuni e controlliamo come possono essere utilizzate.

2. Configurazione del progetto

Per poterlo usare in questo modo, dovremo aggiungere la sua dipendenza nel nostro pom.xml .

L'ultima versione può essere trovata nel repository Maven Central:

 org.infinispan infinispan-core 9.1.5.Final 

D'ora in poi, tutta l'infrastruttura sottostante necessaria verrà gestita in modo programmatico.

3. Installazione di CacheManager

Il CacheManager è la base della maggior parte delle funzionalità che utilizzeremo. Funge da contenitore per tutte le cache dichiarate, controlla il loro ciclo di vita ed è responsabile della configurazione globale.

Infinispan viene fornito con un modo davvero semplice per creare il CacheManager :

public DefaultCacheManager cacheManager() { return new DefaultCacheManager(); }

Ora siamo in grado di costruire le nostre cache con esso.

4. Installazione cache

Una cache è definita da un nome e una configurazione. La configurazione necessaria può essere costruita utilizzando la classe ConfigurationBuilder , già disponibile nel nostro classpath.

Per testare le nostre cache, costruiremo un metodo semplice che simula alcune query pesanti:

public class HelloWorldRepository { public String getHelloWorld() { try { System.out.println("Executing some heavy query"); Thread.sleep(1000); } catch (InterruptedException e) { // ... e.printStackTrace(); } return "Hello World!"; } }

Inoltre, per essere in grado di controllare le modifiche nelle nostre cache, Infinispan fornisce una semplice annotazione @Listener .

Quando definiamo la nostra cache, possiamo passare qualche oggetto interessato a qualsiasi evento che accade al suo interno, e Infinispan lo notificherà durante la gestione della cache:

@Listener public class CacheListener { @CacheEntryCreated public void entryCreated(CacheEntryCreatedEvent event) { this.printLog("Adding key '" + event.getKey() + "' to cache", event); } @CacheEntryExpired public void entryExpired(CacheEntryExpiredEvent event) { this.printLog("Expiring key '" + event.getKey() + "' from cache", event); } @CacheEntryVisited public void entryVisited(CacheEntryVisitedEvent event) { this.printLog("Key '" + event.getKey() + "' was visited", event); } @CacheEntryActivated public void entryActivated(CacheEntryActivatedEvent event) { this.printLog("Activating key '" + event.getKey() + "' on cache", event); } @CacheEntryPassivated public void entryPassivated(CacheEntryPassivatedEvent event) { this.printLog("Passivating key '" + event.getKey() + "' from cache", event); } @CacheEntryLoaded public void entryLoaded(CacheEntryLoadedEvent event) { this.printLog("Loading key '" + event.getKey() + "' to cache", event); } @CacheEntriesEvicted public void entriesEvicted(CacheEntriesEvictedEvent event) { StringBuilder builder = new StringBuilder(); event.getEntries().forEach( (key, value) -> builder.append(key).append(", ")); System.out.println("Evicting following entries from cache: " + builder.toString()); } private void printLog(String log, CacheEntryEvent event) { if (!event.isPre()) { System.out.println(log); } } }

Prima di stampare il nostro messaggio controlliamo se l'evento notificato è già avvenuto, perché, per alcuni tipi di evento, Infinispan invia due notifiche: una prima e una subito dopo che è stata elaborata.

Ora costruiamo un metodo per gestire la creazione della cache per noi:

private  Cache buildCache( String cacheName, DefaultCacheManager cacheManager, CacheListener listener, Configuration configuration) { cacheManager.defineConfiguration(cacheName, configuration); Cache cache = cacheManager.getCache(cacheName); cache.addListener(listener); return cache; }

Si noti come si passa una configurazione a CacheManager e quindi si utilizza lo stesso cacheName per ottenere l'oggetto corrispondente alla cache desiderata. Nota anche come informiamo l'ascoltatore sull'oggetto cache stesso.

Verificheremo ora cinque diverse configurazioni della cache e vedremo come configurarle e utilizzarle al meglio.

4.1. Cache semplice

Il tipo più semplice di cache può essere definito in una riga, utilizzando il nostro metodo buildCache :

public Cache simpleHelloWorldCache( DefaultCacheManager cacheManager, CacheListener listener) { return this.buildCache(SIMPLE_HELLO_WORLD_CACHE, cacheManager, listener, new ConfigurationBuilder().build()); }

Ora possiamo creare un servizio :

public String findSimpleHelloWorld() { String cacheKey = "simple-hello"; return simpleHelloWorldCache .computeIfAbsent(cacheKey, k -> repository.getHelloWorld()); }

Nota come usiamo la cache, controllando prima se la voce desiderata è già memorizzata nella cache. In caso contrario, dovremo chiamare il nostro repository e quindi memorizzarlo nella cache.

Aggiungiamo un metodo semplice nei nostri test per cronometrare i nostri metodi:

protected  long timeThis(Supplier supplier) { long millis = System.currentTimeMillis(); supplier.get(); return System.currentTimeMillis() - millis; }

Testandolo, possiamo controllare il tempo che intercorre tra l'esecuzione di due chiamate di metodo:

@Test public void whenGetIsCalledTwoTimes_thenTheSecondShouldHitTheCache() { assertThat(timeThis(() -> helloWorldService.findSimpleHelloWorld())) .isGreaterThanOrEqualTo(1000); assertThat(timeThis(() -> helloWorldService.findSimpleHelloWorld())) .isLessThan(100); }

4.2. Cache di scadenza

Possiamo definire una cache in cui tutte le voci hanno una durata, in altre parole, gli elementi verranno rimossi dalla cache dopo un dato periodo. La configurazione è abbastanza semplice:

private Configuration expiringConfiguration() { return new ConfigurationBuilder().expiration() .lifespan(1, TimeUnit.SECONDS) .build(); }

Ora costruiamo la nostra cache utilizzando la configurazione precedente:

public Cache expiringHelloWorldCache( DefaultCacheManager cacheManager, CacheListener listener) { return this.buildCache(EXPIRING_HELLO_WORLD_CACHE, cacheManager, listener, expiringConfiguration()); }

E infine, usalo in un metodo simile dalla nostra semplice cache sopra:

public String findSimpleHelloWorldInExpiringCache() { String cacheKey = "simple-hello"; String helloWorld = expiringHelloWorldCache.get(cacheKey); if (helloWorld == null) { helloWorld = repository.getHelloWorld(); expiringHelloWorldCache.put(cacheKey, helloWorld); } return helloWorld; }

Testiamo di nuovo i nostri tempi:

@Test public void whenGetIsCalledTwoTimesQuickly_thenTheSecondShouldHitTheCache() { assertThat(timeThis(() -> helloWorldService.findExpiringHelloWorld())) .isGreaterThanOrEqualTo(1000); assertThat(timeThis(() -> helloWorldService.findExpiringHelloWorld())) .isLessThan(100); }

Running it, we see that in quick succession the cache hits. To showcase that the expiration is relative to its entry put time, let's force it in our entry:

@Test public void whenGetIsCalledTwiceSparsely_thenNeitherHitsTheCache() throws InterruptedException { assertThat(timeThis(() -> helloWorldService.findExpiringHelloWorld())) .isGreaterThanOrEqualTo(1000); Thread.sleep(1100); assertThat(timeThis(() -> helloWorldService.findExpiringHelloWorld())) .isGreaterThanOrEqualTo(1000); }

After running the test, note how after the given time our entry was expired from the cache. We can confirm this by looking at the printed log lines from our listener:

Executing some heavy query Adding key 'simple-hello' to cache Expiring key 'simple-hello' from cache Executing some heavy query Adding key 'simple-hello' to cache

Note that the entry is expired when we try to access it. Infinispan checks for an expired entry in two moments: when we try to access it or when the reaper thread scans the cache.

We can use expiration even in caches without it in their main configuration. The method put accepts more arguments:

simpleHelloWorldCache.put(cacheKey, helloWorld, 10, TimeUnit.SECONDS);

Or, instead of a fixed lifespan, we can give our entry a maximum idleTime:

simpleHelloWorldCache.put(cacheKey, helloWorld, -1, TimeUnit.SECONDS, 10, TimeUnit.SECONDS);

Using -1 to the lifespan attribute, the cache won't suffer expiration from it, but when we combine it with 10 seconds of idleTime, we tell Infinispan to expire this entry unless it is visited in this timeframe.

4.3. Cache Eviction

In Infinispan we can limit the number of entries in a given cache with the eviction configuration:

private Configuration evictingConfiguration() { return new ConfigurationBuilder() .memory().evictionType(EvictionType.COUNT).size(1) .build(); }

In this example, we're limiting the maximum entries in this cache to one, meaning that, if we try to enter another one, it'll be evicted from our cache.

Again, the method is similar to the already presented here:

public String findEvictingHelloWorld(String key) { String value = evictingHelloWorldCache.get(key); if(value == null) { value = repository.getHelloWorld(); evictingHelloWorldCache.put(key, value); } return value; }

Let's build our test:

@Test public void whenTwoAreAdded_thenFirstShouldntBeAvailable() { assertThat(timeThis( () -> helloWorldService.findEvictingHelloWorld("key 1"))) .isGreaterThanOrEqualTo(1000); assertThat(timeThis( () -> helloWorldService.findEvictingHelloWorld("key 2"))) .isGreaterThanOrEqualTo(1000); assertThat(timeThis( () -> helloWorldService.findEvictingHelloWorld("key 1"))) .isGreaterThanOrEqualTo(1000); }

Running the test, we can look at our listener log of activities:

Executing some heavy query Adding key 'key 1' to cache Executing some heavy query Evicting following entries from cache: key 1, Adding key 'key 2' to cache Executing some heavy query Evicting following entries from cache: key 2, Adding key 'key 1' to cache

Check how the first key was automatically removed from the cache when we inserted the second one, and then, the second one removed also to give room for our first key again.

4.4. Passivation Cache

The cache passivation is one of the powerful features of Infinispan. By combining passivation and eviction, we can create a cache that doesn't occupy a lot of memory, without losing information.

Let's have a look at a passivation configuration:

private Configuration passivatingConfiguration() { return new ConfigurationBuilder() .memory().evictionType(EvictionType.COUNT).size(1) .persistence() .passivation(true) // activating passivation .addSingleFileStore() // in a single file .purgeOnStartup(true) // clean the file on startup .location(System.getProperty("java.io.tmpdir")) .build(); }

We're again forcing just one entry in our cache memory, but telling Infinispan to passivate the remaining entries, instead of just removing them.

Let's see what happens when we try to fill more than one entry:

public String findPassivatingHelloWorld(String key) { return passivatingHelloWorldCache.computeIfAbsent(key, k -> repository.getHelloWorld()); }

Let's build our test and run it:

@Test public void whenTwoAreAdded_thenTheFirstShouldBeAvailable() { assertThat(timeThis( () -> helloWorldService.findPassivatingHelloWorld("key 1"))) .isGreaterThanOrEqualTo(1000); assertThat(timeThis( () -> helloWorldService.findPassivatingHelloWorld("key 2"))) .isGreaterThanOrEqualTo(1000); assertThat(timeThis( () -> helloWorldService.findPassivatingHelloWorld("key 1"))) .isLessThan(100); }

Now let's look at our listener activities:

Executing some heavy query Adding key 'key 1' to cache Executing some heavy query Passivating key 'key 1' from cache Evicting following entries from cache: key 1, Adding key 'key 2' to cache Passivating key 'key 2' from cache Evicting following entries from cache: key 2, Loading key 'key 1' to cache Activating key 'key 1' on cache Key 'key 1' was visited

Note how many steps did it take to keep our cache with only one entry. Also, note the order of steps – passivation, eviction and then loading followed by activation. Let's see what those steps mean:

  • Passivation – our entry is stored in another place, away from the mains storage of Infinispan (in this case, the memory)
  • Eviction – the entry is removed, to free memory and to keep the configured maximum number of entries in the cache
  • Loading – when trying to reach our passivated entry, Infinispan checks it's stored contents and load the entry to the memory again
  • Activation – the entry is now accessible in Infinispan again

4.5. Transactional Cache

Infinispan viene fornito con un potente controllo delle transazioni. Come la controparte del database, è utile per mantenere l'integrità mentre più di un thread sta cercando di scrivere la stessa voce.

Vediamo come possiamo definire una cache con capacità transazionali:

private Configuration transactionalConfiguration() { return new ConfigurationBuilder() .transaction().transactionMode(TransactionMode.TRANSACTIONAL) .lockingMode(LockingMode.PESSIMISTIC) .build(); }

Per renderlo possibile testarlo, creiamo due metodi: uno che termini rapidamente la sua transazione e uno che richiede un po 'di tempo:

public Integer getQuickHowManyVisits() { TransactionManager tm = transactionalCache .getAdvancedCache().getTransactionManager(); tm.begin(); Integer howManyVisits = transactionalCache.get(KEY); howManyVisits++; System.out.println("I'll try to set HowManyVisits to " + howManyVisits); StopWatch watch = new StopWatch(); watch.start(); transactionalCache.put(KEY, howManyVisits); watch.stop(); System.out.println("I was able to set HowManyVisits to " + howManyVisits + " after waiting " + watch.getTotalTimeSeconds() + " seconds"); tm.commit(); return howManyVisits; }
public void startBackgroundBatch() { TransactionManager tm = transactionalCache .getAdvancedCache().getTransactionManager(); tm.begin(); transactionalCache.put(KEY, 1000); System.out.println("HowManyVisits should now be 1000, " + "but we are holding the transaction"); Thread.sleep(1000L); tm.rollback(); System.out.println("The slow batch suffered a rollback"); }

Ora creiamo un test che esegua entrambi i metodi e controlliamo come si comporterà Infinispan:

@Test public void whenLockingAnEntry_thenItShouldBeInaccessible() throws InterruptedException { Runnable backGroundJob = () -> transactionalService.startBackgroundBatch(); Thread backgroundThread = new Thread(backGroundJob); transactionalService.getQuickHowManyVisits(); backgroundThread.start(); Thread.sleep(100); //lets wait our thread warm up assertThat(timeThis(() -> transactionalService.getQuickHowManyVisits())) .isGreaterThan(500).isLessThan(1000); }

Eseguendolo, vedremo nuovamente le seguenti attività nella nostra console:

Adding key 'key' to cache Key 'key' was visited Ill try to set HowManyVisits to 1 I was able to set HowManyVisits to 1 after waiting 0.001 seconds HowManyVisits should now be 1000, but we are holding the transaction Key 'key' was visited Ill try to set HowManyVisits to 2 I was able to set HowManyVisits to 2 after waiting 0.902 seconds The slow batch suffered a rollback

Controlla l'ora sul thread principale, in attesa della fine della transazione creata dal metodo lento.

5. conclusione

In questo articolo, abbiamo visto cos'è Infinispan e le sue principali caratteristiche e capacità come cache all'interno di un'applicazione.

Come sempre, il codice può essere trovato su Github.