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 wcache.yml, z kontekstowym cache per-akcja. Dziś mamy dwa osobne, jasne mechanizmy: komponent Cache (PSR-6/PSR-16) przezCacheInterfaceoraz 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.ymlper-środowisko i był ściśle związany z warstwą widoku. Dziś adapter to jedna linia wcache.yaml, a ten samCacheInterfacedział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 SymfonyHttpCache. 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)zexpiresAfter(), - ✅ wybór adaptera (filesystem/Redis/APCu/array) w
cache.yamlbez 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).