Cache

Cache [kesz] przyspieszy twoją aplikację, przechowując raz uzyskane dane do przyszłego użytku. Pokażemy:

  • jak używać cache
  • jak zmienić magazyn
  • jak poprawnie unieważniać cache

Korzystanie z cache w Nette jest bardzo łatwe, a jednocześnie obejmuje bardzo zaawansowane potrzeby. Został zaprojektowany z myślą o wydajności i 100% odporności. W standardzie znajdziesz adaptery do najczęstszych magazynów backendowych. Umożliwia unieważnianie oparte na tagach, wygasanie czasowe, posiada ochronę przed cache stampede itp.

Instalacja

Bibliotekę pobierzesz i zainstalujesz za pomocą narzędzia Composer:

composer require nette/caching

Podstawowe użycie

Centrum pracy z cache, czyli pamięcią podręczną, stanowi obiekt Nette\Caching\Cache. Tworzymy jego instancję i jako parametr przekazujemy konstruktorowi tzw. magazyn. Jest to obiekt reprezentujący miejsce, gdzie dane będą fizycznie przechowywane (baza danych, Memcached, pliki na dysku, …). Do magazynu dostaniemy się, prosząc o jego przekazanie za pomocą dependency injection z typem Nette\Caching\Storage. Wszystko, co istotne, dowiesz się w sekcji Magazyny.

W wersji 3.0 interfejs miał jeszcze prefiks I, więc nazwa brzmiała Nette\Caching\IStorage. Ponadto stałe klasy Cache były pisane wielkimi literami, więc na przykład Cache::EXPIRE zamiast Cache::Expire.

W poniższych przykładach zakładamy, że mamy utworzony alias Cache i w zmiennej $storage magazyn.

use Nette\Caching\Cache;

$storage = /* ... */; // instancja Nette\Caching\Storage

Cache jest właściwie key–value store, czyli dane odczytujemy i zapisujemy pod kluczami, tak jak w tablicach asocjacyjnych. Aplikacje składają się z wielu niezależnych części i jeśli wszystkie będą używać jednego magazynu (wyobraź sobie jeden katalog na dysku), wcześniej czy później doszłoby do kolizji kluczy. Nette Framework rozwiązuje ten problem, dzieląc całą przestrzeń na przestrzenie nazw (podkatalogi). Każda część programu używa wtedy swojej przestrzeni z unikalną nazwą i żadna kolizja już nie może wystąpić.

Nazwę przestrzeni podajemy jako drugi parametr konstruktora klasy Cache:

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

Teraz możemy za pomocą obiektu $cache odczytywać i zapisywać do pamięci podręcznej. Do obu służy metoda load(). Pierwszym argumentem jest klucz, a drugim PHP callback, który jest wywoływany, gdy klucz nie zostanie znaleziony w cache. Callback generuje wartość, zwraca ją, a ta jest zapisywana w cache:

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

Jeśli drugi parametr nie zostanie podany $value = $cache->load($key), zwrócony zostanie null, jeśli elementu nie ma w cache.

Świetne jest to, że w cache można przechowywać dowolne serializowalne struktury, nie muszą to być tylko ciągi znaków. To samo dotyczy nawet kluczy.

Element z pamięci podręcznej usuwamy metodą remove():

$cache->remove($key);

Zapisać element do pamięci podręcznej można również metodą $cache->save($key, $value, array $dependencies = []). Preferowany jest jednak powyższy sposób za pomocą load().

Memoizacja

Memoizacja oznacza buforowanie wyniku wywołania funkcji lub metody, aby móc go użyć następnym razem bez ponownego obliczania tej samej rzeczy.

Memoizowanie można wywoływać dla metod i funkcji za pomocą call(callable $callback, ...$args):

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

Funkcja gethostbyaddr() zostanie wywołana dla każdego parametru $ip tylko raz, a następnym razem zostanie zwrócona wartość z cache.

Możliwe jest również utworzenie memoizowanego opakowania nad metodą lub funkcją, które można wywołać później:

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

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

$result = $memoizedFactorial(5); // oblicza po raz pierwszy
$result = $memoizedFactorial(5); // po raz drugi z cache

Wygaśnięcie & unieważnienie

Przy zapisywaniu do cache trzeba rozwiązać kwestię, kiedy wcześniej zapisane dane stają się nieaktualne. Nette Framework oferuje mechanizm, jak ograniczyć ważność danych lub je kontrolowanie usuwać (w terminologii frameworka „unieważniać“).

Ważność danych ustawia się w momencie zapisywania za pomocą trzeciego parametru metody save(), np.:

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

Lub za pomocą parametru $dependencies przekazywanego przez referencję do callbacku metody load(), np.:

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

Lub za pomocą trzeciego parametru w metodzie load(), np.:

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

W kolejnych przykładach będziemy zakładać drugą opcję, a więc istnienie zmiennej $dependencies.

Wygaśnięcie

Najprostszym wygaśnięciem jest limit czasowy. W ten sposób zapisujemy dane do cache z ważnością 20 minut:

// akceptuje również liczbę sekund lub timestamp UNIX
$dependencies[Cache::Expire] = '20 minutes';

Jeśli chcielibyśmy przedłużyć okres ważności przy każdym odczycie, można to osiągnąć w następujący sposób, ale uwaga, narzut cache przez to wzrośnie:

$dependencies[Cache::Sliding] = true;

Przydatna jest możliwość wygaśnięcia danych w momencie zmiany pliku lub jednego z wielu plików. Można to wykorzystać na przykład przy zapisywaniu do cache danych powstałych w wyniku przetwarzania tych plików. Używaj ścieżek absolutnych.

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

Możemy pozwolić, aby element w cache wygasł w momencie, gdy wygaśnie inny element (lub jeden z wielu innych). Można to wykorzystać, gdy przechowujemy w cache na przykład całą stronę HTML, a pod innymi kluczami jej fragmenty. Gdy fragment się zmieni, unieważniona zostanie cała strona. Jeśli fragmenty mamy zapisane pod kluczami np. frag1 i frag2, użyjemy:

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

Wygaśnięcie można również kontrolować za pomocą własnych funkcji lub metod statycznych, które zawsze przy odczycie decydują, czy element jest jeszcze ważny. W ten sposób możemy na przykład pozwolić, aby element wygasł zawsze, gdy zmieni się wersja PHP. Tworzymy funkcję, która porównuje aktualną wersję z parametrem, a przy zapisywaniu dodajemy do zależności tablicę w formacie [nazwa funkcji, ...argumenty]:

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

$dependencies[Cache::Callbacks] = [
	['checkPhpVersion', PHP_VERSION_ID] // wygaśnij, gdy checkPhpVersion(...) === false
];

Wszystkie kryteria można oczywiście łączyć. Cache wygaśnie wtedy, gdy przynajmniej jedno kryterium nie jest spełnione.

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

Unieważnianie za pomocą tagów

Bardzo użytecznym narzędziem do unieważniania są tzw. tagi. Każdemy elementowi w cache możemy przypisać listę tagów, które są dowolnymi ciągami znaków. Załóżmy, że mamy stronę HTML z artykułem i komentarzami, którą będziemy buforować. Przy zapisywaniu określamy tagi:

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

Przejdźmy do administracji. Tutaj znajdziemy formularz do edycji artykułu. Wraz z zapisaniem artykułu do bazy danych wywołamy polecenie clean(), które usunie z cache elementy według tagu:

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

Podobnie, w miejscu dodawania nowego komentarza (lub edycji komentarza) nie zapomnijmy unieważnić odpowiedniego tagu:

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

Co przez to osiągnęliśmy? Że nasza pamięć podręczna HTML będzie unieważniana (usuwana), gdy tylko zmieni się artykuł lub komentarze. Kiedy edytowany jest artykuł o ID = 10, następuje wymuszone unieważnienie tagu article/10, a strona HTML, która nosi ten tag, zostanie usunięta z cache. To samo nastąpi przy wstawieniu nowego komentarza pod odpowiedni artykuł.

Tagi wymagają tzw. Journal.

Unieważnianie za pomocą priorytetu

Poszczególnym elementom w cache możemy ustawić priorytet, za pomocą którego będzie można je usuwać, gdy na przykład cache przekroczy określoną wielkość:

$dependencies[Cache::Priority] = 50;

Usuniemy wszystkie elementy o priorytecie równym lub mniejszym niż 100:

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

Priorytety wymagają tzw. Journal.

Czyszczenie cache

Parametr Cache::All usuwa wszystko:

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

Odczyt masowy

Do masowego odczytu i zapisu do cache służy metoda bulkLoad(), której przekazujemy tablicę kluczy i otrzymujemy tablicę wartości:

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

Metoda bulkLoad() działa podobnie jak load() również z drugim parametrem callbackiem, któremu przekazywany jest klucz generowanego elementu:

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

Użycie z PSR-16

Aby użyć Nette Cache z interfejsem PSR-16, możesz wykorzystać adapter PsrCacheAdapter. Umożliwia on bezproblemową integrację między Nette Cache a dowolnym kodem lub biblioteką, która oczekuje cache zgodnego z PSR-16.

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

Teraz możesz używać $psrCache jako cache PSR-16:

$psrCache->set('key', 'value', 3600); // zapisuje wartość na 1 godzinę
$value = $psrCache->get('key', 'default');

Adapter obsługuje wszystkie metody zdefiniowane w PSR-16, w tym getMultiple(), setMultiple() i deleteMultiple().

Buforowanie wyjścia

Bardzo elegancko można przechwytywać i buforować wyjście:

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

	echo ... // wypisujemy dane

	$capture->end(); // zapisujemy wyjście do cache
}

W przypadku, gdy wyjście jest już zapisane w cache, metoda capture() je wypisuje i zwraca null, więc warunek się nie wykona. W przeciwnym razie zaczyna przechwytywać wyjście i zwraca obiekt $capture, za pomocą którego ostatecznie zapisujemy wypisane dane do cache.

W wersji 3.0 metoda nazywała się $cache->start().

Buforowanie w Latte

Buforowanie w szablonach Latte jest bardzo łatwe, wystarczy część szablonu otoczyć znacznikami {cache}...{/cache}. Cache jest automatycznie unieważniany w momencie zmiany szablonu źródłowego (w tym ewentualnych dołączonych szablonów wewnątrz bloku cache). Znaczniki {cache} można zagnieżdżać w sobie, a gdy zagnieżdżony blok zostanie unieważniony (na przykład przez tag), unieważniony zostanie również blok nadrzędny.

W znaczniku można podać klucze, do których będzie powiązany cache (tutaj zmienna $id) i ustawić wygaśnięcie oraz tagi do unieważnienia.

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

Wszystkie pozycje są opcjonalne, więc nie musimy podawać ani wygaśnięcia, ani tagów, ani nawet kluczy.

Użycie cache można również uzależnić za pomocą if – zawartość będzie wtedy buforowana tylko wtedy, gdy warunek zostanie spełniony:

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

Magazyny

Magazyn to obiekt reprezentujący miejsce, gdzie dane są fizycznie przechowywane. Możemy użyć bazy danych, serwera Memcached lub najłatwiej dostępnego magazynu, jakim są pliki na dysku.

Magazyn Opis
FileStorage domyślny magazyn z zapisem do plików na dysku
MemcachedStorage wykorzystuje serwer Memcached
MemoryStorage dane są tymczasowo w pamięci
SQLiteStorage dane są zapisywane do bazy danych SQLite
DevNullStorage dane nie są zapisywane, odpowiednie do testowania

Do obiektu magazynu dostaniesz się, prosząc o jego przekazanie za pomocą dependency injection z typem Nette\Caching\Storage. Jako domyślny magazyn Nette dostarcza obiekt FileStorage zapisujący dane do podkatalogu cache w katalogu dla plików tymczasowych.

Zmienić magazyn można w konfiguracji:

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

FileStorage

Zapisuje cache do plików na dysku. Magazyn Nette\Caching\Storages\FileStorage jest bardzo dobrze zoptymalizowany pod kątem wydajności i przede wszystkim zapewnia pełną atomowość operacji. Co to oznacza? Że podczas używania cache nie może się zdarzyć, że odczytamy plik, który jeszcze nie został w całości zapisany przez inny wątek, lub że ktoś nam go “pod ręką” usunie. Użycie cache jest więc całkowicie bezpieczne.

Ten magazyn ma również wbudowaną ważną funkcję, która zapobiega ekstremalnemu wzrostowi zużycia procesora w momencie, gdy cache zostanie usunięty lub jeszcze nie jest rozgrzany (tj. utworzony). Jest to prewencja przed cache stampede. Zdarza się, że w jednym momencie pojawi się większa liczba równoczesnych żądań, które chcą z cache tej samej rzeczy (np. wyniku kosztownego zapytania SQL), a ponieważ w pamięci podręcznej jej nie ma, wszystkie procesy zaczynają wykonywać to samo zapytanie SQL. Obciążenie się w ten sposób mnoży i może nawet dojść do sytuacji, że żaden wątek nie zdąży odpowiedzieć w limicie czasowym, cache się nie utworzy, a aplikacja ulegnie awarii. Na szczęście cache w Nette działa tak, że przy wielu równoczesnych żądaniach o jeden element generuje go tylko pierwszy wątek, pozostałe czekają, a następnie wykorzystują wygenerowany wynik.

Przykład utworzenia FileStorage:

// magazynem będzie katalog '/path/to/temp' na dysku
$storage = new Nette\Caching\Storages\FileStorage('/path/to/temp');

MemcachedStorage

Serwer Memcached to wysokowydajny system przechowywania w rozproszonej pamięci, którego adapterem jest Nette\Caching\Storages\MemcachedStorage. W konfiguracji podajemy adres IP i port, jeśli różni się od standardowego 11211.

Wymaga rozszerzenia PHP memcached.

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

MemoryStorage

Nette\Caching\Storages\MemoryStorage to magazyn, który przechowuje dane w tablicy PHP, a więc znikają one wraz z zakończeniem żądania.

SQLiteStorage

Baza danych SQLite i adapter Nette\Caching\Storages\SQLiteStorage oferują sposób na przechowywanie cache w jednym pliku na dysku. W konfiguracji podajemy ścieżkę do tego pliku.

Wymaga rozszerzeń PHP pdo i pdo_sqlite.

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

DevNullStorage

Specjalną implementacją magazynu jest Nette\Caching\Storages\DevNullStorage, który w rzeczywistości w ogóle nie przechowuje danych. Jest więc odpowiedni do testowania, gdy chcemy wyeliminować wpływ cache.

Użycie cache w kodzie

Przy używaniu cache w kodzie mamy dwa sposoby. Pierwszy z nich polega na tym, że prosimy o przekazanie magazynu za pomocą dependency injection i tworzymy obiekt 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');
	}
}

Druga możliwość polega na tym, że prosimy o przekazanie od razu obiektu Cache:

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

Obiekt Cache jest następnie tworzony bezpośrednio w konfiguracji w ten sposób:

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

Journal

Nette przechowuje tagi i priorytety w tzw. journalu. Standardowo używa się do tego SQLite i pliku journal.s3dbwymagane są rozszerzenia PHP pdo i pdo_sqlite.

Zmienić journal można w konfiguracji:

services:
	cache.journal: MyJournal

Usługi DI

Te usługi są dodawane do kontenera DI:

Nazwa Typ Opis
cache.journal Nette\Caching\Storages\Journal journal
cache.storage Nette\Caching\Storage magazyn

Wyłączenie cache

Jedną z możliwości wyłączenia cache w aplikacji jest ustawienie jako magazynu DevNullStorage:

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

To ustawienie nie ma wpływu na buforowanie szablonów w Latte ani kontenera DI, ponieważ te biblioteki nie korzystają z usług nette/caching i zarządzają swoją pamięcią podręczną samodzielnie. Ich cache zresztą nie trzeba wyłączać w trybie deweloperskim.

wersja: 3.x