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.