17

Dzień 17 z 18

Cache

Opublikowany

Przyspieszamy Jobeet na dwóch poziomach: cache aplikacyjny (CacheInterface — zapamiętywanie wyników w kodzie) i HTTP Cache (odpowiedzi serwowane bez uruchamiania kontrolera). Poznamy adaptery, tagowanie i inwalidację, nagłówki ETag/Last-Modified oraz ESI dla fragmentów.

Czego się nauczysz

  • Dwa poziomy cache: aplikacyjny i HTTP
  • Cache aplikacyjny: CacheInterface::get(klucz, callback)
  • Adaptery: filesystem, Redis, APCu, array (testy)
  • Tagowanie i inwalidacja (TagAwareCacheInterface, invalidateTags)
  • HTTP Cache: setPublic, setMaxAge, ETag, 304 Not Modified
  • ESI — cache dla fragmentów o różnym czasie życia
  • Podgląd trafień/pudeł w profilerze i cache:pool:*

Co zmieniło się w Symfony 8 vs 4.2

  • Komponent Cache (PSR-6/PSR-16) zamiast sfViewCacheManager
  • Wybór adaptera jedną linią w cache.yaml (kod bez zmian)
  • Standardowy HTTP Cache (nagłówki) zamiast kontekstowego cache akcji
  • Tagowanie cache z bardzo dobrą wydajnością w Symfony 7.x

Dzień 17: Cache

Jobeet działa, jest przetestowany — czas go przyspieszyć. Dziś poznamy dwa uzupełniające się poziomy cache w Symfony: cache aplikacyjny (zapamiętywanie wyników w kodzie) i HTTP Cache (odpowiedzi serwowane bez dotykania aplikacji). Nauczymy się też tagować i inwalidować dane.

Zmiana vs Symfony 4.2: oryginał (dzień „The Cache") opierał się na sfViewCacheManager — cache'owaniu całych szablonów i akcji konfigurowanym w cache.yml, z kontekstowym cache per-akcja. Dziś mamy dwa osobne, jasne mechanizmy: komponent Cache (PSR-6/PSR-16) przez CacheInterface oraz HTTP Cache na nagłówkach (Cache-Control, ETag, Last-Modified) obsługiwany przez reverse proxy lub jądro Symfony.


Dwa poziomy cache

  • Cache aplikacyjny — zapamiętujesz w kodzie wynik kosztownej operacji (zapytanie, obliczenie, wywołanie API) pod kluczem. Kolejne żądania czytają gotowy wynik. Sterujesz nim precyzyjnie z PHP.
  • HTTP Cache — cała odpowiedź jest oznaczona nagłówkami; przeglądarka lub reverse proxy (Varnish, Symfony HttpCache) serwuje ją ponownie bez uruchamiania kontrolera. Najszybsze, ale grubszoziarniste.

Zaczniemy od aplikacyjnego, potem HTTP.


Cache aplikacyjny — CacheInterface

Symfony daje gotową pulę cache.app, wstrzykiwaną przez autowiring pod CacheInterface. Domyślny adapter (patrz config/packages/cache.yaml) zapisuje dane na dysku (var/cache), więc działa od razu, bez dodatkowej infrastruktury.

Wzorzec jest jeden — metoda get(klucz, callback): jeśli klucz istnieje, zwraca wartość z cache; jeśli nie — wykonuje callback, zapisuje wynik i go zwraca. Zastosujmy to do listy kategorii z aktywnymi ofertami (zapytanie wykonywane na każdej stronie):

// src/Service/CategoryProvider.php
namespace App\Service;

use App\Repository\CategoryRepository;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;

final readonly class CategoryProvider
{
    public function __construct(
        private CategoryRepository $categories,
        private CacheInterface $cache,
    ) {}

    /** @return array<int, \App\Entity\Category> */
    public function getActiveCategories(): array
    {
        return $this->cache->get('categories_with_active_jobs', function (ItemInterface $item) {
            $item->expiresAfter(3600); // ważne przez godzinę

            return $this->categories->findWithActiveJobs();
        });
    }
}
  • $item->expiresAfter(3600) ustawia czas życia wpisu (w sekundach). Po jego upływie callback wykona się ponownie.
  • Callback uruchamia się tylko przy pudle (cache miss). Przy trafieniu Symfony zwraca zapisaną wartość bez odpytywania bazy.

Uwaga: klucz cache musi być stabilny i unikalny. Jeśli zależy od parametrów (np. strony paginacji), zakoduj je w kluczu: "jobs_page_{$page}".


Adaptery cache

Adapter wybierasz w config/packages/cache.yaml — kod (CacheInterface) pozostaje bez zmian:

framework:
    cache:
        app: cache.adapter.filesystem       # domyślny — zapis na dysk (dev)
        # app: cache.adapter.redis          # produkcja — szybki, współdzielony
        # default_redis_provider: redis://redis:6379
        # app: cache.adapter.apcu           # pamięć procesu PHP (pojedynczy serwer)
Adapter Zastosowanie
filesystem domyślny, działa od razu, dobry na dev
redis produkcja wieloserwerowa — współdzielony, bardzo szybki
apcu jeden serwer, cache w pamięci PHP
array testy — cache w pamięci, znika po żądaniu

Zmiana vs Symfony 4.2: w symfony 1.x cache konfigurowało się w cache.yml per-środowisko i był ściśle związany z warstwą widoku. Dziś adapter to jedna linia w cache.yaml, a ten sam CacheInterface działa niezależnie od backendu (filesystem/Redis/APCu).


Tagowanie i inwalidacja

Cache czasowy (TTL) bywa niewystarczający — gdy pracodawca opublikuje nową ofertę, chcemy natychmiast odświeżyć listę, nie czekać godziny. Służą do tego tagi: oznaczasz wpisy, a potem unieważniasz je zbiorczo. Wstrzykujemy TagAwareCacheInterface:

use Symfony\Contracts\Cache\TagAwareCacheInterface;

public function getActiveCategories(): array
{
    return $this->cache->get('categories_with_active_jobs', function (ItemInterface $item) {
        $item->expiresAfter(3600);
        $item->tag('jobs');            // oznacz wpis tagiem

        return $this->categories->findWithActiveJobs();
    });
}

Po każdej zmianie ofert unieważniamy wszystko otagowane jobs — np. w akcji publikacji oferty (Dzień 8):

public function publish(Job $job, TagAwareCacheInterface $cache, EntityManagerInterface $em): Response
{
    $job->setActivated(true);
    $em->flush();

    $cache->invalidateTags(['jobs']);   // wyrzuć z cache wszystko z tagiem 'jobs'

    return $this->redirectToRoute('job_preview', ['token' => $job->getToken()]);
}

Jeden invalidateTags(['jobs']) czyści wszystkie powiązane wpisy naraz — nie musisz znać ich kluczy.

Wydajność: tagowanie w Symfony 7.x jest szybkie także na Redisie. To główny mechanizm utrzymywania spójności cache z danymi.


HTTP Cache — nagłówki odpowiedzi

Strona szczegółów oferty rzadko się zmienia — możemy pozwolić przeglądarce/proxy trzymać jej kopię. Ustawiamy nagłówki na obiekcie Response:

#[Route('/job/{token}', name: 'job_show', methods: ['GET'])]
public function show(Job $job): Response
{
    $response = $this->render('job/show.html.twig', ['job' => $job]);

    $response->setPublic();                 // może cache'ować także proxy współdzielone
    $response->setMaxAge(600);              // świeże przez 10 minut
    $response->setLastModified($job->getUpdatedAt());

    return $response;
}

Walidacja przez ETag/Last-Modified oszczędza jeszcze więcej — jeśli klient ma aktualną wersję, zwracamy 304 Not Modified bez renderowania:

public function show(Job $job, Request $request): Response
{
    $response = new Response();
    $response->setEtag(md5($job->getId() . $job->getUpdatedAt()->getTimestamp()));
    $response->setPublic();

    if ($response->isNotModified($request)) {
        return $response;                   // 304 — koniec, nic nie renderujemy
    }

    return $this->render('job/show.html.twig', ['job' => $job], $response);
}

Aby jądro Symfony samo działało jak reverse proxy w dev, otocz kernel w public/index.php klasą HttpCache (opcjonalne — na produkcji zwykle używa się Varnisha lub cache CDN).

Zmiana vs Symfony 4.2: dawne kontekstowe cache'owanie akcji (sfViewCacheManager) zastąpił standardowy HTTP Cache — te same nagłówki rozumieją przeglądarki, Varnish, CDN i Symfony HttpCache. Zero własnego formatu, pełna interoperacyjność.


ESI — cache dla fragmentów

Problem: strona oferty może być cache'owana na 10 minut, ale pasek „ostatnio oglądane" jest per-użytkownik. ESI (Edge Side Includes) pozwala cache'ować każdy fragment osobno — proxy skleja stronę z kawałków o różnym czasie życia:

{# strona cache'owana długo, ten fragment świeży #}
{{ render_esi(controller('App\\Controller\\JobController::recentlyViewed')) }}

Fragment renderuje się osobnym pod-żądaniem z własnymi nagłówkami cache. Gdy ESI nie jest dostępne, Symfony z automatu renderuje fragment inline — działa wszędzie.


Podgląd w profilerze

Web Debug Toolbar ma zakładkę Cache — pokazuje liczbę trafień (hits) i pudeł (misses) oraz czas poszczególnych operacji. To najprostszy sposób, by sprawdzić, czy cache faktycznie działa:

docker compose exec app php bin/console cache:pool:clear cache.app   # wyczyść pulę aplikacyjną
docker compose exec app php bin/console cache:pool:list              # lista pul

Podsumowanie

Jobeet jest teraz szybszy i skalowalny:

  • ✅ cache aplikacyjny przez CacheInterface::get(klucz, callback) z expiresAfter(),
  • ✅ wybór adaptera (filesystem/Redis/APCu/array) w cache.yaml bez zmian w kodzie,
  • ✅ tagowanie (TagAwareCacheInterface, $item->tag()) i natychmiastowa inwalidacja (invalidateTags()),
  • ✅ HTTP Cache przez nagłówki Response (setPublic, setMaxAge, setEtag, isNotModified → 304),
  • ✅ ESI dla fragmentów o różnym czasie życia,
  • ✅ podgląd trafień/pudeł w profilerze i czyszczenie pul komendą cache:pool:*.

W Dniu 18 — ostatnim — zajmiemy się deploymentem: wieloetapowy Dockerfile produkcyjny, pipeline CI/CD w GitHub Actions oraz dobre praktyki (sekrety, zmienne środowiskowe, rollback).

Narzędzia / paczki

symfony/cache CacheInterface HTTP Cache

Spis treści