Nette Caching

La cache accelera la vostra applicazione conservando i dati che una volta erano costosi da ottenere, così in futuro vi si accede più in fretta. Vedremo:

  • come usare la cache
  • come cambiare il backend di storage
  • come invalidare correttamente la cache

Usare la cache in Nette è molto semplice, eppure copre esigenze di caching sofisticate. È progettata per le prestazioni e per una durabilità del 100%. Comprende adattatori per i backend di storage più diffusi. Supporta l'invalidazione tramite tag, la scadenza temporale, la protezione contro il cache stampede e altro.

Installazione

Il pacchetto si scarica e si installa con Composer:

composer require nette/caching

Uso di base

L'elemento centrale per lavorare con la cache è l'oggetto Nette\Caching\Cache. Ne creiamo un'istanza passando al costruttore un oggetto di storage. Questo oggetto rappresenta il luogo fisico in cui i dati verranno salvati (database, Memcached, file su disco ecc.). L'oggetto di storage lo ottenete di solito con la dependency injection chiedendo il tipo Nette\Caching\Storage. L'essenziale lo imparerete nella sezione sugli storage.

Nella versione 3.0 l'interfaccia aveva ancora il prefisso I, quindi si chiamava Nette\Caching\IStorage. Inoltre le costanti della classe Cache si scrivevano in maiuscolo, per esempio Cache::EXPIRE invece di Cache::Expire.

Per gli esempi seguenti supponiamo di avere un alias Cache e un'istanza di storage nella variabile $storage.

use Nette\Caching\Cache;

$storage = /* ... */; // istanza di Nette\Caching\Storage

La cache è in sostanza un key-value store, cioè leggiamo e scriviamo i dati con delle chiavi, come negli array associativi. Le applicazioni sono composte da più parti indipendenti. Se tutte le parti usassero un unico storage (immaginate un'unica directory su disco), prima o poi si verificherebbero collisioni di chiavi. Il Nette Framework lo risolve dividendo lo spazio dello storage in namespace (concettualmente come delle sottodirectory). Ogni parte dell'applicazione lavora poi nel proprio namespace con un nome univoco e non si verifica alcuna collisione.

Il nome del namespace si indica come secondo argomento del costruttore della classe Cache:

$cache = new Cache($storage, 'Full Html Pages');

Se serve, da un'istanza esistente potete derivare una nuova cache limitata a un sotto-namespace con il metodo derive():

$subCache = $cache->derive('Images');

Ora possiamo usare l'oggetto $cache per leggere dalla cache e scriverci. A entrambi gli scopi serve il metodo load(). Il primo argomento è la chiave, il secondo un callback PHP che viene richiamato se la chiave non si trova nella cache. Il callback genera il valore, lo restituisce e il metodo load() lo mette in cache:

$value = $cache->load($key, function () use ($key) {
	$computedValue = /* ... */; // calcolo costoso
	return $computedValue;
});

Se il secondo parametro viene omesso ($value = $cache->load($key)), load() restituisce null se l'elemento non si trova nella cache.

È ottimo che si possano mettere in cache non solo stringhe, ma qualsiasi struttura serializzabile. Lo stesso vale per le chiavi.

Per cancellare un elemento dalla cache serve il metodo remove():

$cache->remove($key);

Un elemento si può salvare in cache anche con il metodo $cache->save($key, $data, ?array $dependencies = null). In generale però si preferisce l'approccio con load() mostrato sopra.

Memoizzazione

La memoizzazione consiste nel mettere in cache il risultato della chiamata di una funzione o di un metodo, così che alla chiamata successiva con gli stessi argomenti venga restituito il risultato dalla cache invece di ricalcolarlo.

Metodi e funzioni si possono chiamare in modo memoizzato con call(callable $callback, ...$args):

$result = $cache->call('gethostbyaddr', $ip);

La funzione gethostbyaddr() viene quindi chiamata una sola volta per ogni argomento $ip diverso. Le chiamate successive con lo stesso $ip restituiranno il valore dalla cache.

Si può anche creare un wrapper memoizzato attorno a un metodo o a una funzione, da chiamare in seguito:

function factorial($num)
{
	return /* ... */;
}

$memoizedFactorial = $cache->wrap('factorial');

$result = $memoizedFactorial(5); // la prima volta lo calcola
$result = $memoizedFactorial(5); // la seconda volta lo restituisce dalla cache

Scadenza e invalidazione

Quando si usa la cache bisogna affrontare la questione di quando i dati salvati in precedenza diventano non validi. Il Nette Framework offre meccanismi per limitare la validità dei dati o per cancellarli esplicitamente (nella terminologia del framework si parla di “invalidazione”).

La validità dei dati si imposta al momento del salvataggio, tipicamente con il terzo parametro del metodo save(), per esempio:

$cache->save($key, $value, [
	$cache::Expire => '20 minutes',
]);

In alternativa si può impostare con il parametro $dependencies passato per riferimento al callback nel metodo load(), per esempio:

$value = $cache->load($key, function (&$dependencies) {
	$dependencies[Cache::Expire] = '20 minutes';
	return /* ... */;
});

Oppure usando il terzo parametro del metodo load() stesso, per esempio:

$value = $cache->load($key, function () {
	return /* ... */;
}, [Cache::Expire => '20 minutes']);

Negli esempi seguenti supporremo la seconda variante, quella che usa la variabile $dependencies dentro il callback.

Scadenza

La forma più semplice di scadenza è il limite di tempo. Questo mette in cache i dati con una validità di 20 minuti:

// accetta anche il numero di secondi oppure un timestamp UNIX
$dependencies[Cache::Expire] = '20 minutes';

Se volete che il periodo di validità si prolunghi a ogni lettura (scadenza scorrevole), potete ottenerlo così, ma tenete presente che questo aumenta il carico della cache:

$dependencies[Cache::Sliding] = true;

Un'opzione utile è far scadere i dati quando un determinato file, o uno di più file, viene modificato. Torna utile per esempio quando si mettono in cache dati derivati dall'elaborazione di questi file. Usate percorsi assoluti.

$dependencies[Cache::Files] = '/path/to/data.yaml';
// oppure
$dependencies[Cache::Files] = ['/path/to/data1.yaml', '/path/to/data2.yaml'];

Possiamo far scadere un elemento della cache quando scade un altro elemento determinato (o uno di più altri). Torna utile quando si mettono in cache per esempio un'intera pagina HTML e i suoi frammenti sotto chiavi diverse. Quando un frammento cambia, tutta la pagina va invalidata. Se i frammenti sono salvati sotto chiavi come frag1 e frag2, usate:

$dependencies[Cache::Items] = ['frag1', 'frag2'];

La scadenza si può governare anche con funzioni o metodi statici personalizzati. Vengono chiamati a ogni lettura per stabilire se l'elemento è ancora valido. Possiamo per esempio far scadere un elemento ogni volta che cambia la versione di PHP. Create una funzione che confronta la versione attuale con un parametro e, al salvataggio, aggiungete alle dipendenze un array nel formato [nome della funzione, ...argomenti]:

function checkPhpVersion($ver): bool
{
	return $ver === PHP_VERSION_ID;
}

$dependencies[Cache::Callbacks] = [
	['checkPhpVersion', PHP_VERSION_ID] // scade quando checkPhpVersion(...) === false
];

Naturalmente tutti questi criteri si possono combinare. L'elemento della cache scade se almeno un criterio non è più soddisfatto.

$dependencies[Cache::Expire] = '20 minutes';
$dependencies[Cache::Files] = '/path/to/data.yaml';

Invalidazione tramite tag

I tag offrono un meccanismo di invalidazione molto utile. A ogni elemento salvato nella cache possiamo assegnare un elenco di tag (stringhe qualsiasi). Supponiamo per esempio di avere una pagina HTML che mostra un articolo e i suoi commenti, che vogliamo mettere in cache. Al salvataggio indichiamo i tag pertinenti:

$dependencies[Cache::Tags] = ["article/$articleId", "comments/$articleId"];

Passiamo ora alla sezione di amministrazione. Qui abbiamo un form per modificare gli articoli. Insieme al salvataggio dell'articolo nel database chiamiamo il metodo clean() per cancellare gli elementi della cache in base al loro tag:

$cache->clean([
	$cache::Tags => ["article/$articleId"],
]);

Allo stesso modo, quando aggiungiamo un nuovo commento (o ne modifichiamo uno), dobbiamo ricordarci di invalidare il tag corrispondente:

$cache->clean([
	$cache::Tags => ["comments/$articleId"],
]);

Che cosa abbiamo ottenuto? La nostra cache HTML verrà ora invalidata (cancellata) ogni volta che cambiano l'articolo associato o i suoi commenti. Quando si modifica l'articolo con ID = 10, viene invalidato il tag article/10 e la pagina HTML in cache che porta questo tag viene cancellata. Lo stesso accade quando viene aggiunto un nuovo commento sotto l'articolo in questione.

I tag richiedono un Journal.

Invalidazione per priorità

Ai singoli elementi della cache possiamo assegnare delle priorità. Questo permette una cancellazione controllata, per esempio quando la cache supera un certo limite di dimensione:

$dependencies[Cache::Priority] = 50;

Per cancellare tutti gli elementi con priorità minore o uguale a 100:

$cache->clean([
	$cache::Priority => 100,
]);

Le priorità richiedono il cosiddetto Journal.

Svuotare la cache

Il parametro Cache::All cancella tutto:

$cache->clean([
	$cache::All => true,
]);

Lettura in blocco

Per leggere e scrivere nella cache in blocco serve il metodo bulkLoad(). Passategli un array di chiavi e restituisce un array dei valori corrispondenti:

$values = $cache->bulkLoad($keys);

Il metodo bulkLoad() funziona in modo simile a load() e accetta anch'esso un secondo parametro callback. Questo callback riceve la chiave dell'elemento che si sta generando:

$values = $cache->bulkLoad($keys, function ($key, &$dependencies) {
	$computedValue = /* ... */; // calcolo costoso
	return $computedValue;
});

Al contrario, per scrivere più elementi in una volta serve il metodo bulkSave(), che accetta un array di coppie chiave => valore ed eventuali dipendenze:

$cache->bulkSave([
	$key1 => $value1,
	$key2 => $value2,
], [Cache::Expire => '20 minutes']);

Uso con PSR-16

Per usare Nette Cache con l'interfaccia PSR-16 potete sfruttare il PsrCacheAdapter. Permette un'integrazione fluida tra Nette Cache e qualsiasi codice o libreria che si aspetta un'implementazione di cache compatibile con PSR-16.

$psrCache = new Nette\Bridges\Psr\PsrCacheAdapter($storage);

Ora potete usare $psrCache come una normale cache PSR-16:

$psrCache->set('key', 'value', 3600); // salva il valore per 1 ora
$value = $psrCache->get('key', 'default');

L'adattatore supporta tutti i metodi definiti in PSR-16, compresi getMultiple(), setMultiple() e deleteMultiple().

Cache dell'output

L'output si può catturare e mettere in cache in modo molto elegante:

if ($capture = $cache->capture($key)) {

	// echo ... stampa di alcuni dati

	$capture->end(); // salva l'output nella cache
}

Se l'output è già presente nella cache, il metodo capture() lo stampa e restituisce null, quindi il blocco della condizione if viene saltato. Altrimenti comincia a bufferizzare l'output e restituisce un oggetto $capture, che usate per salvare infine nella cache i dati catturati con il suo metodo end().

Nella versione 3.0 questo metodo si chiamava $cache->start().

Cache in Latte

La cache nei template Latte è molto semplice. Basta racchiudere la parte di template che volete mettere in cache tra i tag {cache}...{/cache}. La cache viene invalidata automaticamente ogni volta che il file sorgente del template cambia (compresi i template inclusi dentro il blocco messo in cache). I tag {cache} si possono annidare. Quando un blocco annidato viene invalidato (per esempio tramite un tag), viene invalidato anche il blocco genitore.

Dentro il tag potete indicare le chiavi a cui la voce di cache sarà legata (qui la variabile $id), impostare un tempo di scadenza e definire i tag di invalidazione.

{cache $id, expire: '20 minutes', tags: [tag1, tag2]}
	...
{/cache}

Tutti questi parametri sono facoltativi, quindi non dovete indicare né la scadenza, né i tag, né le chiavi.

L'uso della cache si può anche rendere condizionale con if: il contenuto verrà messo in cache solo se la condizione è soddisfatta:

{cache $id, if: !$form->isSubmitted()}
	{$form}
{/cache}

Storage

Uno storage è un oggetto che rappresenta il luogo fisico in cui i dati vengono salvati. Possiamo usare un database, un server Memcached, oppure lo storage più a portata di mano: i file su disco.

Storage Descrizione
FileStorage Storage predefinito, salva la cache in file su disco.
MemcachedStorage Usa un server Memcached per lo storage.
MemoryStorage I dati sono conservati temporaneamente in memoria (si perdono alla fine della richiesta).
SQLiteStorage I dati sono conservati in un file di database SQLite.
DevNullStorage I dati non vengono affatto salvati; utile per i test.

L'oggetto di storage lo ottenete con la dependency injection chiedendo il tipo Nette\Caching\Storage. Per impostazione predefinita Nette offre un oggetto FileStorage che salva i dati nella sottodirectory cache dentro la directory dei file temporanei.

Lo storage predefinito lo potete cambiare nella configurazione:

services:
	cache.storage: Nette\Caching\Storages\DevNullStorage

FileStorage

Scrive le voci di cache in file su disco. Lo storage Nette\Caching\Storages\FileStorage è molto ottimizzato per le prestazioni e, cosa fondamentale, garantisce la piena atomicità delle operazioni. Che cosa significa? Che usando la cache non può capitare di leggere un file che un altro thread non ha ancora finito di scrivere, o che qualcuno lo cancelli mentre lo state leggendo. Usare questo storage di cache è quindi del tutto sicuro.

Questo storage comprende anche un'importante funzione integrata che impedisce un'impennata estrema dell'uso della CPU quando la cache viene svuotata o è ancora “fredda” (cioè non ancora creata). Si tratta della prevenzione del cosiddetto cache stampede. Si verifica quando più richieste concorrenti chiedono contemporaneamente lo stesso elemento della cache (per esempio il risultato di una query SQL costosa). Se l'elemento in quel momento non è in cache, tutti questi processi potrebbero cominciare a eseguire la stessa operazione costosa (come la query SQL). Questo moltiplica il carico del server e può perfino capitare che nessun thread riesca a rispondere entro il limite di tempo, che la cache non venga creata e che l'applicazione si blocchi. Per fortuna la cache di Nette se ne occupa: quando ci sono più richieste concorrenti per lo stesso elemento, solo il primo thread lo genera. Gli altri thread aspettano e poi usano il risultato generato dal primo.

Esempio di creazione di un FileStorage:

// lo storage sarà la directory '/path/to/temp' su disco
$storage = new Nette\Caching\Storages\FileStorage('/path/to/temp');

MemcachedStorage

Il server Memcached è un sistema distribuito di caching di oggetti in memoria ad alte prestazioni. Il suo adattatore in Nette è Nette\Caching\Storages\MemcachedStorage. Nella configurazione indicate l'indirizzo IP del server e la porta, se differisce dalla standard 11211.

Richiede l'estensione PHP memcached.

services:
	cache.storage: Nette\Caching\Storages\MemcachedStorage('10.0.0.5')

MemoryStorage

Nette\Caching\Storages\MemoryStorage è uno storage che tiene i dati in un array PHP. Di conseguenza i dati si perdono alla fine della richiesta.

SQLiteStorage

Il database SQLite, insieme all'adattatore Nette\Caching\Storages\SQLiteStorage, offre un modo di mettere in cache i dati in un unico file su disco. La configurazione indica il percorso di questo file di database.

Richiede le estensioni PHP pdo e pdo_sqlite.

services:
	cache.storage: Nette\Caching\Storages\SQLiteStorage('%tempDir%/cache.db')

DevNullStorage

Un'implementazione particolare di storage è Nette\Caching\Storages\DevNullStorage, che non salva affatto i dati. È quindi adatta agli scopi di test, quando volete eliminare gli effetti della cache.

Usare la cache nel codice

Quando usate la cache nel vostro codice avete due approcci principali. Il primo è ottenere l'oggetto di storage con la dependency injection e poi creare da soli l'oggetto Cache:

use Nette;

class ClassOne
{
	private Nette\Caching\Cache $cache;

	public function __construct(Nette\Caching\Storage $storage)
	{
		$this->cache = new Nette\Caching\Cache($storage, 'my-namespace');
	}
}

La seconda possibilità è farsi passare direttamente l'oggetto Cache:

class ClassTwo
{
	public function __construct(
		private Nette\Caching\Cache $cache,
	) {
	}
}

L'oggetto Cache va poi definito nella configurazione, per esempio così:

services:
	- ClassTwo( Nette\Caching\Cache(namespace: 'my-namespace') )

Journal

Nette conserva le informazioni su tag e priorità in un cosiddetto journal. Per impostazione predefinita a questo scopo si usa SQLite tramite il file journal.s3db e sono necessarie le estensioni PHP pdo e pdo_sqlite.

L'implementazione del journal la potete cambiare nella configurazione:

services:
	cache.journal: MyJournal

Servizi DI

Al container DI vengono aggiunti questi servizi:

Nome Tipo Descrizione
cache.journal Nette\Caching\Storages\Journal Lo storage del journal della cache
cache.storage Nette\Caching\Storage Lo storage principale della cache

Disattivare la cache

Un modo di disattivare la cache nella vostra applicazione è impostare come backend di storage DevNullStorage:

services:
	cache.storage: Nette\Caching\Storages\DevNullStorage

Questa impostazione non influisce sulla cache dei template Latte né su quella del container DI, perché queste librerie non usano i servizi di nette/caching e gestiscono la propria cache in modo indipendente. Inoltre le loro cache di solito non hanno bisogno di essere disattivate in modalità di sviluppo.

Se state aggiornando a una versione più recente, guardate la pagina aggiornamento.

versione: 3.x