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.