In ogni applicazione che prevede l'interazione con gli utenti, è necessario ridurre il tempo di caricamento delle informazioni richieste. Utilizzando un sistema di cache, è possibile ottimizzare il tempo di caricamento dei dati e ottenere, quindi, prestazioni migliori.
Immaginiamo, ad esempio, un sistema che utilizza un database per memorizzare i dati, ogni richiesta dell'utente corrisponde a un'interrogazione al DBMS. Il tempo necessario per effettuare la connessione, e l'interrogazione sulla base dati, potrebbe influire notevolmente sul tempo di caricamento della pagina.
Sfruttando la filosofia che sta alla base di un sistema di caching, invece, è possibile ridurre i tempi di attesa dell'utente. È possibile effettuare un salvataggio temporaneo in memoria di una serie di dati, che hanno determinate caratteristiche di variabilità e che hanno un'alta frequenza di utilizzo. Utilizzare la cache, infatti, non vuol dire avere a disposizione tutte le informazioni disponibili e censite nel sistema, ma solo parte di esse. Quando viene richiesta un'informazione, non c'è nessuna certezza che i dati si trovino all'interno della cache, ma conviene comunque fare un tentativo per verificarne l'eventuale esistenza prima di leggerli dal DBMS. Solo qualora i dati non siano presenti, è necessario leggerli dal database e caricarli all'interno della cache. Le successive richieste degli utenti che riguardano i dati risulteranno sicuramente più veloci.
Naturalmente la cache deve avere dimensioni ridotte per permettere ricerche più veloci. Sono disponibili moltissimi algoritmi che permettono di individuare l'elemento da eliminare quando la cache è piena.
In questo articolo analizziamo i concetti fondamentali di JCS, Java Cache System, una libreria opensource sviluppata dalla Apache Software Foundation, che permette di mettere a punto un meccanismo di caching all'interno delle proprie applicazioni java.
L'ultima versione disponibile è la 1.3. È possibile scaricare anche direttamente il jar da includere nelle proprie applicazioni. Per utilizzare JCS all'interno delle proprie applicazioni, è necessario includere nel classpath anche le seguenti librerie dipendenti:
Gli elementi all'interno della cache vengono suddivisi in regioni, ognuna delle quali può gestire differenti tipologie di elementi. A ciascuna regione può essere associato un plugin, di seguito denominato "ausiliare". JCS mette a disposizione dello sviluppatore diverse tipologie di ausiliari ognuno dei quali ha differenti implementazioni e degli attributi specifici configurabili. Per ulteriori approfondimenti è consigliato fare riferimento alla documentazione ufficiale.
La configurazione di un sistema di caching è affidata a un file di testo: cache.ccf. È possibile utilizzare anche altre tipologie di file ma queste, sono al di là del campo di applicazione di questa semplice guida introduttiva.
Supponiamo di avere un catalogo di prodotti nel nostro negozio online. Ogni volta che un utente accede alla lista di prodotti, oppure al dettaglio di ciascuno di questi, occorre accedere al DB per ottenere le informazioni da visualizzare. È molto probabile che i prodotti disponibili non vengano aggiornati troppo spesso e che gli utenti visualizzino soprattutto la pagina principale. È possibile quindi aggiungere alla cache i prodotti più aggiornati. Quando l'amministratore del sistema aggiungerà, modificherà o cancellerà uno o più prodotti da un ipotetico pannello di back-end, ci preoccuperemo di aggiornare i dati all'interno della nostra cache.
Il nostro file cache.ccf sarà il seguente:
#REGIONE PRODOTTI
jcs.region.prodotti=PRODOTTI
jcs.region.prodotti.cacheattributes=org.apache.jcs.engine.CompositeCacheAttributes
jcs.region.prodotti.cacheattributes.MaxObjects=1000
jcs.region.prodotti.cacheattributes.MemoryCacheName=org.apache.jcs.engine.memory.lru.LRUMemoryCache
jcs.region.prodotti.cacheattributes.UseMemoryShrinker=false
jcs.region.prodotti.cacheattributes.MaxMemoryIdleTimeSeconds=3600
jcs.region.prodotti.cacheattributes.ShrinkerIntervalSeconds=60
jcs.region.prodotti.elementattributes=org.apache.jcs.engine.ElementAttributes
jcs.region.prodotti.elementattributes.IsEternal=false
#AUXILIARY PRODOTTI
jcs.auxiliary.PRODOTTI=org.apache.jcs.auxiliary.disk.indexed.IndexedDiskCacheFactory
jcs.auxiliary.PRODOTTI.attributes=org.apache.jcs.auxiliary.disk.indexed.IndexedDiskCacheAttributes
jcs.auxiliary.PRODOTTI.attributes.DiskPath=D:ProjectsDPCJCStemp
jcs.auxiliary.PRODOTTI.attributes.MaxPurgatorySize=10000000
jcs.auxiliary.PRODOTTI.attributes.MaxKeySize=1000000
jcs.auxiliary.PRODOTTI.attributes.MaxRecycleBinSize=5000
jcs.auxiliary.PRODOTTI.attributes.OptimizeAtRemoveCount=300000
jcs.auxiliary.PRODOTTI.attributes.ShutdownSpoolTimeLimit=60
Nella prima riga del file abbiamo definito una regione chiamata PRODOTTI. Mediante l'attributo cacheattributes
definiamo le impostazioni generali utilizzate dalla nostra cache. Nel nostro esempio abbiamo utilizzato la classe CompositeCacheAttributes
che permette di definire i seguenti parametri principali:
- MaxObjects: numero massimo di oggetti che la cache può contenere. Superata tale soglia viene individuato l'elemento candidato che deve essere eliminato dalla cache per far posto ad un nuovo elemento;
- MemoryCacheName: nome della classe responsabile della gestione degli elementi. Tale classe consente di determinare l'elemento da eliminare quando la cache risulta piena. Di default viene utilizzata la classe
LRUMemoryCache
che si basa sull'algoritmo Least Recently Used (utilizzato meno di recente); - UseMemoryShrinker: permette di attivare o disattivare lo Shrinker, ovvero un Thread che periodicamente verifica se un oggetto presente nella cache è scaduto e deve essere eliminato. Di defaut non è attivo (false);
- MaxMemoryIdleTimeSeconds: permette di definire la durata di un oggetto all'interno della cache. Se tale soglia viene raggiunta, l'oggetto è considerato scaduto e lo shriker, qualora fosse attivo, può procedere alla sua eliminazione;
- ShrinkerIntervalSeconds: permette di definire l'intervallo di tempo di esecuzione dello shrinker. Di default tale valore è di 60 secondi;
- Sono previsti anche attributi relativi agli elementi appartenenti alla regione che sono i seguenti:
- IsEternal: permette di rendere un elemento "eterno" in modo che questo non possa mai essere eliminato dallo shrinker;
- MaxLifeSeconds: permette di definire la durata di un oggetto all'interno della cache. Superata tale soglia l'elemento viene considerato scaduto.
È possibile anche definire delle impostazioni di default. Tutte le regioni ereditano le impostazioni di default qualora le proprietà non vengano esplicitamente definite.
Nell'esempio abbiamo associato alla nostra regione l'ausiliare IndexedDiskCacheFactory
, che permette di effettuare lo spool (scrittura) sul disco degli elementi prima che questi vengano eliminati dalla cache. Questo meccanismo permette di memorizzare gli elementi della cache sul disco e le chiavi di ciascuno di essi in memoria permettendo, quindi, di verificare la presenza di un elemento molto più velocemente.
Quando un elemento viene rimosso dalla cache viene subito copiato in una "regione" della memoria chiamata "purgatorio" per evitare di attendere l'interrupt del sistema che abilita la scrittura sul disco. Può capitare che un elemento venga richiesto ma non è presente nella cache. In questo caso viene prima verificata la presenza nel "purgatorio". In caso positivo l'elemento viene riportato nella cache.
I principali attributi configurabili per questo ausiliare sono i seguenti:
- DiskPath: directory fisica nella quale vengono memorizzati gli elementi;
- MaxPurgatorySize: numero massimo di elementi del "purgatorio";
- MaxKeySize: numero massimo di chiavi presenti in memoria;
- MaxRecycleBinSize: dopo l'eliminazione di un gruppo di elementi viene avviata la deframmentazione della porzione di disco contenente gli elementi. Questo parametro permette di stabilire dopo quante eliminazioni deve essere abilitata la deframmentazione.
Adesso analizziamo la classe ProdottoManager che permette di gestire i nostri prodotti. La classe implementa il pattern singleton poiché è necessario avere un'unica istanza della classe nel sistema.
Le operazioni principali da gestire sono le seguenti:
- Creazione della cache: è possibile ottenere un'istanza della cache mediante il metodo statico
getIstance
della classe JCS. Unico parametro richiesto è il nome della regione (esempio:JCS.getInstance(regione)
). Nel nostro esempio, l'inizializzazione della cache è affidata al costruttore della classe; - Inserimento di un elemento nella cache: per inserire un elemento all'interno della cache è necessario utilizzare il metodo put su un'istanza della classe JCS, che riceve in ingresso la chiave ed il valore dell'oggetto (ad esempio:
cache.put(chiave, istanzaOggetto)
); - Lettura di un elemento dalla cache: per leggere un elemento dalla cache è necessario utilizzare il metodo get su un'istanza della classe JCS, che riceve in ingresso la chiave dell'elemento ricercato. Se l'oggetto non è presente, viene restituito un riferimento null, altrimenti un'istanza dell'oggetto corrispondente.
Listato 1. Permette di gestire i prodotti
public class ProdottoManager {
private final String CACHE_NAME = "prodotti";
private JCS cache = null;
private static ProdottoManager instance = null;
private Log log = LogFactory.getLog(this.getClass());
private ProdottoManager() {
try {
setCache(JCS.getInstance(CACHE_NAME));
} catch (CacheException e) {
log.error(e);
}
}
public static ProdottoManager getInstance() {
if (instance == null)
instance = new ProdottoManager();
return instance;
}
public JCS getCache() {
return cache;
}
public void setCache(JCS cache) {
this.cache = cache;
}
public ProdottoVO get(int id) {
ProdottoVO prodotto = (ProdottoVO) cache.get("id_" + id);
if (null == prodotto) {
prodotto = load(id);
log.info("oggetto id_" + id + " letto dal db");
try {
cache.put("id_" + id, prodotto);
log.info("oggetto id_" + id + " inserito nella cache");
} catch (CacheException e) {
log.error(e);
}
}
return prodotto;
}
private ProdottoVO load(int id) {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
log.error(e);
}
ProdottoVO prodotto = null;
switch (id) {
case 1:
prodotto = new ProdottoVO(1, "prodotto 1", "xxxxx");
break;
case 2:
prodotto = new ProdottoVO(2, "prodotto 2", "yyyyyy");
break;
case 3:
prodotto = new ProdottoVO(3, "prodotto 3", "zzzzz");
break;
default:
prodotto = null;
break;
}
return prodotto;
}
}
La classe ProdottoManager contiene due metodi principali:
- public ProdottoVO get(int id): tale metodo restituisce un'istanza di ProdottoVO corrispondente all'id passato come parametro. Il prodotto viene letto dalla cache mediante il metodo
cache.get("id_" + id)
. Qualora il prodotto non è presente, viene caricato dal database e, successivamente, inserito nella cache mediante il metodocache.put("id_" + id, prodotto)
. - private ProdottoVO load(int id): tale metodo restituisce un'istanza di ProdottoVO corrispondente all'id passato come parametro. Il prodotto dovrebbe essere letto dal database ma per semplificare l'esempio abbiamo soltanto simulato l'attesa necessaria per accedere alla base dati ed ottenere le informazioni creando un thread sincrono che attende 3000 millisecondi.
Per notare come la cache riesca ad ottimizzare il tempo di caricamento è necessario richiamare più volte il metodo get
della classe ProdottoManager. La prima esecuzione risulterà sicuramente più lenta perché gli elementi richiesti non sono presenti nella cache, ma le successive esecuzioni risulteranno molto più veloci.
Listato 2. Copia il contenuto della cache sul disco
public class CacheTest{
public static void main(String[] args){
long timeStart = System.currentTimeMillis();
ProdottoManager pm = ProdottoManager.getInstance();
ProdottoVO p1 = pm.get(1);
ProdottoVO p2 = pm.get(2);
ProdottoVO p3 = pm.get(3);
pm.getCache().dispose();
long timeStop = System.currentTimeMillis();
long timer = timeStop - timeStart;
System.out.println(timeStart + " " + timeStop + " " + timer);
System.exit(0);
}
}
Il metodo dispose()
permette di copiare il contenuto della cache sul disco. In questo modo dalla seconda esecuzione in poi, la cache verrà inizializzata con i prodotti presenti sul disco.