Cache

Cache [keš] zrychlí vaši aplikaci tím, že jednou náročně získaná data uloží pro příští použití. Ukážeme si:

  • jak používat cache
  • jak změnit úložiště
  • jak správně cache invalidovat

Nette Framework nabízí velmi intuitivní API pro práci s cache. Ostatně, nic jiného jste asi ani nečekali, že? ;-) Než si ukážeme první příklad, je třeba zvážit, kam se mají data fyzicky ukládat. Můžeme použít databázi, server Memcached, nebo nejdostupnější úložiště, což je pevný disk:

// úložištěm bude adresář 'temp' na disku
$storage = new Nette\Caching\Storages\FileStorage('temp');

Úložiště Nette\Caching\Storages\FileStorage je velmi dobře optimalizované pro výkon a především zajišťuje plnou atomicitu operací. Co to znamená? Že při použití cache se nemůže stát, že přečteme soubor, který ještě není (jiným vláknem) kompletně zapsaný, nebo že by vám jej někdo „pod rukama“ smazal. Použití cache je tedy zcela bezpečné. Více o úložišti se dozvíte v kapitole Úložiště.

Pro samotnou práci s cache používáme objekt Nette\Caching\Cache:

use Nette\Caching\Cache;

$cache = new Cache($storage);

Obsah proměnné $data zapíšeme do cache pod klíčem $key:

$cache->save($key, $data);

A takto z cache čteme: (pokud položka v cache není, vrátí metoda null)

$value = $cache->load($key);
if ($value === null) ...

Metoda load() má jako druhý parametr callable $fallback, který je využit ve chvíli, kdy daná položka neexistuje. Tento callback dostane parametrem odkaz na pole $dependencies, jehož pomocí lze nastavit pravidla pro zneplatnění.

$value = $cache->load($key, function (&$dependencies) {
    // nějaká data
    return 15;
});

Položku z cache vymažeme uložením null, nebo metodou remove():

$cache->save($key, null);
// nebo
$cache->remove($key);

Do cache lze ukládat jakékoliv struktury, nemusí to být jen řetězec. A totéž platí i pro klíče.

Webové aplikace se běžně skládají z celé řady vzájemně nezávislých částí, a pokud všechny cachují data do společného úložiště (tedy třeba adresáře), dříve nebo později by došlo ke kolizi názvů. Nette Framework problém řeší tak, že celý prostor rozděluje na sekce (v případě FileStorage jde o podadresáře). Každá část programu pak používá svou sekci s unikátním názvem a k žádné kolizi již dojít nemůže. Název sekce uvedeme jako druhý parametr konstruktoru třídy Cache:

$cache = new Cache($storage, 'HtmlOutput');

Cachování v šablonách

Cachování v šablonách je velmi snadné, stačí část šablony obalit značkami {cache}...{/cache}. Cache se automaticky invaliduje ve chvíli, kdy se změní zdrojová šablona (včetně případných inkludovaných šablon uvnitř bloku cache). Značky {cache} lze vnořovat do sebe, a když se vnořený blok zneplatní (například tagem), zneplatní se i blok nadřazený.

V makru je možné uvést klíče, na které se bude cache vázat (zde proměnná $id) a nastavit expiraci a tagy pro zneplatnění

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

Všechny položky jsou volitelné, takže nemusíme uvádět ani expiraci, ani tagy, nakonec ani klíče.

Použití cache lze také podmínit pomocí if – obsah se pak bude cachovat pouze bude-li splněna podmínka:

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

Pokud teprve v šabloně získáváme data z modelu (princip on demand nebo-li lazy), bude cache obzvlášť efektivní.

Cachování výsledku funkce

Cachování výsledku volání funkce nebo metody lze zapsat pomocí metody call():

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

Funkce gethostbyaddr($ip) se tak zavolá jen jednou a příště už se vrací hodnota z cache. Samozřejmě pro různé $ip se cachují různé výsledky.

Obdobně je možné obalit si nějakou operaci cachí a volat až později.

function calculate($number)
{
    return 'number is ' . $number;
}

$wrapper = $cache->wrap('calculate');

$result = $wrapper(1); // number is 1
$result = $wrapper(2); // number is 2

Cachování výstupu

Výstup můžeme cachovat nejen v šablonách:

if ($block = $cache->start($key)) {

    ... vypisujeme data ..

    $block->end(); // uložíme výstup do cache
}

V případě, že výstup už je v cache uložen, tak ho metoda start() vypíše a vrátí null. V opačném případě začne výstup odchytávat a vrátí objekt $block, pomocí něhož nakonec vypsaná data uložíme do cache.

Expirace a invalidace

S ukládáním dat do cache vznikají dva problémy. Jednak je tu pochopitelně hrozba, že se úložiště zcela zaplní a nebude možné další data zapisovat. A také se může stát, že některá dříve uložená data se stanou v průběhu času neplatná. Nette Framework proto nabízí mechanismus, jak omezit platnost dat nebo je řízeně mazat (v terminologii frameworku „invalidovat“).

Platnost dat se nastavuje v okamžiku ukládání a to pomocí třetího parametru metody save:

$cache->save($key, $data, [
    Cache::EXPIRE => '20 minutes', // akceptuje i sekundy nebo timestamp
]);

Z kódu je patrné, že jsme data uložili s platností 20 minut. Po uplynutí této doby bude cache hlásit, že pod klíčem $key žádný záznam nemá (tj. vrátí null). Pokud bychom chtěli prodloužit dobu platnosti s každým čtením, lze toho docílit takto:

$cache->save($key, $data, [
    Cache::EXPIRE => '20 minutes',
    Cache::SLIDING => true,
]);

Šikovná je možnost nechat data vyexpirovat v okamžiku, kdy se změní určitý soubor či některý z více souborů. Toho lze využít třeba při ukládání dat vzniklých parsováním těchto souborů do cache. Pro bezproblémovou funkčnost je třeba používat absolutní cesty.

$cache->save($key, $data, [
    Cache::FILES => 'data.yaml', // lze uvést i pole souborů
]);

Kritérium Cache::FILES je samozřejmě možné kombinovat i s časovou expirací Cache::EXPIRE apod.

Cache může být závislá i na jiných položkách cache. Což lze využít tehdy, když ukládáme do cache třeba celou HTML stránku a pod jinými klíči její fragmenty. Jakmile se část změní, invaliduje se celá stránka.

$cache->save('page', $html, [
    // vyexpiruje, když vyexpiruje frag1 nebo frag2
    Cache::ITEMS => ['frag1', 'frag2'],
]);

Expiraci lze řídit i pomocí vlastních callbacků:

function controlExpiration($val)
{
    return $val;
}

$cache->save($key, $value, [
    Cache::CALLBACKS => [['controlExpiration', 1]],
]);

Expirace pomocí tagů a priority

Velmi užitečným invalidačním nástrojem jsou tzv. tagy. Každé položce v cache můžeme přiřadit seznam tagů. Mějme třeba HTML stránku s článkem a komentáři, kterou budeme cachovat. Při ukládání specifikujeme tagy:

$cache->save($articleId, $html, [
    Cache::TAGS => ["article/$articleId", "comments/$articleId"],
]);

Přesuňme se do administrace. Tady najdeme formulář pro editaci článku. Společně s uložením článku do databáze zavoláme příkaz clean(), který smaže z cache položky dle tagu:

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

Stejně tak v místě přidání nového komentáře (nebo editace komentáře) neopomeneme invalidovat příslušný tag:

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

Čeho jsme tím dosáhli? Že se nám HTML cache bude invalidovat (mazat) sama. Kdykoliv uživatel změní článek s ID = 10, dojde k vynucené invalidaci tagu article/10 a HTML stránka, která uvedený tag nese, se z cache smaže. Totéž nastane při vložení nového komentáře pod příslušný článek.

Obdobou tagů je řízení expirace dle priority:

$cache->save($key, $value, [
    Cache::PRIORITY => 50,
]);

// smažeme všechny položky s prioritou rovnou nebo menší než 100:
$cache->clean([
    Cache::PRIORITY => 100,
]);

A nakonec zmiňme parametr Cache::ALL, který smaže vše.

Úložiště

Kromě představeného úložiště FileStorage jsou v Nette připraveny tyto další uložište:

Úložiště Popis Hromadné čtení
FileStorage výchozí úložiště s ukládáním do souboru na disk
MemcachedStorage využívá Memcached caching systém (deprecated)
NewMemcachedStorage využívá Memcached caching systém ano
MemoryStorage data jsou dočasně v paměti
SQLiteStorage data se ukládají do SQLite databáze ano
DevNullStorage data se neukládají, vhodné pro testování

Lze si také napsat vlastní úložiště, jediný požadavek je, aby implementovalo rozhraní IStorage.

Služba úložiště

Abychom si nemuseli všude vytvářet objekt $storage, lze si ho vyžádat pomocí Dependency Injection. Pokud neurčíte jinak, Nette poskytuje výchozí úložiště FileStorage, ukládající data do adresáře určeného parametrem tempDir nastaveným v bootstrap.php pomocí $configurator->setTempDirectory().

use Nette;

class MyPresenter
{
    /**
     * @inject
     * @var Nette\Caching\IStorage
     */
    public $storage;

    public function actionDefault()
    {
        $cache = new Cache($this->storage, 'htmlFront');
        $cache->save($key, $data);
    }
}

Vypnutí cache pro testování

Speciální implementací úložiště Nette\Caching\IStorage je DevNullStorage, které ve skutečnosti data neukládá vůbec. Je tak vhodné pro testování, kdy chceme eliminovat vliv cache.

Nastavíme v config.neon:

services:
    cacheStorage:
        factory: Nette\Caching\Storages\DevNullStorage

Konkurenční cachování

Smazání cache je běžná operace po nahrání nové verze aplikace na server. V tu chvíli ovšem dostane server docela zabrat, protože musí vybudovat cache novou. Získání některých dat může být dost náročné, příkladem je třeba cache pro RobotLoader. A pokud navíc v krátkém okamžiku přijde třeba 30 požadavků a všechny se snaží o totéž, náročnost ještě násobně roste.

Řešením je upravit chování aplikace tak, aby data vytvářelo vždy jen jedno vlákno a ostatní čekala. K tomu stačí jediné – uvést hodnotu ve formě callbacku nebo anonymní funkce:

$result = $cache->save($key, function () {
    return buildData(); // náročná operace
});

Framework pak zajistí, že tělo funkce bude v jednu chvíli volat jen jedno vlákno a ostatní budou čekat. Pokud vlákno z nějakého důvodu selže, dostane šanci jiné.

Použití cache napříč aplikací

Při použití cache ve službách aplikace čelíme rozhodnutí, zda předávat do služeb objekt Cache nebo pouze IStorage. Vyžaduje-li služba cache pouze pro sebe, předáváme IStorage:

use Nette\Caching;

class MyService
{
    /** @var Caching\Cache */
    private $cache;

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

Služba dostane instanci úložiště z DI kontejneru:

services:
    - MyService

Jiná situace je, pokud potřebujeme více instancí jedné služby, ale oddělenou cache.

use Nette\Caching;

class MyService
{
    /** @var Caching\Cache */
    private $cache;

    public function __construct(Caching\Cache $cache)
    {
        $this->cache = $cache;
    }
}

Objekt cache vytváříme pro každou službu zvlášť:

services:
    one: MyService( Nette\Caching\Cache(namespace: 'one') )
    two: MyService( Nette\Caching\Cache(namespace: 'two') )