Nette Caching

Cache przyspiesza Twoją aplikację, przechowując dane, których uzyskanie było kiedyś kosztowne obliczeniowo, i umożliwiając szybszy dostęp do nich w przyszłości. Omówimy:

  • jak używać cache
  • jak zmienić magazyn zaplecza
  • jak poprawnie inwalidować cache

Używanie cache w Nette jest bardzo proste, a mimo to pokrywa zaawansowane potrzeby cache'owania. Zaprojektowane jest z myślą o wydajności i 100-procentowej trwałości. Zawiera adaptery do najczęstszych magazynów zaplecza. Wspiera inwalidację po tagach, wygasanie po czasie, ochronę przed cache stampede i więcej.

Instalacja

Pobierz i zainstaluj pakiet za pomocą Composera:

composer require nette/caching

Podstawowe użycie

Kluczowym elementem pracy z cache jest obiekt Nette\Caching\Cache. Tworzymy jego instancję, przekazując konstruktorowi obiekt magazynu zaplecza. Obiekt magazynu reprezentuje fizyczne miejsce, w którym dane będą przechowywane (baza danych, Memcached, pliki na dysku itd.). Obiekt magazynu uzyskujesz zwykle przez wstrzykiwanie zależności, prosząc o typ Nette\Caching\Storage. Najważniejsze rzeczy poznasz w sekcji o magazynach.

W wersji 3.0 interfejs miał jeszcze przedrostek I, więc nazywał się Nette\Caching\IStorage. Poza tym stałe klasy Cache zapisywane były wielkimi literami, np. Cache::EXPIRE zamiast Cache::Expire.

Dla poniższych przykładów załóżmy, że mamy alias Cache i instancję magazynu w zmiennej $storage.

use Nette\Caching\Cache;

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

Cache to w istocie magazyn klucz-wartość, czyli czytamy i zapisujemy dane za pomocą kluczy, podobnie jak w tablicach asocjacyjnych. Aplikacje składają się z wielu niezależnych części. Gdyby wszystkie części używały jednego magazynu (wyobraź sobie jeden katalog na dysku), prędzej czy później doszłoby do kolizji kluczy. Nette Framework rozwiązuje to, dzieląc przestrzeń magazynu na przestrzenie nazw (koncepcyjnie jak podkatalogi). Każda część aplikacji pracuje wtedy we własnej przestrzeni nazw o unikalnej nazwie, co zapobiega jakimkolwiek kolizjom.

Nazwę przestrzeni nazw podaj jako drugi argument konstruktora klasy Cache:

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

W razie potrzeby możesz z istniejącej instancji wyprowadzić nową cache ograniczoną do podprzestrzeni nazw metodą derive():

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

Teraz możemy używać obiektu $cache do czytania z cache i zapisywania do niej. Obu celom służy metoda load(). Pierwszym argumentem jest klucz, a drugim callback PHP wywoływany, gdy klucza nie ma w cache. Callback generuje wartość, zwraca ją, a metoda load() ją buforuje:

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

Jeśli drugi parametr zostanie pominięty ($value = $cache->load($key)), load() zwraca null, gdy pozycji nie ma w cache.

Świetne jest to, że buforować można dowolne struktury dające się serializować, nie tylko ciągi. To samo dotyczy kluczy.

Do usunięcia pozycji z cache służy metoda remove():

$cache->remove($key);

Pozycję możesz też zapisać do cache metodą $cache->save($key, $data, ?array $dependencies = null). Zwykle preferowane jest jednak pokazane wyżej podejście z load().

Memoizacja

Memoizacja polega na buforowaniu wyniku wywołania funkcji albo metody, żeby przy kolejnym wywołaniu z tymi samymi argumentami zwracany był wynik zbuforowany zamiast liczony ponownie.

Metody i funkcje można wywoływać w sposób memoizowany za pomocą call(callable $callback, ...$args):

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

Funkcja gethostbyaddr() wywoływana jest więc tylko raz dla każdego unikalnego argumentu $ip. Kolejne wywołania z tym samym $ip zwrócą wartość z cache.

Można też utworzyć memoizowany wrapper wokół metody albo funkcji, który można potem wywoływać:

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

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

$result = $memoizedFactorial(5); // za pierwszym razem liczy
$result = $memoizedFactorial(5); // za drugim zwraca z cache

Wygasanie i inwalidacja

Przy używaniu cache trzeba rozwiązać kwestię tego, kiedy wcześniej zapisane dane stają się nieważne. Nette Framework daje mechanizmy ograniczania ważności danych albo jawnego ich usuwania (w terminologii frameworku nazywanego “inwalidacją”).

Ważność danych ustawia się w momencie zapisu, zwykle trzecim parametrem metody save(), np.:

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

Alternatywnie można ustawić ją parametrem $dependencies przekazywanym przez referencję do callbacku w metodzie load(), np.:

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

Albo za pomocą 3. parametru samej metody load(), np.:

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

W poniższych przykładach będziemy zakładać drugi wariant, wykorzystujący zmienną $dependencies wewnątrz callbacku.

Wygasanie

Najprostszą formą wygasania jest limit czasowy. To buforuje dane z ważnością 20 minut:

// przyjmuje też liczbę sekund albo uniksowy timestamp
$dependencies[Cache::Expire] = '20 minutes';

Jeśli chcesz, żeby okres ważności przedłużał się przy każdym odczycie (wygasanie przesuwane), możesz osiągnąć to tak, ale miej świadomość, że zwiększa to narzut cache:

$dependencies[Cache::Sliding] = true;

Przydatną opcją jest sprawienie, żeby dane wygasały przy modyfikacji konkretnego pliku albo jednego z kilku plików. Przydaje się to na przykład przy buforowaniu danych powstałych z przetwarzania tych plików. Używaj ścieżek absolutnych.

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

Możemy sprawić, żeby pozycja cache wygasała, gdy wygaśnie inna konkretna pozycja (albo jedna z kilku innych). Przydaje się to przy buforowaniu na przykład całej strony HTML i jej fragmentów pod różnymi kluczami. Gdy fragment się zmieni, cała strona powinna zostać zinwalidowana. Jeśli fragmenty przechowywane są pod kluczami frag1 i frag2, użyj:

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

Wygasaniem można też sterować własnymi funkcjami albo metodami statycznymi. Wywoływane są przy każdym odczycie, żeby ustalić, czy pozycja jest nadal ważna. Możemy na przykład sprawić, żeby pozycja wygasała zawsze, gdy zmieni się wersja PHP. Utwórz funkcję porównującą bieżącą wersję z parametrem, a przy zapisie dodaj 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śnie, gdy checkPhpVersion(...) === false
];

Naturalnie wszystkie te kryteria można łączyć. Pozycja cache wygasa, jeśli przynajmniej jedno kryterium przestaje być spełnione.

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

Inwalidacja tagami

Tagi dają bardzo przydatny mechanizm inwalidacji. Każdej pozycji zapisanej w cache możemy przypisać listę tagów (dowolnych ciągów). Załóżmy na przykład, że mamy stronę HTML wyświetlającą artykuł i jego komentarze, którą chcemy buforować. Przy zapisie podajemy odpowiednie tagi:

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

Przejdźmy teraz do sekcji administracyjnej. Mamy tu formularz do edycji artykułów. Razem z zapisem artykułu do bazy danych wywołujemy metodę clean(), żeby usunąć zbuforowane pozycje na podstawie ich tagu:

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

Podobnie przy dodawaniu nowego komentarza (albo edycji istniejącego) musimy pamiętać o zinwalidowaniu odpowiedniego tagu:

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

Co osiągnęliśmy? Nasza cache HTML będzie teraz inwalidowana (usuwana) zawsze, gdy zmieni się powiązany artykuł albo jego komentarze. Przy edycji artykułu o ID = 10 inwalidowany jest tag article/10, a zbuforowana strona HTML nosząca ten tag zostaje usunięta. To samo dzieje się przy dodaniu nowego komentarza pod danym artykułem.

Tagi wymagają dziennika.

Inwalidacja po priorytecie

Poszczególnym pozycjom cache możemy przypisać priorytety. Pozwala to na kontrolowane usuwanie, na przykład gdy cache przekroczy określony limit rozmiaru:

$dependencies[Cache::Priority] = 50;

Żeby usunąć wszystkie pozycje o priorytecie równym 100 albo mniejszym:

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

Priorytety wymagają tak zwanego dziennika.

Czyszczenie cache

Parametr Cache::All czyści wszystko:

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

Odczyt masowy

Do masowego odczytu i zapisu do cache służy metoda bulkLoad(). Przekaż jej tablicę kluczy, a zwróci tablicę odpowiadających wartości:

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

Metoda bulkLoad() działa podobnie do load(), przyjmuje też drugi parametr będący callbackiem. Callback ten otrzymuje klucz generowanej pozycji:

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

Odwrotnie, żeby zapisać wiele pozycji naraz, użyj metody bulkSave(), która przyjmuje tablicę par klucz => wartość i opcjonalne zależności:

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

Użycie z PSR-16

Żeby używać Nette Cache z interfejsem PSR-16, możesz wykorzystać PsrCacheAdapter. Umożliwia płynną integrację między Nette Cache a dowolnym kodem albo biblioteką oczekującą implementacji cache zgodnej z PSR-16.

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

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

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

Adapter wspiera wszystkie metody zdefiniowane w PSR-16, wraz z getMultiple(), setMultiple() i deleteMultiple().

Buforowanie wyjścia

Wyjście można bardzo elegancko przechwycić i zbuforować:

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

	// echo ... wypisanie jakichś danych

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

Jeśli wyjście jest już w cache, metoda capture() wypisuje je i zwraca null, więc blok warunku if zostaje pominięty. W przeciwnym razie zaczyna buforować wyjście i zwraca obiekt $capture, którym ostatecznie zapisujesz przechwycone dane do cache metodą end().

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

Buforowanie w Latte

Buforowanie w szablonach Latte jest bardzo proste. Wystarczy opakować część szablonu, którą chcesz buforować, tagami {cache}...{/cache}. Cache inwalidowana jest automatycznie zawsze, gdy zmieni się źródłowy plik szablonu (wraz z wszystkimi szablonami dołączonymi wewnątrz buforowanego bloku). Tagi {cache} można zagnieżdżać. Gdy zagnieżdżony blok zostanie zinwalidowany (np. tagiem), inwalidowany jest też jego blok nadrzędny.

Wewnątrz tagu możesz podać klucze, do których wpis cache będzie przypisany (tutaj zmienna $id), ustawić czas wygaśnięcia i zdefiniować tagi inwalidacyjne.

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

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

Użycie buforowania można też uzależnić od warunku za pomocą if: treść zostanie zbuforowana tylko wtedy, gdy warunek jest spełniony:

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

Magazyny

Magazyn to obiekt reprezentujący fizyczne miejsce przechowywania danych. Możemy użyć bazy danych, serwera Memcached albo najłatwiej dostępnego magazynu: plików na dysku.

Magazyn Opis
FileStorage Magazyn domyślny, zapisuje cache do plików na dysku.
MemcachedStorage Do przechowywania używa serwera Memcached.
MemoryStorage Dane przechowywane są tymczasowo w pamięci (giną na końcu żądania).
SQLiteStorage Dane przechowywane są w pliku bazy SQLite.
DevNullStorage Dane nie są faktycznie przechowywane; przydatne w testach.

Obiekt magazynu uzyskujesz przez wstrzykiwanie zależności, prosząc o typ Nette\Caching\Storage. Domyślnie Nette dostarcza obiekt FileStorage, który przechowuje dane w podkatalogu cache w katalogu na pliki tymczasowe.

Domyślny magazyn możesz zmienić w konfiguracji:

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

FileStorage

Zapisuje wpisy cache do plików na dysku. Magazyn Nette\Caching\Storages\FileStorage jest mocno zoptymalizowany pod kątem wydajności i, co kluczowe, zapewnia pełną atomowość operacji. Co to znaczy? Przy używaniu cache nie może się zdarzyć, że odczytasz plik, którego inny wątek jeszcze nie dopisał do końca, albo że ktoś usunie go, gdy Ty go czytasz. Używanie tego magazynu cache jest więc całkowicie bezpieczne.

Magazyn ten zawiera też ważną wbudowaną funkcję zapobiegającą ekstremalnemu skokowi zużycia CPU, gdy cache zostanie wyczyszczona albo jest jeszcze “zimna” (czyli jeszcze nie powstała). Znane jest to jako zapobieganie cache stampede. Zjawisko to występuje, gdy wiele równoległych żądań prosi jednocześnie o tę samą pozycję z cache (np. o wynik kosztownego zapytania SQL). Jeśli pozycji akurat nie ma w cache, wszystkie te procesy mogą zacząć wykonywać tę samą kosztowną operację (jak zapytanie SQL). Zwielokrotnia to obciążenie serwera, a może się nawet zdarzyć, że żaden wątek nie zdąży odpowiedzieć w limicie czasu, cache nie powstanie, a aplikacja może się wysypać. Na szczęście cache Nette sobie z tym radzi: gdy pojawia się wiele równoległych żądań o tę samą pozycję, generuje ją tylko pierwszy wątek. Pozostałe wątki czekają, a potem używają wyniku wygenerowanego przez pierwszy.

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 rozproszony system buforowania obiektów w pamięci. Jego adapterem w Nette jest Nette\Caching\Storages\MemcachedStorage. W konfiguracji podaj adres IP serwera 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 trzymający dane w tablicy PHP. Dane giną więc, gdy żądanie się kończy.

SQLiteStorage

Baza SQLite wraz z adapterem Nette\Caching\Storages\SQLiteStorage daje sposób buforowania danych w jednym pliku na dysku. Konfiguracja podaje ścieżkę do tego pliku bazy.

Wymaga rozszerzeń PHP pdo i pdo_sqlite.

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

DevNullStorage

Szczególną implementacją magazynu jest Nette\Caching\Storages\DevNullStorage, który faktycznie nie przechowuje żadnych danych. Nadaje się więc do celów testowych, gdy chcesz wyeliminować wpływ buforowania.

Używanie cache w kodzie

Przy używaniu buforowania w swoim kodzie masz dwa główne podejścia. Pierwsze to uzyskanie obiektu magazynu przez wstrzykiwanie zależności, a potem samodzielne utworzenie obiektu 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ść to poproszenie bezpośrednio o obiekt Cache:

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

Obiekt Cache trzeba wtedy zdefiniować w konfiguracji, na przykład tak:

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

Dziennik

Nette przechowuje informacje o tagach i priorytetach w tak zwanym dzienniku. Domyślnie używany jest do tego SQLite przez plik journal.s3db, a wymagane są rozszerzenia PHP pdo i pdo_sqlite.

Implementację dziennika możesz zmienić w konfiguracji:

services:
	cache.journal: MyJournal

Usługi DI

Do kontenera DI dodawane są te usługi:

Nazwa Typ Opis
cache.journal Nette\Caching\Storages\Journal Magazyn dziennika cache
cache.storage Nette\Caching\Storage Główny magazyn cache

Wyłączenie cache

Jednym ze sposobów wyłączenia buforowania w Twojej aplikacji jest ustawienie magazynu zaplecza na DevNullStorage:

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

Ustawienie to nie wpływa na buforowanie szablonów Latte ani kontenera DI, bo biblioteki te nie korzystają z usług nette/caching i zarządzają swoją cache niezależnie. Poza tym ich cache zwykle nie trzeba wyłączać w trybie deweloperskim.

Jeśli aktualizujesz do nowszej wersji, zajrzyj na stronę aktualizacji.

wersja: 3.x