Symfony Messenger et rabbitmq

Symfony 4 apporte un nouveau composant qui va nous permettre de brancher notre application sur un, ou des, brokers de messages. Grâce à ce composant, on va pouvoir accélérer notre application en traitant de façon asynchrone tout ce qui n’est pas strictement nécessaire à l’affichage de nos pages. Ce module maintenu par la Core Team Symfony va à terme remplacer les bundles existants. 

Use case

Un exemple couramment utilisé pour expliquer pourquoi c’est nécessaire d’utiliser ce genre de technique est le suivant:

Un utilisateur passe une commande sur mon site de e-commerce. Il a payé sa commande et attend juste le message de confirmation. Si je traite tout en synchrone je vais devoir réaliser plusieurs traitements avant de lui rendre la main:

  1. Changer le statut de sa commande
  2. Mettre à jour les stocks et invalider les caches qui correspondent
  3. Envoyer un email de confirmation au client
  4. Prévenir le service de préparation de cette nouvelle commande
  5. Envoyer des metrics pour suivre le volume des ventes

Sur les 5 tâches citées, le client n’a besoin que de la première de façon immédiate. Les autres peuvent se faire dans les secondes ou minutes qui suivent. 

Au-delà du simple point de performance il y a aussi le problème de responsabilité unique. Si toute cette logique est dans le contrôleur, vous allez devoir gérer tout un ensemble de règles disparates. Le contrôleur va forcement devenir inmaintenable au bout d’un moment.

AMQP

Pour pouvoir différer les autres tâches, il va falloir que je pousse des messages dans des queues (type rabbitmq, SQS) et que des workers viennent les consommer pour réaliser les traitements.

Sur cet exemple, de Microsoft, on voit bien le principe d’un broker de message. Un ou plusieurs producteurs (senders) envoient des messages dans une queue et ils sont dépilés par un ou plusieurs consommateurs (receivers).

Dans le meilleur des mondes, vous n’avez qu’une technologie de service de queue et peut-être même qu’un seul serveur à interroger. Dans ce cas, c’est assez simple et vous utilisez sûrement déjà un client php pour le faire.

Mais si vous êtes à cheval sur plusieurs technos/ serveurs, c’est vite un casse-tête car il va falloir gérer plusieurs protocoles et serializers.

Symfony Messenger

Le composant Messenger va vous permettre de gérer les problèmes de techno et de serveurs grâce à de la configuration YML. Dans votre implémentation vous n’aurez pas à vous soucier de comment va partir votre message, ni de comment le sérializer. Le composant est compatible avec les brokers de messages AMQP (la plupart).

  • Message : Un objet PHP serializable
  • Bus : Le composant qui va s’occuper de la communication avec le queue manager et d’appliquer les middlewares que l’on aura pu enregistrer (logs, debug,…)
  • Handler : La classe qui va recevoir un message à exécuter. C’est cette classe qui va tenir la logique métier.
  • Receiver : Déserialize le message reçu via le bus et le transmet au bon handler.
  • Sender : Sérialize le message et le transmet au queue manager via le bus.

Nous allons appliquer ce principe pour le cas de l’envoi du mail de confirmation de commande. Il faudra répéter le pattern pour chacun des autres types d’action à effectuer.

Mise en application

Installation du composant

Le composant s’installe à l’aide de composer via la commande :

composer require symfony/messenger

Symfony flex va s’occuper automatiquement d’enregistrer le bundle et créer le fichier de configuration par défaut.  Le composant vient avec son intégration à la Symfony toolbar et au profiler. Il est donc possible de voir en détail les messages dispatchés lors d’un hit.

Configuration du composant

Je vais prendre le cas d’un rabbitmq. Il va falloir veiller à installer et activer l’extension php amqp.

Une fois que c’est fait, nous pouvons éditer le fichier de configuration de messenger pour y ajouter nos transports.

parameters:
messenger.transport.default_serialization_context: {groups: [messenger]}
framework:
messenger:
transports:
# les Data Source Name pour mes différents transports
amqp_mailer: '%env(MESSENGER_TRANSPORT_DSN_MAILER)%'
amqp_default: '%env(MESSENGER_TRANSPORT_DSN)%'
routing:
# En fonction du type de message on peut router le message
# pour ma part j'utilise des interface
'App\Message\Interfaces\Mailer': amqp_mailer
# Tout ce qui n'a pas matché avance passera ici
'*': amqp_default

Dans votre fichier .env à la racine de votre dépôt, il faudra ajouter les différents Data Source Name.

###> symfony/messenger ###
MESSENGER_TRANSPORT_DSN_MAILER=amqp://LOGIN:PASSWORD@HOST:5672/%2f/mails
MESSENGER_TRANSPORT_DSN=amqp://LOGIN:PASSWORD@HOST:5672/%2f/default
###< symfony/messenger ###
view raw .env hosted with ❤ by GitHub

Création de notre message

Le message est un simple objet php.

namespace App\Message\Interfaces;
interface Message
{
}
view raw message hosted with ❤ by GitHub
L’interface par défaut de mes messages (Optionnel mais pratique à l’usage)
<?php
namespace App\Message\Interfaces;
interface Mailer extends Message
{
}
view raw mailer hosted with ❤ by GitHub
L’interface des objets de type mail
<?php
namespace App\Message;
use App\Message\Interfaces\Mailer;
class ConfirmCommandMailer implements Mailer
{
/**
* @var int
*/
protected $commandId;
/**
* ConfirmCommandMailer constructor.
* @param int $commandId
*/
public function __construct(int $commandId)
{
$this->commandId = $commandId;
}
/**
* @return int
*/
public function getCommandId() :int
{
return $this->commandId;
}
}

Dans mon message j’envoie les id des différentes entités. Selon vos besoins vous pouvez directement mettre les entités.

Envoie du message dans le broker

Maintenant que nous avons notre objet de message nous allons pouvoir le pousser.

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\Messenger\MessageBusInterface;
use App\Message\ConfirmCommandMailer;
class CommandController extends Controller
{
/**
* @var MessageBusInterface
*/
private $bus;
/**
* CommandController constructor.
*/
public function __construct(MessageBusInterface $bus)
{
$this->bus = $bus;
}
/**
* @Route("/customer/{idCustomer}/command/{idCommand}/confirm", name="confirm_command", methods={"POST"})
* @param $name
*/
public function index($idCustomer, $idCommand)
{
// check des params
// changement du statut de la commande
//…
// on envoie notre message dans le broker
$this->bus->dispatch(new ConfirmCommandMailer($idCommand));
// rendu du tpl
}
}

Traitement du message

Pour le moment, votre application ne fonctionnera pas car Symfony Messenger refusera de prendre en compte un message dont il ne connait pas le handler.

<?php
namespace App\Message\Handler;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use App\Message\ConfirmCommandMailer;
use \Swift_Mailer;
/**
* Class ConfirmCommandMailerHandler
*/
class ConfirmCommandMailerHandler
{
/**
* @var Swift_Mailer
*/
protected $mailer;
/**
* @var EntityManagerInterface
*/
protected $entityManager;
/**
* ConfirmCommandMailerHandler constructor.
* @param \Swift_Mailer $mailer
* @param EntityManagerInterface $entityManager
*/
public function __construct(Swift_Mailer $mailer, EntityManagerInterface $entityManager)
{
$this->mailer = $mailer;
$this->entityManager = $entityManager;
}
/**
* @param ConfirmCommandMailer $message
*/
public function __invoke(ConfirmCommandMailer $message)
{
$command = $this->entityManager->getRepository(Command::class)->find($message->getCommandId());
// verifier que les données sont cohérantes
$message = (new \Swift_Message('Commande confirmée'))
->setFrom('no-reply@example.com')
->setTo($command->getCustomer()->getEmail())
->setBody(
"Commande #" . $command->getId() . " confirmée"
)
;
$this->mailer->send($message);
}
}

Il faut maintenant enregistrer notre handler dans le container avec le tag ‘messenger.message_handler’.

App\Message\Handler\ConfirmCommandMailerHandler:
tags: ['messenger.message_handler']
view raw service.yml hosted with ❤ by GitHub

À partir de ce moment, Symfony va vous autoriser à dispatcher les messages ConfirmCommandMailer. Pour cet exemple, j’ai utilisé  Swift Mailer mais libre à vous d’utiliser une autre librairie.

Le composant va utiliser la reflection PHP pour détecter le handler qui doit être utilisé pour un message. Il va regarder le type du paramètre passé à la fonction __invoke. 

Lancer le worker

Maintenant que l’on a tout ce qu’il nous faut, il ne reste plus qu’à lancer notre consommateur. Il faudra lancer au minimum autant de workers que de channels.

bin/console messenger:consume-messages amqp_mailer

Il y a pas mal d’options disponibles pour limiter la durée de vie du daemon, la mémoire allouée, le temps de pause entre chaque message traité… Pour ma part je lance toujours au moins deux consommateurs pour un type de message et je fais en sorte qu’ils se tuent automatiquement tous les n messages traités.

Voici une démonstration sous forme de GIF de ce que l’on vient de faire. Pour simplifier la démonstration, j’ai fait une seconde commande Symfony qui pousse notre message dans la queue.

En production

En production il faut automatiser le lancement du daemon et le relancer en cas de crash. Pour ce faire vous pouvez utiliser supervisor avec la config suivante :

[program:amqp_mailer]
command=php /symfony/bin/console messenger:consume-messages amqp_mailer
startsecs = 0
stdout_logfile=/tmp/supervisord-amqp.log
stdout_logfile_maxbytes=10MB
view raw gistfile1.txt hosted with ❤ by GitHub

Généralement je fais tourner les workers dans des conteners docker dans un cluster swarm. De cette manière je peux gérer le nombre de consommateurs par type de message à la volée. Voici un exemple de Docker file qui peut faire tourner un worker symfony messenger. Je l’ai fait pour un projet perso, il n’est donc pas parfaitement optimisé pour de la vraie prod. 

FROM php:7.2
# Composer
RUN php -r "copy('https://getcomposer.org/installer&#39;, 'composer-setup.php');" \
&& php -r "if (hash_file('SHA384', 'composer-setup.php') === '544e09ee996cdf60ece3804abc52599c22b1f40f4323403c44d44fdfdd586475ca9813a858088ffbc1f233e9b180f061') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;" \
&& php composer-setup.php –install-dir=/bin –filename=composer \
&& php -r "unlink('composer-setup.php');"
# amqp
RUN apt-get update && apt-get install -y librabbitmq-dev libssh-dev \
&& docker-php-ext-install opcache bcmath sockets \
&& pecl install amqp \
&& docker-php-ext-enable amqp
# core tools
RUN apt-get install -y git-core
# php exts
RUN docker-php-ext-install pdo pdo_mysql zip mbstring
RUN mkdir /app
WORKDIR /app
env QUEUE_NAME amqp_default
ENTRYPOINT ["sh", "-c", "/app/bin/console messenger:consume-messages ${QUEUE_NAME} –limit=100 –time-limit=900 -n"]
Exemple de docker file simple pour un worker Symfony messenger

Et voici comment l’utiliser dans le cadre d’un docker compose :

version: '3'
services:
worker_mailer:
image: ma-super-image-de-worker:latest
environment:
– QUEUE_NAME=amqp_mailer
volumes:
– "/PATH/VERS/MON/APP/SF:/app:rw"
deploy:
mode: replicated
replicas: 2

Vous pouvez maintenant faire un docker-compose up ou le lancer sur un cluster swarm via la commande docker stack deploy.

Limitation

Pour le moment il n’y a pas de solution « out of the box » pour gérer le re-jeux des messages en cas d’erreur. C’est à vous de catcher les erreurs et soit de les pousser dans une autre queue, d’écrire un log, une métrique, ou de les stocker en base pour les identifier au besoin. C’est une lacune assez importante de la librairie qui devrait être corrigée.

Les problèmes possibles

Que veux dire l’erreur : Attempted to load class « AMQPConnection » from the global namespace.
Did you forget a « use » statement?

Ce message vous signale que vous n’avez pas l’extension amqp d’activée sur votre machine. Il faut installer et activer l’extension php-amqp.

Comment résoudre l’erreur: [ErrorException] Declaration of SymfonyFlexParallelDownloader::getRemoteContents($originUrl, $fileUrl, $context) should be compatible with ComposerUtilRemoteFilesystem::getRemoteContents($originUrl, $fileUrl, $context, ?array & $responseHeaders = NULL)

Il faut exécuter composer update –no-plugins pour mettre Symfony flex à jour.

Comment corriger l’erreur : [SymfonyComponentMessengerExceptionNoHandlerForMessageException]
No handler for message « AppMessageConfirmCommandMailer ».

Il faut veiller à deux points pour trouver la source de cette erreur. Premièrement que vous avez bel et bien créé un handler avec une méthode __invoke qui prend un objet de type AppMessageConfirmCommandMailer en premier et unique paramètre. Et dans un second temps que vous avez bien ajouté votre handler dans votre fichier services.yml avec le tag « messenger.message_handler »

Cocktailand – Gérer ses images sous symfony

La page d’accueil de cocktailand affiche en moyenne 35 images. Dans sa première version, je n’avais pas pris la peine de soigner la gestion des images. J’avais pour objectif de sortir le produit minimum viable, je me suis donc attardé sur les fonctionnalités principales:

  • L’ajout des recettes de cocktail
  • L’affichage des recettes de cocktails
  • La catégorisation
  • Le moteur de recherche

Cette méthodologie permet de sortir rapidement un site de base et de l’enrichir de façon successive.

Le fait d’avoir mis en place varnish et les esis faisait que la page s’affichait très rapidement mais les images mettaient du temps à s’afficher.

Dans cet article je vais faire le point sur les différentes techniques à mettre en place pour réduire au maximum le temps de chargement des images sur votre site WEB.

Optimiser les images

La première chose à faire est de redimensionner votre image à une taille raisonnable.

Il est rare d’avoir besoin d’une image avec une résolution plus grande que 1024×768 pixels.

La seconde étape est d’optimiser vos images à l’aide d’outils comme imageoptimcompressor.io ou tout autre outil du genre.

Vous pouvez régler l’outil pour avoir ou non de la perte de qualité au profit d’un gain de taille.

Il n’est pas rare de gagner 70% de taille sur une image.

Sur 35 images de 1Mo, on gagnerait 25Mo.

Cache http

Maintenant que nous avons optimisé la taille de nos images au maximum et que le visiteur les a téléchargées, nous allons dire à son navigateur de les conserver pour la prochaine fois.

Côté varnish j’ai pour habitude d’avoir cette règle qui surcharge le retour de nginx pour ajouter une durée de cache de 24h.

sub vcl_backend_response {
if (bereq.url ~ "\.(jpe?g|png|gif|pdf|tiff?|css|js|ttf|woff2?|otf|eot|svg)$") {
set beresp.ttl = std.duration(beresp.http.age+"s",0s) + 24h;
}
}
view raw vcl image hosted with ❤ by GitHub

Cette règle est géniale si chaque fichier est immuable et a une url unique. Cela implique que lors de votre build vous ajoutiez un hash unique dans le nom de vos JS et CSS. Si vous avez des fichiers qui changent au cours de la journée, cette solution n’est pas faite pour vous.

Http 2

Http 2 permet d’optimiser très largement le temps de téléchargement des assets, car il conserve la même connexion au serveur. HTTP 1 pour sa part initialise une connexion par fichier à télécharger. Cette démonstration http2 vs http1 va vous permettre de vous rendre compte du gain par rapport à http 1.

Le site va télécharger une mosaïque qui est composée de 100 petites images en http 1 puis en http 2. Le gain est environ de 60% de temps de chargement sur mon macbook pro avec une connexion fibre.

Si vous ne savez pas comment faire, ou que vous n’êtes pas techniquement sûr de comment gérer http2 sur votre serveur, vous pouvez utiliser cloudflare. Par défaut, cloudflare va gérer les connexions http 2 tout en continuant de faire des appels http 1 sur votre serveur. Il va s’occuper de multiplexer les requêtes et tout se passera de manière transparente pour vous. Ce n’est pas parfait, mais c’est mieux que rien…

Pré-calculer les différentes tailles

Pour la partie serveur, nous avons maintenant une stack idéale pour afficher rapidement nos images.

Il ne reste que quelques détails à régler. Dans la première partie je vous ai dit de redimensionner vos images au format maximum que vous pourrez un jour utiliser.

Mais pour avoir de bonnes performances, il va falloir aller plus loin.

C’est le moment de pré-calculer l’ensemble des tailles d’images que vous allez utiliser. L’idée est de ne faire télécharger que le strict nécessaire à l’utilisateur. Si nous avons besoin d’une image de 64×64 pixels il est préférable d’envoyer une image qui fait déjà la bonne taille plutôt que de télécharger une grosse image et de laisser le navigateur la redimensionner. Vous allez gagner en bande passante mais aussi en CPU.

Par exemple, sur cocktailand, une image peut actuellement être utilisée avec les tailles suivantes:

  • 64×64
  • 240×180
  • 270×200
  • 400×400

Pour un peu plus de 1000 images sur le site, le travail n’est pas faisable à la main. D’autant plus que je ne m’interdis pas d’ajouter de nouvelles tailles si je modifie le design ou les supports.

Sur Symfony, il existe un bundle liip/imagine-bundle qui permet cette automatisation. Le bundle va générer à la volée les miniatures lors de la première demande puis les stocker sur le serveur pour les prochaines demandes.

Une fois le bundle installé dans votre projet, vous n’avez qu’à enregistrer vos formats dans la configuration et à utiliser le filtre twig.

liip_imagine:
resolvers:
default:
web_path:
web_root: "%kernel.project_dir%/public"
# en écrivant ici on garde le cache entre chaque release
cache_prefix: "images/cache"
loaders:
default:
filesystem:
data_root: "%kernel.project_dir%/public/"
cache: default
data_loader: default
# valid drivers options include "gd" or "gmagick" or "imagick"
driver: "gd"
# define your filter sets under this option
filter_sets:
# an example thumbnail transformation definition
# https://symfony.com/doc/current/bundles/LiipImagineBundle/basic-usage.html#create-thumbnails
thumbnail:
jpeg_quality: 75
png_compression_level: 8
filters:
auto_rotate: ~
strip: ~
thumbnail:
size: [64, 64]
mode: outbound
allow_upscale: true
large:
jpeg_quality : 90
png_compression_level: 6
filters:
auto_rotate: ~
strip: ~
thumbnail:
size: [400, 400]
mode: outbound
allow_upscale: true
home_header_small:
jpeg_quality : 80
png_compression_level: 7
filters:
auto_rotate: ~
strip: ~
thumbnail:
size: [300, 300]
mode: outbound
allow_upscale: true
home_header_big:
jpeg_quality : 80
png_compression_level: 7
filters:
auto_rotate: ~
strip: ~
thumbnail:
size: [900, 300]
mode: outbound
allow_upscale: true
page_header:
jpeg_quality : 80
png_compression_level: 7
filters:
auto_rotate: ~
strip: ~
thumbnail:
size: [200, 200]
mode: outbound
allow_upscale: true
article_media:
jpeg_quality : 80
png_compression_level: 7
filters:
auto_rotate: ~
strip: ~
thumbnail:
size: [200, 220]
mode: outbound
allow_upscale: true
pub_media:
jpeg_quality : 80
png_compression_level: 7
filters:
auto_rotate: ~
strip: ~
thumbnail:
size: [250, 250]
mode: outbound
allow_upscale: true
avatar_media:
jpeg_quality : 80
png_compression_level: 7
filters:
auto_rotate: ~
strip: ~
thumbnail:
size: [200, 250]
mode: outbound
allow_upscale: true
medium_desktop:
jpeg_quality : 80
png_compression_level: 6
filters:
auto_rotate: ~
strip: ~
thumbnail:
size: [240, 180]
mode: outbound
allow_upscale: true
medium_tablet:
jpeg_quality : 85
png_compression_level: 6
filters:
auto_rotate: ~
strip: ~
thumbnail:
size: [270, 200]
mode: outbound
allow_upscale: true
medium_mobile:
jpeg_quality : 90
png_compression_level: 5
filters:
auto_rotate: ~
strip: ~
thumbnail:
size: [400, 400]
mode: outbound
allow_upscale: true
tiny:
jpeg_quality: 60
png_compression_level: 8
filters:
auto_rotate: ~
strip: ~
thumbnail:
size: [35, 35]
mode: outbound
allow_upscale: true
view raw liip_imagine hosted with ❤ by GitHub

On pourra remarquer que plus l’image est petite, plus j’ai fait baisser la qualité.

Résultat pour une image déclinée dans plusieurs des tailles:

Respectivement les tailles sont de 2Ko, 12Ko et 39Ko. Il y a donc un facteur 20 entre la miniature et le plus grand format utilisé sur le site.

Lazy loading/ image set

Le dernier point permet un énorme gain de performance, mais il peut aussi impacter votre SEO si vous le faites mal. L’idée est de ne faire télécharger au visiteur que les images qui seront immédiatement visible à l’écran. Si l’image n’est pas visible à l’écran, elle ne sera pas téléchargée au chargement, mais au scroll quand elle entrera dans le viewport. Le problème réside dans le fait de mettre en place cette technique et en laissant Google (ou autre) voir les images. Le paquet npm vanilla-lazyload est vraiment sympa car il répond aux deux points précédents et en plus il permet de mettre en place des « images responsives » via les srcset.

Une fois le paquet installé vous n’avez quasiment rien à faire pour initialiser la librairie.

const LazyLoad = require('vanilla-lazyload');
let lazyLoad = new LazyLoad();
view raw LazyLoad hosted with ❤ by GitHub

Une fois la librairie mise en place et instanciée côté javascript, il ne reste plus qu’à utiliser les data attributes des images pour l’utiliser. Voici un exemple d’utilisation avec imagine.

<img alt="Recette simple du Mojito alsacien"
data-src="{{ asset(cocktail.image)|imagine_filter('medium_desktop') }}"
data-srcset="{{ asset(cocktail.image)|imagine_filter('medium_mobile') }} 200w, {{ asset(cocktail.image)|imagine_filter('medium_tablet') }} 400w"
sizes="(min-width: 20em) 35vw, 100vw">
view raw data-srcset hosted with ❤ by GitHub

Conclusion

Une fois l’ensemble des techniques mises en place, le chargement de la home ne télécharge « que » 1.2 Mo d’images, publicités incluses. Voici des données issues de webPageTest concernant la home.

Le premier chargement de la page pour un visiteur:

Le second chargement de la page pour un visiteur:

On remarque que le nombre d’assets téléchargés lors du second chargement est plus faible. Si on regarde en détails la liste des fichiers téléchargés, on constate, avec chagrin, que plus rien ne provient de cocktailand. Il ne reste en effet que le tracking et la publicité.

Cocktailand – Ajouter du cache HTTP dans mon symfony

C’est quoi un ESI ?

Les ESI ou Edge Side Includes sont un balisage supporté par Varnish qui permet de gérer des temps de cache différents pour des blocs de la même page.

Dans le cadre de Cocktailand, certains blocs sont actualisés régulièrement comme le « Cocktail du jour » mais d’autres ne changent quasiment jamais comme la liste des catégories.

Voici donc le découpage que j’ai fait sur la page principale. Pour la barre de menu, c’est bien évidemment le contenu du méga menu que j’ai voulu mettre en évidence.

Il est donc intéressant de ne pas avoir à invalider toute la page lorsque le cocktail du jour est changé. Le second avantage en termes de performance est que les blocs peuvent être utilisés sur différentes pages. Cela signifie qu’un ESI présent sur toutes les pages du site ne sera généré qu’une seule fois. Lors des autres appels, Varnish utilisera son cache.

Configuration de varnish

Pour Cocktailand, la configuration de varnish est assez simple car je ne fais pas de purge et parce qu’il n’y a pas d’espace connecté sur le site.

Voici la configuration que j’ai:

vcl 4.0;
import std;
backend default {
// le hostname du nginx dans ma stack
.host = "front";
.port = "80";
}
sub vcl_recv {
set req.http.Surrogate-Capability = "abc=ESI/1.0";
unset req.http.Cookie;
}
//Ensuite, ce block est appelé après la réception des headers de réponse.
//Nous supprimons le header et activons les ESI
sub vcl_backend_response {
if (beresp.http.Surrogate-Control ~ "ESI/1.0") {
unset beresp.http.Surrogate-Control;
set beresp.do_esi = true;
}
if (bereq.url ~ "\.(jpe?g|png|gif|pdf|tiff?|css|js|ttf|woff2?|otf|eot|svg)$") {
set beresp.ttl = std.duration(beresp.http.age+"s",0s) + 24h;
}
}
sub vcl_deliver {
if (obj.hits > 0) {
set resp.http.X-Cache = "HIT";
} else {
set resp.http.X-Cache = "MISS";
}
}
view raw varnish vcl hosted with ❤ by GitHub

La mise en place dans Symfony 4

ESI

Le support des ESI dans Symfony est intégré nativement dans le framework.

Dans le fichier config/packages/framework.yaml il faut activer les ESIs.

framework:
esi: { enabled: true }
fragments: { path: /_fragment }
view raw framework.yaml hosted with ❤ by GitHub

Toutes vos routes qui servent des ESI devront commencer par _fragment.

Dans vos vues twig, vous avez des helpers à disposition pour poser vos tags.

{{ render_esi(url('popular_cocktails')) }}
view raw render_esi hosted with ❤ by GitHub

Cache http

En utilisant le package sensio/framework-extra-bundle on peut gérer le cache sur les controller avec des annotations.

/**
* @Route("/cocktail/recette/{name}-{id}", name="cocktail_detail", requirements={"id"="\d+", "name"="[0-9a-z-]+"})
* @Cache(public=true, maxage=86400, mustRevalidate=false, maxStale=86400)
**/
public function index($name, $id)
{
// …
}
view raw @Cache hosted with ❤ by GitHub

Temps de réponse

Une fois les ESIs et le cache HTTP mis en place, on peut analyser les performances avec Webpagetest.

L’outil va nous donner des informations sur le temps de réponse de l’application et surtout des indications sur ce qu’il faudrait améliorer.

Par exemple, sur ce test, il me dit que je peux potentiellement améliorer la gestion des fonts et des images.

// @todo activer gzip sur les fonts

Ce que l’on peut remarquer, c’est que le site commence à envoyer le HTML après 231ms. Dans ce temps il y a en moyenne 40ms de DNS Lookup, 30ms de connexion et 80ms de négociation SSL. Malheureusement sur cette partie je n’ai pas la main, c’est donc l’overhead de base pour toute page du site. Mais comme j’utilise HTTP2 je vais mutualiser toute cette partie pour les images assets servies sur le même domaine.

Les nouvelles problématiques

Tout mettre en cache, c’est bien pour les performances, mais malheureusement certaines informations ont besoin de « temps réel ».

Quand un visiteur ajoute une note sur une recette, il est nécessaire que cette note mise à jour si le visiteur rafraîchit la page.

Il y a plusieurs solutions:

  • Faire des bans de cache lors de l’ajout d’une note
  • Afficher les données du cache et rafraîchir les données en AJAX

Bien évidemment, la première solution est celle qui devrait être implémentée. Maintenant, c’est pas mal de code et de configuration sur varnish. C’est d’autant plus compliqué que, dans sa version gratuite, Varnish ne permet pas de faire un ban sur plusieurs instances.

Faire les bans impliquerait de maintenir une liste des varnishs qui tournent (si jamais je fais scaller cette brique) et de faire n appels curl pour faire un ban partout.

Pour le moment, j’ai fait le choix de la requête AJAX non cachée. Si jamais le site gagne en popularité, il faudra retravailler sur ce point.

Conclusion

Sur un blog ou un site dans lequel le visiteur ne peut quasiment pas interagir avec vos données, c’est très simple de mettre en place du cache varnish et le gain de performance est énorme. Maintenant, si vous avez des besoins plus complexes (site transactionnel, forum,…) vous allez devoir mettre en place une mécanique d’invalidation de cache.

Cocktailand – Monitorer les performances d’un site web

Les outils

Monitorer les performances WEB d’un site web est vraiment indispensable, mais ce n’est pas simple de le faire gratuitement. Il existe des outils en ligne comme SpeedCurve mais c’est relativement cher.

L’idée ici est d’avoir un suivi, certes plus simple, mais de façon gratuite. Il se trouve que j’ai déjà une stack graphite/grafana qui tourne en production. Si ce n’est pas votre cas et que vous avez un serveur avec docker qui tourne dessus vous pouvez toujours démarrer un container avec l’image docker-grafana-graphite pour tester la suite de cet article. Une fois que votre graphite est en ligne on va pouvoir commencer à pousser des metrics dedans. Un outil est très connu pour obtenir des metrics de performance sur un site web. Webpagetest, dont j’ai déjà parlé dans un billet précédent sur la mise en place de varnish et des ESIs. Il se trouve que webpagetest fournie une api.

Pour obtenir une clef d’api il faut en faire la demande sur le site de WebPageTest.

Vous recevrez votre clef par email automatiquement sous quelques minutes.

La mise en place

Il ne reste plus qu’a faire le lien entre les retours de l’API de webpagetest et notre graphite. J’ai choisi de faire un petit script en node js, mais vous pouvez le faire avec ce que vous voulez.

Installer les dépendances

npm install statsd-client webpagetest –save

Le script

Très claireiment c’est pas un script optimisé ni prévu pour monitorer plusieurs pages d’un site. Maintenant si vous avez besoin d’étendre son scope ce ne sera pas trop difficile.

Il y a quelques placeholder dans le script à remplacer par vos valeurs:

  • NOM_DU_SITE_OU_DE_LA_PAGE: Le nom de votre site en snake case (ex: cocktailand_fr)
  • VOTRE_URL: L’url de la page à monitorer (ex https://cocktailand.fr)
  • VOTRE_HOST_GRAPHITE: L’ip ou le hostname de votre graphite
  • VOTRE_API_KEY: La clef d’api que WebPageTest vous a donnée
const WebPageTest = require('webpagetest');
const http = require('http');
const SDC = require('statsd-client'),
sdc = new SDC({
prefix: '<NOM_DU_SITE_OU_DE_LA_PAGE>.webpagetest',
host: '<VOTRE_HOST_GRAPHITE>',
port: 8125
});
const wpt = new WebPageTest('www.webpagetest.org', '<VOTRE_API_KEY>');
wpt.runTest('<VOTRE_URL>', {location: 'ec2-eu-west-1:Chrome'}, (err, data) => {
if (err) {
console.error(err);
return;
}
let jsonUrl = data.data.jsonUrl;
let iterationNumber = 0;
let stop = setInterval(function () {
// 20 mins max
if (iterationNumber > 600) {
clearInterval(stop);
return;
}
iterationNumber ++;
http.get(jsonUrl, (resp) => {
let rawData = '';
// A chunk of data has been recieved.
resp.on('data', (chunk) => {
rawData += chunk;
});
// The whole response has been received. Print out the result.
resp.on('end', () => {
let response = JSON.parse(rawData);
if (response.statusCode === 200) {
clearInterval(stop);
['firstView', 'repeatView'].forEach((key) => {
sdc.gauge(key + '.first_paint', response.data.median[key].firstPaint);
sdc.gauge(key + '.fully_loaded', response.data.median[key].fullyLoaded);
sdc.gauge(key + '.bytes_out', response.data.median[key].bytesOut);
sdc.gauge(key + '.image_total', response.data.median[key].image_total);
sdc.gauge(key + '.load_time', response.data.median[key].loadTime);
sdc.gauge(key + '.speed_index', response.data.median[key].SpeedIndex);
sdc.gauge(key + '.cached', response.data.median[key].cached);
sdc.gauge(key + '.dom_interactive', response.data.median[key].domInteractive);
sdc.gauge(key + '.nb_domains', Object.keys(response.data.median[key].domains).length);
sdc.gauge(key + '.resources.html.size', response.data.median[key].breakdown.html.bytes);
sdc.gauge(key + '.resources.html.nb', response.data.median[key].breakdown.html.requests);
sdc.gauge(key + '.resources.css.size', response.data.median[key].breakdown.css.bytes);
sdc.gauge(key + '.resources.css.nb', response.data.median[key].breakdown.css.requests);
sdc.gauge(key + '.resources.js.size', response.data.median[key].breakdown.js.bytes);
sdc.gauge(key + '.resources.js.nb', response.data.median[key].breakdown.js.requests);
sdc.gauge(key + '.resources.image.size', response.data.median[key].breakdown.image.bytes);
sdc.gauge(key + '.resources.image.nb', response.data.median[key].breakdown.image.requests);
});
}
});
}).on("error", (err) => {
console.log("Error: " + err.message);
});
}, 2000);
});

Cronner le script

Je fait tourner le script toutes les heures.

0 * * * * node webPageTest.js

De cette manière je peux analyser le comportement du site sur une journée complète et éliminer les fausses alertes.

Il est en effet possible d’avoir des résultats qui varient en fonctions de plusieurs facteurs extérieur:

  • La saturation du réseau de webPageTest
  • Un agent de webpagetest qui est plus lent qu’un autre
  • Des publicités mal optimisées de la part d’un partenaire

Il ne faut pas s’inquiéter si un résultat est moins bon qu’un autre.

Le but ici est d’analyser une tendance.

Faire un beau dashboard

Maintenant que vous avez toutes vos informations dans votre graphite on peut commencer à grapher ce qui nous intéresse.

Voici par exemple mon dashboard:

Pour ma part je surveille principalement les temps de réponse et le cache avec mon dashboard, mais en fonction des objectifs que l’on se donne on peut axer les graphiques sur d’autres KPIs.

Conclusion

J’ai un suivi des performances. Si grafana est à jour (ce qui n’est pas mon cas) il est même possible d’envoyer des mails d’alertes quand on dépasse une limite fixée.

Après plusieurs semaines d’exploitation je me rends compte que le site en lui-même est stable et avec des performances prévisibles, mais que les grandes inconnues sont les publicités. Une mauvaise pub sur le site peut anéantir tout le travail fait en amont sur les performances.

Performance site wordpress

Booster votre wordpress

Augmenter les performance pour diminuer le temps de réponse de son site devrait être la préoccupation de tout webmaster, afin d’améliorer au maximum l’expérience utilisateur mais aussi pour être mieux référencé sur Google. Nous allons voir dans cet article comment optimiser WordPress pour obtenir un temps de réponse minimal.

Logo wordpress

Il existe une kyrielle de plugins WordPress qui permettent de réduire le temps de réponse de WordPress. Un des plus connus est w3 Total Cache car il possède énormément de fonctionnalités.

Mais après l’avoir installé, je me suis rendu compte que WordPress consommait quasi 100% de tous les coeurs de mon serveur pour afficher une page. C’est un problème qui est assez fréquent avec ce plugin. C’est pourquoi j’ai cherché des solutions alternatives.

Charge CPU avant optimisation

Coté WordPress

WP Super Cache

WP Super Cache est développé par Donncha O Caoimh, auteur de la branche Wp MU.

Le plugin va générer des pages HTML statiques à partir des pages du sites. Les pages sont mises à jour régulièrement (réglable). Ce procédé permet de servir des pages sans avoir à la générer.

La seconde fonctionnalité intéressante du plugin est le pré-chargement des pages pour forcer la mise en cache. Il est possible par exemple de pré-générer ou mettre à jour les caches toutes les 30 minutes. Cette fonctionnalité est très intéressante car même le premier visiteur profitera du cache, contrairement à la plupart des plugins de cache ou c’est le premier visiteur qui va le générer.

WP Minify

Wp Minify est un plugin qui permet la compression et la concaténation des fichiers JS et CSS. Selon la qualité de vos fichiers (en terme de code) il n’est peut être pas possible de les compresser. Dans ce cas la il faut aller dans option avancé et désactiver la minification.

La concaténation des /assets, même non minifiés, permet de diminuer le nombre de requêtes et donc le temps de réponse.

WP Optimize

Wp Optimize est un plugin qui permet de nettoyer et optimiser la base de données de wordpress. Il va aussi défragmenter les tables. Il suffit de le lancer de temps en temps.

jQuery lazy load plugin

jQuery lazy load plugin est un plugin qui pemet de charger les images des pages à la demande. le plugin est basé sur le plugin JQuery Lazy Load.

Ceci permet de diminuer grandement le temps de chargement initial d’une page. Et c’est quand l’utilisateur affichera l’image qu’elle sera chargée.

Sur une page d’accueil où il y a une dizaine d’articles (ou plus) cela peut faire gagner énormément de connexion HTTP.

Conseils de bon sens

  • Eviter d’installer trop de plugins
  • Limiter les JS et CSS externe (hors CDN)
  • Utiliser des images compressées et découpées à la bonne taille

Côté Serveur

Si on veux que le site soit aussi performant que possible, il va falloir, si possible, aller configurer un peu le serveur. Pour cette partie vous allez avoir besoin des accès root sur le serveur.

XCache

XCache est un accélérateur de code PHP. Il va mettre en cache le code PHP compilé afin de ne pas avoir à le re-compiler une prochaine fois. Contrairement à PHP APC, XCache est stable et entièrement compatible avec PHP 5.4+.

Voici un tutoriel qui date un peu, mais qui est toujours d’actualité, qui explique comment faire l’installation de XCache sur un serveur debian : Installer XCache sur Debian.

Configurer Apache

Apache est très configurable, le tutoriel optimiser la configuration d’Apache est assez complet à ce sujet.

MySQL

Pour une installation basique de wordpress le SGBD est MySQL.

Il existe des scripts comme MySQLTuner qui peuvent aider à configurer correctement le serveur MySQL.

Installer un Firewall

Un firewall, par exemple Shorewall permet de faire le tri dans les demandes et de ne pas envoyer à Apache les requêtes de type DDOS. Contrairement au mod_evasive la requête va être ignorée au niveau de la carte réseau. On va donc limiter au maximum son impact sur le serveur en termes de ressources utilisées.

Résultat

Voici le résultat de Yslow sur la page d’accueil du blog une fois toutes les optimisations mises en place.

Et voici le temps de chargement de la vue réseau de Firebug pour le premier et le second chargement de la page d’accueil:

Premier chargement

Second chargement