MidТеория4 min

HTTP Caching

Reverse proxies, expiration (Cache-Control, Expires), validation (ETag, Last-Modified)

Концепция HTTP-кеширования

HTTP-кеширование позволяет повторно использовать ранее полученные ответы. Существует два основных механизма: expiration (истечение) и validation (проверка).

В экзамене Sf8 ESI (Edge Side Includes) НЕ входят в темы. Фокус на базовых механизмах кеширования.

Типы кешей

Тип Расположение Описание
Browser cache Клиент Кеш браузера пользователя
Shared proxy cache Сеть CDN, Varnish, Symfony reverse proxy
Gateway cache Сервер Перед приложением

Expiration Model

Expiration говорит кешу, как долго ответ считается свежим. До истечения срока кеш не обращается к серверу.

Cache-Control

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;

final class ProductController extends AbstractController
{
    public function list(): Response
    {
        $response = $this->render('product/list.html.twig', [
            'products' => $this->getProducts(),
        ]);

        // Public cache: can be cached by CDN/proxy
        $response->setPublic();
        $response->setMaxAge(3600); // 1 hour for proxies
        $response->headers->set(
            'Cache-Control',
            'public, max-age=3600, s-maxage=7200'
        );

        return $response;
    }

    public function profile(): Response
    {
        $response = $this->render('user/profile.html.twig');

        // Private cache: only browser cache, not CDN
        $response->setPrivate();
        $response->setMaxAge(600); // 10 minutes

        return $response;
    }

    public function checkout(): Response
    {
        $response = $this->render('checkout/index.html.twig');

        // No cache at all
        $response->headers->set(
            'Cache-Control',
            'no-cache, no-store, must-revalidate'
        );

        return $response;
    }
}

Cache-Control директивы

Директива Описание
public Может кешироваться прокси и CDN
private Только браузерный кеш (per-user)
max-age=N Срок в секундах для browser cache
s-maxage=N Срок в секундах для shared cache (proxy/CDN)
no-cache Всегда проверять у сервера (валидация)
no-store Не кешировать вообще
must-revalidate Обязательно проверять после истечения
stale-while-revalidate=N Отдавать устаревший ответ, пока ревалидация

Expires Header

<?php

declare(strict_types=1);

$response->setExpires(new \DateTime('+1 hour'));

// Or specific date
$response->setExpires(new \DateTime('2025-12-31 23:59:59'));

Подвох экзамена: Cache-Control имеет приоритет над Expires. Если оба установлены, Cache-Control побеждает. Expires -- устаревший заголовок, Cache-Control -- предпочтительный.

Validation Model

Validation проверяет, изменился ли ресурс. Сервер возвращает 304 Not Modified, если ресурс не изменился -- без тела ответа.

ETag

ETag -- хеш содержимого. Клиент отправляет If-None-Match, сервер сравнивает.

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

final class ArticleController extends AbstractController
{
    public function show(Request $request, Article $article): Response
    {
        $response = new Response();

        // Generate ETag from content hash
        $etag = md5($article->getContent() . $article->getUpdatedAt()->getTimestamp());
        $response->setEtag($etag);
        $response->setPublic();

        // Check if client has current version
        if ($response->isNotModified($request)) {
            // Return 304 Not Modified (no body)
            return $response;
        }

        // Content changed -- render full response
        $response->setContent(
            $this->renderView('article/show.html.twig', [
                'article' => $article,
            ])
        );

        return $response;
    }
}

Last-Modified

Last-Modified -- дата последнего изменения. Клиент отправляет If-Modified-Since.

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

final class BlogController extends AbstractController
{
    public function index(Request $request): Response
    {
        // Get latest modification date
        $latestPost = $this->postRepository->findLatest();
        $lastModified = $latestPost->getUpdatedAt();

        $response = new Response();
        $response->setLastModified($lastModified);
        $response->setPublic();

        // Check if client already has current version
        if ($response->isNotModified($request)) {
            return $response; // 304 Not Modified
        }

        // Render full response
        $response->setContent(
            $this->renderView('blog/index.html.twig', [
                'posts' => $this->postRepository->findAll(),
            ])
        );

        return $response;
    }
}

Комбинирование ETag и Last-Modified

<?php

declare(strict_types=1);

namespace App\Controller;

final class CatalogController extends AbstractController
{
    public function show(Request $request, int $id): Response
    {
        $catalog = $this->catalogRepository->find($id);

        $response = new Response();
        $response->setPublic();
        $response->setMaxAge(3600);

        // Both ETag and Last-Modified
        $response->setEtag(md5($catalog->getVersion()));
        $response->setLastModified($catalog->getUpdatedAt());

        if ($response->isNotModified($request)) {
            return $response;
        }

        $response->setContent(
            $this->renderView('catalog/show.html.twig', [
                'catalog' => $catalog,
            ])
        );

        return $response;
    }
}

#[Cache] атрибут

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\HttpKernel\Attribute\Cache;

final class StaticController extends AbstractController
{
    #[Cache(maxage: 3600, public: true, smaxage: 7200)]
    public function termsOfService(): Response
    {
        return $this->render('static/terms.html.twig');
    }

    #[Cache(maxage: 86400, public: true)]
    public function about(): Response
    {
        return $this->render('static/about.html.twig');
    }

    #[Cache(maxage: 0, public: false)]
    public function dashboard(): Response
    {
        return $this->render('dashboard/index.html.twig');
    }
}

Reverse Proxy (HttpCache)

Symfony включает встроенный reverse proxy.

<?php

declare(strict_types=1);

// public/index.php
use App\Kernel;

require_once dirname(__DIR__) . '/vendor/autoload_runtime.php';

return function (array $context) {
    $kernel = new Kernel(
        $context['APP_ENV'],
        (bool) $context['APP_DEBUG'],
    );

    // Wrap kernel with HttpCache reverse proxy
    if ('prod' === $context['APP_ENV']) {
        $kernel = new \Symfony\Component\HttpKernel\HttpCache\HttpCache(
            $kernel,
            new \Symfony\Component\HttpKernel\HttpCache\Store(
                __DIR__ . '/../var/cache/http_cache'
            ),
        );
    }

    return $kernel;
};

Vary Header

Vary указывает, какие заголовки запроса влияют на кешированный ответ.

<?php

declare(strict_types=1);

$response->setVary(['Accept-Encoding', 'Accept-Language']);
// Cache stores different versions for different Accept-Encoding/Language

Итоги

  • Expiration: Cache-Control (max-age, s-maxage, public/private)
  • Validation: ETag (хеш) и Last-Modified (дата)
  • isNotModified($request) возвращает 304 без тела ответа
  • Cache-Control имеет приоритет над Expires
  • public -- кешируется CDN/proxy, private -- только браузер
  • #[Cache] атрибут для декларативного кеширования
  • Vary header определяет варианты кеша по заголовкам запроса