Mettre en cache pour la performance
Les problĂšmes de performance peuvent survenir avec la popularitĂ©. Quelques exemples typiques : des index de base de donnĂ©es manquants ou des tonnes de requĂȘtes SQL par page. Vous n'aurez aucun problĂšme avec une base de donnĂ©es vide, mais avec plus de trafic et des donnĂ©es croissantes, cela peut arriver Ă un moment donnĂ©.
Ajouter des en-tĂȘtes de cache HTTP
L'utilisation de stratégies de mise en cache HTTP est un excellent moyen de maximiser les performances de notre site avec un minimum d'effort. Ajoutez un cache reverse proxy en production pour permettre la mise en cache et utilisez un CDN pour aller encore plus loin.
Mettons en cache la page d'accueil pendant une heure :
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
@@ -33,9 +33,12 @@ class ConferenceController extends AbstractController
#[Route('/', name: 'homepage')]
public function index(ConferenceRepository $conferenceRepository): Response
{
- 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;
}
#[Route('/conference/{slug}', name: 'conference')]
La méthode setSharedMaxAge()
configure l'expiration du cache pour les reverse proxies. Utiliser setMaxAge()
permet de contrÎler le cache du navigateur. Le temps est exprimé en secondes (1 heure = 60 minutes = 3600 secondes).
La mise en cache de la page de la conférence est plus difficile car elle est plus dynamique. N'importe qui peut ajouter un commentaire à tout moment, et personne ne veut attendre une heure pour le voir en ligne. Dans de tels cas, utilisez la stratégie de validation HTTP.
Activer le noyau de cache HTTP de Symfony
Pour tester la stratégie de cache HTTP, activez le reverse proxy HTTP de Symfony, mais seulement dans l'environnement de "développement" (pour l'environnement de "production", nous utiliserons une solution "plus robuste") :
1 2 3 4 5 6 7 8 9 10
--- a/config/packages/framework.yaml
+++ b/config/packages/framework.yaml
@@ -22,3 +22,7 @@ when@test:
test: true
session:
storage_factory_id: session.storage.factory.mock_file
+
+when@dev:
+ framework:
+ http_cache: true
En plus d'ĂȘtre un vĂ©ritable reverse proxy HTTP, le reverse proxy HTTP de Symfony (via la classe HttpCache
) ajoute quelques informations de dĂ©bogage sous forme d'en-tĂȘtes HTTP. Cela aide grandement Ă valider les en-tĂȘtes de cache que nous avons dĂ©finis.
VĂ©rifiez sur la page d'accueil :
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
Pour la toute premiĂšre requĂȘte, le serveur de cache vous indique que c'Ă©tait un miss
et qu'il a exécuté une action de store
pour mettre la rĂ©ponse en cache. VĂ©rifiez l'en-tĂȘte cache-control
pour voir la stratégie de cache configurée.
Pour les prochaines demandes, la réponse est mise en cache (l'age
a également été mis à jour) :
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
Ăviter des requĂȘtes SQL avec les ESIs
Le listener TwigEventSubscriber
injecte une variable globale dans Twig avec tous les objets de conférence, et ce sur chaque page du site web. C'est probablement une excellente chose à optimiser.
Vous n'ajouterez pas de nouvelles confĂ©rences tous les jours, donc le code interroge la base de donnĂ©es pour rĂ©cupĂ©rer exactement les mĂȘmes donnĂ©es encore et encore.
Nous pourrions vouloir mettre en cache les noms et les slugs des conférences avec le cache Symfony, mais dÚs que possible, j'aime me reposer sur le systÚme de mise en cache HTTP.
Lorsque vous voulez mettre en cache un fragment d'une page, dĂ©placez-le en dehors de la requĂȘte HTTP en cours en crĂ©ant une sous-requĂȘte. ESI correspond parfaitement Ă ce cas d'utilisation. Un ESI est un moyen d'intĂ©grer le rĂ©sultat d'une requĂȘte HTTP dans une autre.
Créez un contrÎleur qui ne renvoie que le fragment HTML qui affiche les conférences :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -41,6 +41,14 @@ class ConferenceController extends AbstractController
return $response;
}
+ #[Route('/conference_header', name: 'conference_header')]
+ public function conferenceHeader(ConferenceRepository $conferenceRepository): Response
+ {
+ return new Response($this->twig->render('conference/header.html.twig', [
+ 'conferences' => $conferenceRepository->findAll(),
+ ]));
+ }
+
#[Route('/conference/{slug}', name: 'conference')]
public function show(Request $request, Conference $conference, CommentRepository $commentRepository, string $photoDir): Response
{
Créez le template correspondant :
Interrogez la route /conference_header
pour vérifier que tout fonctionne bien.
Il est temps de dévoiler l'astuce ! Mettez à jour le template Twig pour appeler le contrÎleur que nous venons de créer :
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
@@ -16,11 +16,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 %}
Et voilĂ . RafraĂźchissez la page et le site web affiche toujours la mĂȘme chose.
Tip
Utilisez le panneau du profileur Symfony "Request / Response" pour en savoir plus sur la requĂȘte principale et ses sous-requĂȘtes.
Maintenant, chaque fois que vous affichez une page dans le navigateur, deux requĂȘtes HTTP sont exĂ©cutĂ©es : une pour l'en-tĂȘte et une pour la page principale. Vous avez dĂ©gradĂ© les performances. FĂ©licitations !
L'appel HTTP pour l'en-tĂȘte est actuellement effectuĂ© en interne par Symfony, donc aucun aller-retour HTTP n'est impliquĂ©. Cela signifie Ă©galement qu'il n'y a aucun moyen de bĂ©nĂ©ficier des en-tĂȘtes de cache HTTP.
Convertissez l'appel en un "vrai" appel HTTP Ă l'aide d'un ESI.
Tout d'abord, activez le support ESI :
1 2 3 4 5 6 7 8 9 10 11
--- a/config/packages/framework.yaml
+++ b/config/packages/framework.yaml
@@ -12,7 +12,7 @@ framework:
cookie_samesite: lax
storage_factory_id: session.storage.factory.native
- #esi: true
+ esi: true
#fragments: true
php_errors:
log: true
Ensuite, utilisez render_esi
au lieu de render
:
1 2 3 4 5 6 7 8 9 10 11
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -16,7 +16,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 %}
Si Symfony détecte un reverse proxy qui sait comment traiter les ESIs, il active automatiquement le support (sinon, par défaut, il génÚre le rendu de la sous-demande de maniÚre synchrone).
Comme le reverse proxy de Symfony supporte les ESIs, vérifions ses logs (supprimons d'abord le cache - voir "Purger le cache" ci-dessous) :
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
Rafraßchissez quelques fois : la réponse à la route/
est mise en cache et celle Ă /conference_header
ne l'est pas. Nous avons réalisé quelque chose de génial : toute la page est dans le cache mais elle conserve toujours une partie dynamique.
Mais ce n'est pas ce que nous voulons. Mettez l'en-tĂȘte de la page en cache pendant une heure, indĂ©pendamment de tout le reste :
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
@@ -44,9 +44,12 @@ class ConferenceController extends AbstractController
#[Route('/conference_header', name: 'conference_header')]
public function conferenceHeader(ConferenceRepository $conferenceRepository): Response
{
- 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;
}
#[Route('/conference/{slug}', name: 'conference')]
Le cache est maintenant activĂ© pour les deux requĂȘtes :
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'en-tĂȘte x-symfony-cache
contient deux Ă©lĂ©ments : la requĂȘte principale /
et une sous-requĂȘte (l'ESI conference_header
). Les deux sont dans le cache (fresh
).
La stratĂ©gie de cache peut ĂȘtre diffĂ©rente entre la page principale et ses ESIs. Si nous avons une page "about", nous pourrions vouloir la stocker pendant une semaine dans le cache, tout en ayant l'en-tĂȘte mis Ă jour toutes les heures.
Supprimez le listener car nous n'en avons plus besoin :
1
$ rm src/EventSubscriber/TwigEventSubscriber.php
Purger le cache HTTP pour les tests
Tester le site web dans un navigateur ou via des tests automatisés devient un peu plus difficile avec une couche de cache.
Vous pouvez supprimer manuellement tout le cache HTTP en supprimant le répertoire var/cache/dev/http_cache/
:
1
$ rm -rf var/cache/dev/http_cache/
Cette stratégie ne fonctionne pas bien si vous voulez seulement invalider certaines URLs ou si vous voulez intégrer l'invalidation du cache dans vos tests fonctionnels. Ajoutons un petit point d'entrée HTTP, réservé à l'admin, pour invalider certaines URLs :
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
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -36,3 +36,5 @@ services:
tags:
- { name: 'doctrine.orm.entity_listener', event: 'prePersist', entity: 'App\Entity\Conference'}
- { name: 'doctrine.orm.entity_listener', event: 'preUpdate', entity: 'App\Entity\Conference'}
+
+ Symfony\Component\HttpKernel\HttpCache\StoreInterface: '@http_cache.store'
--- a/src/Controller/AdminController.php
+++ b/src/Controller/AdminController.php
@@ -8,6 +8,8 @@ use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\HttpCache\StoreInterface;
+use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Workflow\Registry;
@@ -52,4 +54,16 @@ class AdminController extends AbstractController
'comment' => $comment,
]));
}
+
+ #[Route('/admin/http-cache/{uri<.*>}', methods: ['PURGE'])]
+ public function purgeHttpCache(KernelInterface $kernel, Request $request, string $uri, StoreInterface $store): Response
+ {
+ if ('prod' === $kernel->getEnvironment()) {
+ return new Response('KO', 400);
+ }
+
+ $store->purge($request->getSchemeAndHttpHost().'/'.$uri);
+
+ return new Response('Done');
+ }
}
Le nouveau contrÎleur a été limité à la méthode HTTP PURGE
. Cette méthode n'est pas dans le standard HTTP, mais elle est largement utilisée pour invalider les caches.
Par défaut, les paramÚtres de routage ne peuvent pas contenir /
car ils séparent les segments d'une URL. Vous pouvez remplacer cette restriction pour le dernier paramÚtre de routage, comme uri
par exemple, en définissant votre propre masque (.*
).
La maniĂšre par laquelle nous obtenons l'instance HttpCache
peut aussi sembler un peu étrange ; nous utilisons une classe anonyme, car l'accÚs à la classe "réelle" n'est pas possible. L'instance HttpCache
enveloppe le noyau réel, qui n'est volontairement pas conscient de la couche de cache.
Invalidez la page d'accueil et l'en-tĂȘte avec les confĂ©rences via les appels cURL suivants :
1 2
$ curl -s -I -X PURGE -u admin:admin `symfony var:export SYMFONY_PROJECT_DEFAULT_ROUTE_URL`/admin/http-cache/
$ curl -s -I -X PURGE -u admin:admin `symfony var:export SYMFONY_PROJECT_DEFAULT_ROUTE_URL`/admin/http-cache/conference_header
La sous-commande symfony var:export SYMFONY_PROJECT_DEFAULT_ROUTE_URL
retourne l'URL courante du serveur web local.
Note
Le contrÎleur n'a pas de nom de route car il ne sera jamais référencé dans le code.
Regrouper les routes similaires avec un préfixe
Les deux routes du contrĂŽleur admin ont le mĂȘme prĂ©fixe /admin
. Au lieu de le rĂ©pĂ©ter sur toutes les routes, refactorisez-les pour configurer le prĂ©fixe sur la classe elle-mĂȘme :
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
--- a/src/Controller/AdminController.php
+++ b/src/Controller/AdminController.php
@@ -15,6 +15,7 @@ use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Workflow\Registry;
use Twig\Environment;
+#[Route('/admin')]
class AdminController extends AbstractController
{
private $twig;
@@ -28,7 +29,7 @@ class AdminController extends AbstractController
$this->bus = $bus;
}
- #[Route('/admin/comment/review/{id}', name: 'review_comment')]
+ #[Route('/comment/review/{id}', name: 'review_comment')]
public function reviewComment(Request $request, Comment $comment, Registry $registry): Response
{
$accepted = !$request->query->get('reject');
@@ -55,7 +56,7 @@ class AdminController extends AbstractController
]));
}
- #[Route('/admin/http-cache/{uri<.*>}', methods: ['PURGE'])]
+ #[Route('/http-cache/{uri<.*>}', methods: ['PURGE'])]
public function purgeHttpCache(KernelInterface $kernel, Request $request, string $uri, StoreInterface $store): Response
{
if ('prod' === $kernel->getEnvironment()) {
Mettre en cache les opérations coûteuses en CPU/mémoire
Nous n'avons pas d'algorithmes gourmands en CPU ou en mĂ©moire sur le site web. Pour parler des caches locaux, crĂ©ons une commande qui affiche l'Ă©tape en cours sur laquelle nous travaillons (pour ĂȘtre plus prĂ©cis, le nom du tag Git attachĂ© au commit actuel).
Le composant Symfony Process vous permet d'exécuter une commande et de récupérer le résultat (sortie standard et erreur).
Créez la commande :
Note
Vous auriez pu utiliser make:command
pour créer la commande :
1
$ symfony console make:command app:step:info
Et si on veut mettre le résultat en cache pendant quelques minutes ? Utilisez le cache Symfony.
Et insérez le code dans la logique de cache :
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;
}
Le processus n'est maintenant appelé que si l'élément app.current_step
n'est pas dans le cache.
Analyser et comparer les performances
N'ajoutez jamais de cache Ă l'aveuglette. Gardez Ă l'esprit que l'ajout d'un cache ajoute une couche de complexitĂ©. Et comme nous sommes tous trĂšs mauvais pour deviner ce qui sera rapide et ce qui est lent, vous pourriez vous retrouver dans une situation oĂč le cache rend votre application plus lente.
Mesurez toujours l'impact de l'ajout d'un cache avec un outil de profilage comme Blackfire.
Reportez-vous à l'étape "Performances" pour en savoir plus sur la façon dont vous pouvez utiliser Blackfire pour tester votre code avant de le déployer.
Configurer un cache de reverse proxy en production
PlutĂŽt que d'utiliser le reverse proxy Symfony en production, nous allons utiliser Varnish, un reverse proxy "plus robuste".
Ajoutez Varnish aux services Platform.sh :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
--- a/.platform/services.yaml
+++ b/.platform/services.yaml
@@ -2,3 +2,12 @@
database:
type: postgresql:13
disk: 1024
+
+varnish:
+ type: varnish:6.0
+ relationships:
+ application: 'app:http'
+ configuration:
+ vcl: !include
+ type: string
+ path: config.vcl
Utilisez Varnish comme point d'entrée principal dans les routes :
1 2 3 4 5 6
--- a/.platform/routes.yaml
+++ b/.platform/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}/" }
Enfin, créez un fichier config.vcl
pour configurer Varnish :
Activer le support ESI sur Varnish
La prise en charge des ESIs sur Varnish devrait ĂȘtre activĂ©e explicitement pour chaque requĂȘte. Pour le rendre global, Symfony utilise les en-tĂȘtes standard Surrogate-Capability
et Surrogate-Control
pour activer le support ESI :
Purger le cache de Varnish
L'invalidation du cache en production ne devrait probablement jamais ĂȘtre nĂ©cessaire, sauf en cas d'urgence, et peut-ĂȘtre si vous n'ĂȘtes pas dans la branche master
. Si vous avez besoin de souvent purger le cache, cela signifie probablement que la stratĂ©gie de mise en cache doit ĂȘtre modifiĂ©e (en rĂ©duisant le TTL, ou en utilisant une stratĂ©gie de validation au lieu d'une stratĂ©gie d'expiration).
Quoi qu'il en soit, voyons comment configurer Varnish pour l'invalidation du cache :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
--- a/.platform/config.vcl
+++ b/.platform/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 {
Dans la vraie vie, vous restreindriez probablement plutÎt par IPs comme décrit dans la documentation de Varnish.
Purgez quelques URLs maintenant :
1 2
$ curl -X PURGE -H 'x-purge-token: PURGE_NOW' `symfony cloud:env:url --pipe --primary`
$ curl -X PURGE -H 'x-purge-token: PURGE_NOW' `symfony cloud:env:url --pipe --primary`conference_header
Les URLs semblent un peu étranges parce que celles renvoyées par env:url
se terminent déjà par /
.
Aller plus loin
- Cloudflare, la plate-forme cloud globale ;
- Documentation du cache HTTP de Varnish ;
- Spécifications ESI et ressources ESI ;
- ModĂšle de validation de cache HTTP ;
- Cache HTTP dans Platform.sh.