Přeskočit obsah

Popis Cache třídy

Finální třída Cache v core/Cache/Cache.php – vlastní implementace bez dependencies. Minimalistická, transparentní, určená pro malý tým.


Třída Cache

Statická třída pro file-based cachování. Bez ORM, bez Stash, bez magic. Co vidíš v kódu, to se děje.

Design Goals

  • ✓ Minimal IO
  • ✓ Expirace uložena UVNITŘ souboru (ne v mtime) – viz overview.md, aby se netloukla s OPcache
  • ✓ Atomic writes (unikátní tmp → rename)
  • ✓ Hashed directory structure (2-level: XX/YY/hash.php)
  • read() nikdy nemaže soubor – úklid dělá výhradně purge()/clear()

Konstanta – Default TTL

Cache::SECONDS  // 3600 sekund = 1 hodina

Lze přepsat v třetím parametru metod set() a file().

Úložiště

  • Cesta: $path (inicializovaná v Cache::init())
  • Oprávnění: 0770 na adresáře (přes mkdir())
  • Struktura: path/ab/cd/{md5(key)}.php
  • ab = znaky 0–1 z MD5(key)
  • cd = znaky 2–3 z MD5(key)
  • název souboru = celý MD5(key)

Inicializace

// Voláno v bootstrap paths.php
Cache::init('/path/to/cache/dir');

init() normalizuje cestu (rtrim '/'), zamítne prázdnou/root cestu a vytvoří adresář.


Veřejné metody

init(string $path): void

Inicializuje cache storage path. Volej jednou v bootstrapu.

Cache::init(DIR . '/var/cache');

Vyhodí InvalidArgumentException, pokud je $path po rtrim('/') prázdný řetězec.


set(string $key, mixed $value, int $ttl = self::SECONDS): void

Uloží data do cache s TTL. Expirace ($expiresAt = time() + $ttl) je uložena jako první prvek pole přímo v obsahu souboru, ne ve filemtime().

$expiresAt = time() + $ttl;
$php = "<?php\n\nreturn [" . $expiresAt . ', ' . var_export($value, true) . "];\n";

// unikátní tmp jméno – sdílené jméno by umožnilo souběžnému zápisu přepsat rozepsaný soubor
$tmp = $file . '.' . bin2hex(random_bytes(8)) . '.tmp';

file_put_contents($tmp, $php);
rename($tmp, $file);

opcache_invalidate($file, true);

Argumenty:

  • $key – Cache key (string, libovolný)
  • $value – Data, viz assertCacheable() níže
  • $ttl – Expirace v sekundách (default: 3600)

Validace:

  • $ttl < 0 nebo $ttl > PHP_INT_MAX - time() (přetečení) → InvalidArgumentException
  • Necacheable hodnota (viz níže) → InvalidArgumentException
  • Zápis nebo rename selže → RuntimeException

Příklad:

Cache::set('users_all', $userData, 1800);  // 30 minut


get(string $key): mixed

Načte data z cache. Vrací null pokud neexistuje, je expirovaná, nebo je soubor poškozený/neplatný.

$entry = self::read($file);          // [$expiresAt, $value] nebo null

if ($entry === null) {
    return null;
}

[$expiresAt, $value] = $entry;

if ($expiresAt <= time()) {
    return null;   // expirováno – soubor se NEMAŽE (viz purge())
}

return $value;

Důležité: get() expirovaný soubor nemaže. Mazání při čtení by mohlo smazat záznam, který mezitím přepsal souběžný writer novými daty (race condition). Úklid expirovaných souborů dělá výhradně purge().

Vrací:

  • mixed – Uložená data
  • null – Cache miss, expirace nebo poškozený soubor

Příklad:

$users = Cache::get('users_all');
if ($users === null) {
    $users = DB::results("SELECT * FROM users");
    Cache::set('users_all', $users, 1800);
}


delete(string $key): void

Smaž konkrétní cache položku okamžitě (pokud existuje) a invaliduj OPcache.

if (is_file($file)) {
    @unlink($file);
}
opcache_invalidate($file, true);

Příklad:

// Po UPDATE musíš invalidovat cache
DB::query("UPDATE users SET name = ? WHERE id = ?", [$name, $id]);
Cache::delete('users_all');


clear(): void

Vymaž VŠECHNY cache soubory (bez ohledu na expiraci) a prázdné adresáře.

foreach (self::files() as $file) {           // glob path/*/*/*
    self::removeFile($file);                 // unlink + opcache_invalidate
}

foreach (glob(self::path() . '/*/*') as $dir) {
    @rmdir($dir);
}
foreach (glob(self::path() . '/*') as $dir) {
    @rmdir($dir);
}

self::files() vrací i osiřelé .tmp soubory – clear() je smaže také.


purge(): void

Odstraní EXPIROVANÉ cache soubory a osiřelé .tmp soubory (např. po pádu procesu mezi file_put_contents() a rename()).

$now = time();

foreach (self::files() as $file) {
    if (str_ends_with($file, '.tmp')) {
        // starší než default TTL → osiřelý zápis, smaž
        if (filemtime($file) < $now - Cache::SECONDS) {
            self::removeFile($file);
        }
        continue;
    }

    $entry = self::read($file);
    if ($entry === null || $entry[0] <= $now) {   // poškozený nebo expirovaný
        self::removeFile($file);
    }
}

Toto je jediné místo, které skutečně maže expirované záznamy – proto se má volat pravidelně (cron), viz usage.md.


file(string $filepath, string $type = 'yaml', int $ttl = self::SECONDS): array

Speciální metoda pro cachování obsahu souborů (aktuálně jen YAML). Cache záznam si nese hash zdrojového obsahu – jakákoliv změna souboru (i restore se starým mtime, i přepis stejné velikosti ve stejnou sekundu) záznam invaliduje.

$realpath = realpath($filepath);                     // kanonizace cesty
$content  = file_get_contents($realpath);

$key  = md5($type . ':' . $realpath);
$hash = hash('xxh128', $content);

$cached = self::get($key);
if (is_array($cached) && $cached['hash'] === $hash && is_array($cached['data'] ?? null)) {
    return $cached['data'];
}

$data = Yaml::parse($content) ?? [];
self::set($key, ['hash' => $hash, 'data' => $data], $ttl);

return $data;

Argumenty:

  • $filepath – Cesta k souboru (relativní i absolutní – kanonizuje se přes realpath())
  • $type – Typ souboru; podporováno pouze 'yaml' (jiný typ vrátí [])
  • $ttl – Expirace v sekundách (default: 3600)

Výjimky:

  • Soubor neexistuje / nejde přečíst → RuntimeException
  • YAML root není mapa/list (např. skalár) → RuntimeException

Vrací:

  • array – Parsovaný obsah souboru
  • array – Prázdné pole, pokud $type !== 'yaml'

Příklad:

// První volání: parsuje + cachuje (uloží i hash obsahu)
$settings = Cache::file('app/config/settings.yaml');

// Druhé volání: vrací z cache, pokud se soubor nezměnil
$settings = Cache::file('app/config/settings.yaml');


Privátní metody a properties

$path

private static ?string $path = null;

Inicializovaná v init(). Přístup jen přes self::path(), která vyhodí LogicException, pokud init() ještě neproběhl.

read(string $file): ?array

Načte a rozbalí surový cache záznam ([$expiresAt, $value]). Nikdy nemaže soubor. Chráněné proti: - neexistujícímu souboru (is_file() check) - výjimce z include (poškozený/zkrácený soubor → miss, ne fatal error) - neplatné struktuře (include vrátí false/1/cokoliv, co není [int, mixed])

assertCacheable(mixed $value, string $key, int $depth = 0): void

Zamítne hodnoty, které var_export() neumí spolehlivě zrekonstruovat – kontroluje se při zápisu (set()), ne až při čtení.

Povoleno: skaláry, null, pole (rekurzivně), UnitEnum, stdClass (rekurzivně po property). Cokoliv jiného objektové nebo resourceInvalidArgumentException. Max hloubka rekurze 64 (ochrana proti cyklickým strukturám).

files(): array

glob(path/*/*/*) – všechny soubory ve stromu, včetně osiřelých .tmp.

pathFor(string $key): string

Spočítá cestu k souboru z MD5 hashe klíče.

makeDir(string $dir): void

mkdir($dir, 0770, true) – vytvoří adresář, pokud neexistuje.

removeFile(string $file): void

unlink() (pokud soubor existuje) + invalidate().

invalidate(string $file): void

opcache_invalidate($file, true), pokud je funkce dostupná (CLI SAPI ji obvykle nemá).


Chování

Cache hit

Cache::set('key', ['a' => 1]);
$data = Cache::get('key');
// Vrací: ['a' => 1]

Cache miss (neexistuje)

$data = Cache::get('nonexistent');
// Vrací: null

Cache miss (expirovaná)

Cache::set('key', $data, 1);  // TTL = 1 sekunda
sleep(2);
$data = Cache::get('key');
// Vrací: null – soubor ale zůstává na disku, dokud ho neuklidí purge()/clear()

File format

<?php

return [1780000000, [
  'key' => 'value',
]];

První prvek pole je $expiresAt (unix timestamp), druhý je uložená hodnota z var_export(). Uloženo takto (ne přes mtime) kvůli konfliktu s OPcache – viz overview.md.


Výhody a omezení

✓ Výhody

  • Žádné dependencies – Stash je pryč
  • Atomické writes – unikátní tmp file + rename (no corruption, no race mezi souběžnými writery)
  • Expirace nezávislá na OPcache – žádný konflikt mtime vs. zkompilovaný soubor
  • Transparent – čti soubory přímo v debuggingu
  • Hashovaná struktura – miliony souborů bez FS problémů
  • Bez race condition při čteníread() nikdy nemaže

⚠️ Omezení

  • Filesystem-only – nejde Redis, Memcached, atd.
  • Lokální pouze – multi-server cache potřebuje ruční řešení
  • Malá data – optimizováno pro konfiguraci, DB výsledky, ne binární data
  • Omezené typy hodnot – jen to, co var_export()/assertCacheable() umí bezpečně zrekonstruovat (skaláry, pole, stdClass, enumy)
  • Expirované soubory se nemažou samy – nutný pravidelný purge() (cron)

Typické use cases

  1. DB query results – Cachuj výsledky SELECT, které se opakují
  2. YAML config – Cachuj parsované config soubory (Cache::file())
  3. API responses – Cachuj externí API volání
  4. Expensive computations – Math, parsing, transformations