Passo 21: Caching per le prestazioni

5.0 version
Maintained

Caching per le prestazioni

I problemi di prestazioni sono una conseguenza della popolarità. Alcuni esempi tipici: indici mancanti nelle tabelle del database o troppe query SQL per pagina. Non avrete problemi di prestazioni con un database vuoto, ma potreste averne all’aumentare del traffico e dei dati.

Header HTTP per la cache

L’utilizzo di strategie di cache HTTP è un ottimo modo per migliorare le prestazioni con poco sforzo. Potete aggiungete un reverse proxy in produzione per abilitare la cache e usare un CDN per sfruttare una rete distribuita di cache, per prestazioni ancora migliori.

Mettiamo in cache l’homepage per un’ora:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -37,9 +37,12 @@ class ConferenceController extends AbstractController
      */
     public function index(ConferenceRepository $conferenceRepository)
     {
-        return new Response($this->twig->render('conference/index.html.twig', [
+        $response = new Response($this->twig->render('conference/index.html.twig', [
             'conferences' => $conferenceRepository->findAll(),
         ]));
+        $response->setSharedMaxAge(3600);
+
+        return $response;
     }

     /**

Il metodo setSharedMaxAge() configura la scadenza della cache per i reverse proxy. Utilizzare il metodo setMaxAge() per impostare il tempo di cache per i browser. Il tempo è espresso in secondi (1 ora = 60 minuti = 3600 secondi).

Gestire la cache della pagina della conferenza è più impegnativo, perché è una pagina più dinamica. Chiunque può aggiungere un commento in qualsiasi momento e nessuno vuole aspettare un’ora per vederlo online. In questi casi, utilizzare la strategia di validazione HTTP .

Attivare la cache HTTP nel kernel di Symfony

Per testare la strategia di cache HTTP, occorre usare il reverse proxy HTTP di Symfony:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
--- a/public/index.php
+++ b/public/index.php
@@ -1,6 +1,7 @@
 <?php

 use App\Kernel;
+use Symfony\Bundle\FrameworkBundle\HttpCache\HttpCache;
 use Symfony\Component\ErrorHandler\Debug;
 use Symfony\Component\HttpFoundation\Request;

@@ -21,6 +22,11 @@ if ($trustedHosts = $_SERVER['TRUSTED_HOSTS'] ?? $_ENV['TRUSTED_HOSTS'] ?? false
 }

 $kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
+
+if ('dev' === $kernel->getEnvironment()) {
+    $kernel = new HttpCache($kernel);
+}
+
 $request = Request::createFromGlobals();
 $response = $kernel->handle($request);
 $response->send();

Oltre a essere un vero e proprio reverse proxy HTTP, il reverse proxy HTTP di Symfony (tramite la libreria HttpCache) aggiunge alcune informazioni di debug negli header HTTP. Questo aiuta durante la validazione degli header per la cache che abbiamo impostato.

Controlliamo l’homepage:

1
$ curl -s -I -X GET https://127.0.0.1:8000/
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
HTTP/2 200
age: 0
cache-control: public, s-maxage=3600
content-type: text/html; charset=UTF-8
date: Mon, 28 Oct 2019 08:11:57 GMT
x-content-digest: en63cef7045fe418859d73668c2703fb1324fcc0d35b21d95369a9ed1aca48e73e
x-debug-token: 9eb25a
x-debug-token-link: https://127.0.0.1:8000/_profiler/9eb25a
x-robots-tag: noindex
x-symfony-cache: GET /: miss, store
content-length: 50978

Durante la prima richiesta, il server di cache ci informa che non ha trovato la risposta nella cache (miss) e ha memorizzato (store) la risposta nella cache. Controllare l’intestazione HTTP chiamata cache-control per vedere la strategia di cache configurata.

Nelle successive richieste, le risposte vengono prese dalla cache (anche l’header HTTP age è stato aggiornato):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
HTTP/2 200
age: 143
cache-control: public, s-maxage=3600
content-type: text/html; charset=UTF-8
date: Mon, 28 Oct 2019 08:11:57 GMT
x-content-digest: en63cef7045fe418859d73668c2703fb1324fcc0d35b21d95369a9ed1aca48e73e
x-debug-token: 9eb25a
x-debug-token-link: https://127.0.0.1:8000/_profiler/9eb25a
x-robots-tag: noindex
x-symfony-cache: GET /: fresh
content-length: 50978

Evitare richieste SQL con ESI

Il listener TwigEventSubscriber aggiunge una variabile globale in Twig con tutti gli oggetti conferenza. Questo viene fatto per ogni singola pagina del sito. Probabilmente è un buon metodo di ottimizzazione.

Non si aggiungono nuove conferenze ogni giorno, quindi il codice interroga gli stessi identici dati dal database più e più volte.

Potremmo voler mettere in cache i nomi e gli slug delle conferenze con la cache di Symfony, ma quando possibile mi piace fare affidamento sull’infrastruttura di cache HTTP.

Quando vogliamo mettere in cache un frammento di una pagina, lo spostiamo al di fuori della richiesta HTTP corrente creando una sotto-richiesta . Per questo caso d’uso ESI è la soluzione perfetta. ESI è un modo per incorporare il risultato di una richiesta HTTP in un’altra.

Creiamo un controller che restituisce solo il frammento HTML che visualizza le conferenze:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -45,6 +45,16 @@ class ConferenceController extends AbstractController
         return $response;
     }

+    /**
+     * @Route("/conference_header", name="conference_header")
+     */
+    public function conferenceHeader(ConferenceRepository $conferenceRepository)
+    {
+        return new Response($this->twig->render('conference/header.html.twig', [
+            'conferences' => $conferenceRepository->findAll(),
+        ]));
+    }
+
     /**
      * @Route("/conference/{slug}", name="conference")
      */

Creiamo il template corrispondente:

templates/conference/header.html.twig
1
2
3
4
5
<ul>
    {% for conference in conferences %}
        <li><a href="{{ path('conference', { slug: conference.slug }) }}">{{ conference }}</a></li>
    {% endfor %}
</ul>

Apriamo /conference_header per controllare che tutto funzioni correttamente.

È ora di svelare il trucco! Aggiorniamo il layout Twig per chiamare il controller che abbiamo appena creato:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -8,11 +8,7 @@
     <body>
         <header>
             <h1><a href="{{ path('homepage') }}">Guestbook</a></h1>
-            <ul>
-            {% for conference in conferences %}
-                <li><a href="{{ path('conference', { slug: conference.slug }) }}">{{ conference }}</a></li>
-            {% endfor %}
-            </ul>
+            {{ render(path('conference_header')) }}
             <hr />
         </header>
         {% block body %}{% endblock %}

E voilà. Aggiorniamo la pagina e il sito continuerà a mostrare le stesse informazioni.

Suggerimento

Usiamo il pannello «Request / Response» del Profiler di Symfony per saperne di più sulla richiesta principale e sulle sue sotto-richieste.

Ora, ogni volta che si raggiunge una pagina nel browser vengono eseguite due richieste HTTP: una per l’intestazione e una per la pagina principale. Abbiamo peggiorato le prestazioni. Congratulazioni!

La chiamata HTTP all’intestazione della conferenza è attualmente effettuata internamente da Symfony, quindi non è previsto alcun round-trip HTTP. Questo significa anche che non c’è modo di beneficiare degli header della cache HTTP.

Convertire la chiamata in una chiamata HTTP «reale» utilizzando ESI.

In primo luogo, attiviamo il supporto ESI:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
--- a/config/packages/framework.yaml
+++ b/config/packages/framework.yaml
@@ -10,7 +10,7 @@ framework:
         cookie_secure: auto
         cookie_samesite: lax

-    #esi: true
+    esi: true
     #fragments: true
     php_errors:
         log: true

Quindi, utilizziamo render_esi al posto di render:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -8,7 +8,7 @@
     <body>
         <header>
             <h1><a href="{{ path('homepage') }}">Guestbook</a></h1>
-            {{ render(path('conference_header')) }}
+            {{ render_esi(path('conference_header')) }}
             <hr />
         </header>
         {% block body %}{% endblock %}

Se Symfony rileva un reverse proxy che sa come trattare gli ESI, abilita automaticamente il supporto (in caso contrario, ritorna al render sincrono della richiesta secondaria).

Poiché il reverse proxy di Symfony supporta ESI, controlliamo i suoi log (rimuoviamo prima la cache, si veda la sezione «Pulizia» più avanti):

1
$ curl -s -I -X GET https://127.0.0.1:8000/
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
HTTP/2 200
age: 0
cache-control: must-revalidate, no-cache, private
content-type: text/html; charset=UTF-8
date: Mon, 28 Oct 2019 08:20:05 GMT
expires: Mon, 28 Oct 2019 08:20:05 GMT
x-content-digest: en4dd846a34dcd757eb9fd277f43220effd28c00e4117bed41af7f85700eb07f2c
x-debug-token: 719a83
x-debug-token-link: https://127.0.0.1:8000/_profiler/719a83
x-robots-tag: noindex
x-symfony-cache: GET /: miss, store; GET /conference_header: miss
content-length: 50978

Aggiorniamo qualche volta la pagina: la risposta relativa al percorso / è salvata in cache, mentre quella relativa al percorso /conference_header non lo è. Abbiamo ottenuto un grande risultato: l’intera pagina è in cache, ma una sua parte è ancora dinamica.

Ma non è quello che vogliamo. Manteniamo in cache la pagina di intestazione per un’ora, indipendentemente da tutto il resto:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -50,9 +50,12 @@ class ConferenceController extends AbstractController
      */
     public function conferenceHeader(ConferenceRepository $conferenceRepository)
     {
-        return new Response($this->twig->render('conference/header.html.twig', [
+        $response = new Response($this->twig->render('conference/header.html.twig', [
             'conferences' => $conferenceRepository->findAll(),
         ]));
+        $response->setSharedMaxAge(3600);
+
+        return $response;
     }

     /**

La cache è ora abilitata per entrambe le richieste:

1
$ curl -s -I -X GET https://127.0.0.1:8000/
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
HTTP/2 200
age: 613
cache-control: public, s-maxage=3600
content-type: text/html; charset=UTF-8
date: Mon, 28 Oct 2019 07:31:24 GMT
x-content-digest: en15216b0803c7851d3d07071473c9f6a3a3360c6a83ccb0e550b35d5bc484bbd2
x-debug-token: cfb0e9
x-debug-token-link: https://127.0.0.1:8000/_profiler/cfb0e9
x-robots-tag: noindex
x-symfony-cache: GET /: fresh; GET /conference_header: fresh
content-length: 50978

L’intestazione x-symfony-cache contiene due elementi: la richiesta principale / e una richiesta secondaria (l’ESI conference_header). Entrambi sono in cache (fresh).

La strategia di cache può essere diversa tra la pagina principale e i suoi ESI. Se abbiamo una pagina «about», potremmo volerla conservare per una settimana in cache ma avere comunque l’intestazione aggiornata ogni ora.

Rimuoviamo il listener, non ne abbiamo più bisogno:

1
$ rm src/EventSubscriber/TwigEventSubscriber.php

Pulire la cache HTTP per i test

Testare il sito in un browser o tramite test automatici diventa un po' più difficile quando c’è un livello di cache.

È possibile rimuovere manualmente tutta la cache HTTP, svuotando la cartella var/cache/dev/http_cache/:

1
$ rm -rf var/cache/dev/http_cache/

Questa strategia non funziona bene se si vogliono invalidare solo alcuni URL o se si vuole integrare l’invalidazione della cache nei test funzionali. Aggiungiamo un piccolo endpoint HTTP, solo per l’amministrazione, per invalidare alcuni URL:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
--- a/src/Controller/AdminController.php
+++ b/src/Controller/AdminController.php
@@ -6,8 +6,10 @@ use App\Entity\Comment;
 use App\Message\CommentMessage;
 use Doctrine\ORM\EntityManagerInterface;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Bundle\FrameworkBundle\HttpCache\HttpCache;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\KernelInterface;
 use Symfony\Component\Messenger\MessageBusInterface;
 use Symfony\Component\Routing\Annotation\Route;
 use Symfony\Component\Workflow\Registry;
@@ -54,4 +56,19 @@ class AdminController extends AbstractController
             'comment' => $comment,
         ]);
     }
+
+    /**
+     * @Route("/admin/http-cache/{uri<.*>}", methods={"PURGE"})
+     */
+    public function purgeHttpCache(KernelInterface $kernel, Request $request, string $uri)
+    {
+        if ('prod' === $kernel->getEnvironment()) {
+            return new Response('KO', 400);
+        }
+
+        $store = (new class($kernel) extends HttpCache {})->getStore();
+        $store->purge($request->getSchemeAndHttpHost().'/'.$uri);
+
+        return new Response('Done');
+    }
 }

Il nuovo controller è stato limitato al metodo HTTP PURGE. Questo metodo non fa parte dello standard HTTP, ma è ampiamente usato per invalidare le cache.

Per impostazione predefinita, i parametri della rotta non possono contenere /, visto che funge da separatore negli URL. È possibile sovrascrivere questa restrizione per l’ultimo parametro della rotta, come uri, impostando lo schema dei requisiti (.*).

Il modo in cui otteniamo l’istanza di HttpCache può anche sembrare un po' strano; stiamo usando una classe anonima perché l’accesso a quella «reale» non è possibile. L’istanza di HttpCache avvolge il kernel reale che, come è giusto che sia, non è a conoscenza del livello di cache.

Invalidiamo la homepage e l’intestazione della conferenza tramite le seguenti chiamate cURL:

1
2
$ curl -I -X PURGE -u admin:admin `symfony var:export SYMFONY_DEFAULT_ROUTE_URL`/admin/http-cache/
$ curl -I -X PURGE -u admin:admin `symfony var:export SYMFONY_DEFAULT_ROUTE_URL`/admin/http-cache/conference_header

Il comando symfony var:export SYMFONY_DEFAULT_ROUTE_URL restituisce l’URL corrente del server web locale.

Nota

Il controller non ha un nome di rotta, in quanto non sarà mai menzionato nel codice.

Raggruppamento di percorsi simili con un prefisso

Le due rotte del controller di amministrazione hanno lo stesso prefisso /admin. Invece di ripeterlo su tutte le rotte, rifattorizziamole in modo da configurare il prefisso sulla classe stessa:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
--- a/src/Controller/AdminController.php
+++ b/src/Controller/AdminController.php
@@ -15,6 +15,9 @@ use Symfony\Component\Routing\Annotation\Route;
 use Symfony\Component\Workflow\Registry;
 use Twig\Environment;

+/**
+ * @Route("/admin")
+ */
 class AdminController extends AbstractController
 {
     private $twig;
@@ -29,7 +32,7 @@ class AdminController extends AbstractController
     }

     /**
-     * @Route("/admin/comment/review/{id}", name="review_comment")
+     * @Route("/comment/review/{id}", name="review_comment")
      */
     public function reviewComment(Request $request, Comment $comment, Registry $registry)
     {
@@ -58,7 +61,7 @@ class AdminController extends AbstractController
     }

     /**
-     * @Route("/admin/http-cache/{uri<.*>}", methods={"PURGE"})
+     * @Route("/http-cache/{uri<.*>}", methods={"PURGE"})
      */
     public function flushHttpCache(KernelInterface $kernel, Request $request, string $uri)
     {

Cache di operazioni che richiedono molta CPU o memoria

Non abbiamo algoritmi che impegnino molta CPU o memoria in questo sito. Per parlare di cache locali , creeremo un comando che mostra il passo su cui stiamo lavorando (per essere più precisi, il nome del tag di git associato al commit corrente).

Il componente Process di Symfony permette di eseguire un comando e ottenerne il risultato (standard output ed error output). Installiamolo:

1
$ symfony composer req process

Implementiamo il comando:

src/Command/StepInfoCommand.php
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
namespace App\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;

class StepInfoCommand extends Command
{
    protected static $defaultName = 'app:step:info';

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $process = new Process(['git', 'tag', '-l', '--points-at', 'HEAD']);
        $process->mustRun();
        $output->write($process->getOutput());

        return 0;
    }
}

Nota

Avremmo potuto usare make:command per creare il comando:

1
$ symfony console make:command app:step:info

E se volessimo mettere in cache l’output per qualche minuto? Usiamo il componente Cache di Symfony:

1
$ symfony composer req cache

E aggiungiamo la logica della cache al codice:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
--- a/src/Command/StepInfoCommand.php
+++ b/src/Command/StepInfoCommand.php
@@ -6,16 +6,31 @@ use Symfony\Component\Console\Command\Command;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Output\OutputInterface;
 use Symfony\Component\Process\Process;
+use Symfony\Contracts\Cache\CacheInterface;

 class StepInfoCommand extends Command
 {
     protected static $defaultName = 'app:step:info';

+    private $cache;
+
+    public function __construct(CacheInterface $cache)
+    {
+        $this->cache = $cache;
+
+        parent::__construct();
+    }
+
     protected function execute(InputInterface $input, OutputInterface $output): int
     {
-        $process = new Process(['git', 'tag', '-l', '--points-at', 'HEAD']);
-        $process->mustRun();
-        $output->write($process->getOutput());
+        $step = $this->cache->get('app.current_step', function ($item) {
+            $process = new Process(['git', 'tag', '-l', '--points-at', 'HEAD']);
+            $process->mustRun();
+            $item->expiresAfter(30);
+
+            return $process->getOutput();
+        });
+        $output->writeln($step);

         return 0;
     }

Il processo è ora richiamato solo se l’elemento app.current_step non è in cache.

Profilazione e confronto delle prestazioni

Evitiamo di aggiungere cache alla cieca. Teniamo presente che l’aggiunta di cache aggiunge uno strato di complessità e, dato che siamo tutti pessimi nell’indovinare cosa sarà veloce e cosa sarà lento, potremmo ritrovarci in una situazione in cui la cache rallenti la nostra applicazione.

Misuriamo sempre l’impatto dell’aggiunta di una cache con uno strumento di profilazione come Blackfire.

Fare riferimento al passo «Prestazioni» per saperne di più su come utilizzare Blackfire per testare il codice prima del deploy.

Configurazione di una cache di reverse proxy in produzione

Non usare il reverse proxy di Symfony in produzione. Preferire sempre un reverse proxy come Varnish sulla propria infrastruttura o su una CDN commerciale.

Aggiungere Varnish ai servizi di SymfonyCloud:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
--- a/.symfony/services.yaml
+++ b/.symfony/services.yaml
@@ -7,3 +7,12 @@ queue:
     type: rabbitmq:3.5
     disk: 1024
     size: S
+
+varnish:
+    type: varnish:6.0
+    relationships:
+        application: 'app:http'
+    configuration:
+        vcl: !include
+            type: string
+            path: config.vcl

Utilizzare Varnish come punto di ingresso principale nelle rotte:

patch_file
1
2
3
4
5
6
--- a/.symfony/routes.yaml
+++ b/.symfony/routes.yaml
@@ -1,2 +1,2 @@
-"https://{all}/": { type: upstream, upstream: "app:http" }
+"https://{all}/": { type: upstream, upstream: "varnish:http", cache: { enabled: false } }
 "http://{all}/": { type: redirect, to: "https://{all}/" }

Infine, creiamo un file di configurazione config.vcl per Varnish:

.symfony/config.vcl
1
2
3
sub vcl_recv {
    set req.backend_hint = application.backend();
}

Abilitare il supporto a ESI su Varnish

Il supporto ESI su Varnish dovrebbe essere abilitato esplicitamente per ogni richiesta. Per renderlo universale, Symfony usa lo standard Surrogate-Capability e gli header Surrogate-Control per negoziare il supporto ESI:

.symfony/config.vcl
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
sub vcl_recv {
    set req.backend_hint = application.backend();
    set req.http.Surrogate-Capability = "abc=ESI/1.0";
}

sub vcl_backend_response {
    if (beresp.http.Surrogate-Control ~ "ESI/1.0") {
        unset beresp.http.Surrogate-Control;
        set beresp.do_esi = true;
    }
}

Pulire la cache di Varnish

L’invalidazione della cache in produzione probabilmente non dovrebbe mai essere necessaria, tranne che per scopi di emergenza e forse su branch diversi da master. Se è necessario pulire spesso la cache, probabilmente significa che la strategia di cache dovrebbe essere ottimizzata (abbassando il TTL o usando una strategia di validazione invece di una strategia di scadenza).

In ogni caso, vediamo come configurare Varnish per invalidare la cache:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
--- a/.symfony/config.vcl
+++ b/.symfony/config.vcl
@@ -1,6 +1,13 @@
 sub vcl_recv {
     set req.backend_hint = application.backend();
     set req.http.Surrogate-Capability = "abc=ESI/1.0";
+
+    if (req.method == "PURGE") {
+        if (req.http.x-purge-token != "PURGE_NOW") {
+            return(synth(405));
+        }
+        return (purge);
+    }
 }

 sub vcl_backend_response {

Nella vita reale, probabilmente utilizzeremo limitazioni sulla base dell’indirizzo IP, come descritto nella documentazione di Varnish.

Ora invalidiamo alcuni URL:

1
2
$ curl -X PURGE -H 'x-purge-token PURGE_NOW' `symfony env:urls --first`
$ curl -X PURGE -H 'x-purge-token PURGE_NOW' `symfony env:urls --first`conference_header

Gli URL sembrano un po' strani, poiché quelli restituiti da env:urls finiscono già con /.


  • « Previous Passo 20: Invio di e-mail agli amministratori
  • Next » Passo 22: Impostare lo stile dell’interfaccia utente con Webpack

This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.