Blog des Gens Compliqués

Cache Busting pour Polymer 2.0 et Apache

27/03/2018 17:30:00+02:00|Par DkVZ
Informatique & Web
13 minutes de lecture (facile)

Table des matières

Intro

De tous les artifices Javascript communément utilisés comme armes dans la guerre de qui-c'est-qu'a-le-plus-d'étoiles-sur-ses-projets-sur-github entre Facebook et Google et que les gens se mettent à utiliser sans trop réfléchir, Polymer est mon projet favori.

Techniquement pas un framework mais une librairie (ou un "helper"), cette technologie est loin (très loin) d'être sans failles mais ce n'est pas difficile de passer au dessus de mon opinion abyssale de Réact.

Cette discussion sort (heureusement vous dites-vous n'est-il-pas) du cadre de cet article.

Ce qui m'intéresse moi c'est de pouvoir déployer de nouvelles versions de mon application et ne pas avoir des choses étranges qui se produisent parce qu'une partie des fichiers est en cache et l'autre pas. D'expérience sur les applications Polymer ce genre de soucis produit des pages blanches ou des pages qui cessent de réagir aux actions utilisateur, ce qui est plutôt embêtant.

Seul moyen de le résoudre, presser CTRL+F5 sur Chrome, ou faire une manip infernale depuis la console développeur sur Firefox (oui CTRL+F5 n'a pas l'effet force refresh sous Firefox, allez comprendre pourquoi...) ou encore, bien entendu, vider son cache.

Je ne suis pas certain que cet article soit super clair (oui, genre pire que les autres) mais si ça peut aider quelqu'un... Moi ça pourra peut-être m'auto-aider quand j'aurai tout oublié dans 1 mois.

Sékoi le cache beusting?

Pour être honnête je dois vous avouez que ma connaissance des mécanismes précis de comment-je-met-quoi-en-cache des navigateurs est empirique.

Pour observer ce qu'il se passe au niveau réseau, cache, et requêtes, il s'agit au préalable d'afficher le VOLET DEVELOPPEUR avec Ctrl+Shift+I (pour Chrome et Firefox en tous cas). L'un des onglets du volet développement (je vous conseille de détacher en tant que fenêtre propre) s'appelle Network/Réseau. Il ne se remplit que si on réalise une (nouvelle) requête.

Il y a une différence entre demander directement une ressource, du style Donne-moi slip-propre.jpg ou Donne-moi index.html, et demander des ressources qui sont mentionnées par la ressource que l'on vient de demander. Euh... Ouais je me comprends.

Demander directement un fichier html va toujours générer une requête. Même si vous avez cette page en cache et qu'elle est statique, votre navigateur va envoyer une requête au serveur pour récupérer la page.

Si vous avez la page en cache, la requête va comprendre quelques infos à destination du serveur, comme l'eTag et la date de modification de la ressource que vous avez en cache. Si vous vous demandez "mais VEZDE c'est quoi un eTag?" on s'en essore okay?

Ce qui se passe ensuite est à discrétion du serveur. Il se peut qu'il renvoie entièrement la page avec une réponse HTTP 200 classique, ou qu'il réponde par une 304 qui est une réponse la plus limitée possible (sans le contenu donc) qui indique que cette ressource n'a pas été modifiée par rapport à la version que vous avez en cache.

Réponse 304 avec en-têtes Last-Modified et ETag

Tout ça c'est cool, mais cette page index.html fait elle-même référence à d'autres ressources. Elle charge un fichier CSS séparé et un fichier Javascript externe, ainsi qu'une image. Il se passe quoi de ce côté là?

Ha! C'est là toute la source du problème auquel le cache busting répond.

Par défaut, ces fichiers mentionnées par la page ".html" (ou ".php" ou tout ce que vous voulez) - images, CSS, JS, etc. - que nous appellerons désormais des fichiers statiques - sont servis par le serveur sans date d'expiration (en fait tout est servi par le navigateur sans date d'expiration par défaut) et si le navigateur a une version de ces fichiers statiques en cache, Il ne fait pas de nouvelle requête et utilise les fichiers qu'il a dans son cache.

Et ce, pour toujours ou jusqu'à ce que l'utilisateur purge son cache navigateur ou décide de passer sur Opera parce que 8 GB de RAM ça devient limite pour Firefox et Chrome.

C'est très embêtant pour Polymer qui charge toutes sortes de fichiers externes via des imports HTML, y compris vos composants que vous modifiez régulièrement à chaque itération de votre développement.

Le fichier de base de votre appli Polymer (normalement index.html) devrait se mettre à jour normalement, mais tous les autres fichiers de votre app (tous les composants) ne vont pas se mettre à jour si le client a déjà une version en cache. Ce client ne vas JAMAIS mettre ces fichiers à jour. Et jamais c'est pas souvent, quoi.

Solution foireuse

Ma solution initiale à ce problème a été d'ajouter une politique de cache pour tous les fichiers au niveau du serveur. Le serveur envoie alors les fichiers avec des en-têtes supplémentaires qui indiquent combien de temps les navigateurs devraient conserver ce contenu en cache. Il s'agit littéralement d'une date de péremption.

J'avais écrit un vieil article pour Nginx qui reprend brièvement ces en-têtes de cache, et explique la solution foireuse que j'avais appliquée à l'époque: Contrôle du cache Nginx.

Cette solution n'aide que très relativement pour tout ce qui est fichiers JS, CSS et HTML. Elle aide tous les X temps, en gros. Vos utilisateurs peuvent à nouveau utiliser le site normalement après une mise-à-jour de votre part quand les fichiers statiques sont périmés.

Si comme moi, les gens visitent votre site une fois tous les 2 ans, cette solution fonctionne. Sinon, elle fonctionne moins.

Le seul avantage de cette solution est d'appliquer une règle ferme de rétention de fichiers comme les images. En général on ne modifie que très rarement les images d'un site ou article, mais si vous le faites, les clients qui ont déjà ces images en cache ne vont jamais tenter de les recharger. C'est embêtant si vous avez accidentellement publié une photo compromettante pour votre future carrière politique.

Heureusement retirer les photos compromettantes c'est très facile sur Facebook (normalement)

La méthode "modifier le nom de la ressource"

Le plan ici consiste à changer le nom de tous vos fichiers statiques modifiés à chaque mise-à-jour de votre application web.

Comme expliqué précédemment, le navigateur va toujours vérifier si index.html a été modifié. Ensuite, pour les ressources déclarées sur la page, il regarde en son cache et utilise la version en cache si possible.

Comme on utilise d'autres noms de fichiers pour nos ressources il y a peu de chances (sinon aucune) que le navigateur les ait en cache. Donc il les demande au serveur. Victoire.

Cette méthode n'est applicable que si:

  • Vous utilisez un outil pour "assembler" la partie front de votre appli web (Webpack, Gulp, Grunt, ...) et êtes en mesure de configurer cet outil pour ajouter un hash aux noms de fichiers .
  • Vous servez vos pages en tant que templates depuis une solution serveur type PHP, Java, Python, NodeJS, etc ;

Le premier cas pouvait s'appliquer à Polymer 1.0 et son starter kit qui utilisait Gulp (à condition d'avoir une bonne expertise de Gulp), mais ne s'applique pas à Polymer 2.0 + la Polymer CLI parce que l'outil de build ne fournit aucune option de cache-busting (ceci dit, on en a pas besoin comme nous le verrons plus loinà.

Dans le second cas vous devez gérer votre soupe vous mêmes et vous arranger pour que les noms de fichier des ressources statiques changent à chaque nouvelle version.

Il existe un pseudo "hack" pour arriver facilement à cette fin que je présente juste après maintenant.

Modifier le Query String

Les navigateurs considèrent que ces deux URL doivent être mises en cache séparément, et donc correspondent à une ressource différente:

Dans le second cas on a juste ajouté un query string "v=1.0".

La ressource est mise en cache avec son query string au niveau de l'identification, donc même si dans ce cas-ci les deux URL correspondent exactement au même fichier sur mon serveur, votre navigateur va mettre cette ressource en cache deux fois.

J'ai ajouté un fichier avec v=2.0 sur cette page où j'avais déjà chargé les deux autres URI. Ces trois fichiers sont identiques pourtant ils sont considérés comme différents par le navigateurs

Cette méthode de cache busting est très simple à mettre en place si vous servez vos pages depuis des templates d'un langage serveur (comme PHP par ex.).

Il suffit d'avoir une variable quelque part nommée "version" (vous pouvez avoir des versions pour plusieurs types de ressources), ne pas oublier de l'incrémenter à chaque mise-à-jour du site, et simplement faire en sorte que le serveur concatène cette version en tant que query string à toutes vos ressources statiques modifiées (ou toutes vos ressources statiques pour faire simple).

Un exemple est présenté (en PHP) sur CSSTricks:

<?php $cssVersion = "3.4.2"; ?>

<link rel="stylesheet" href="global.css?v=<?php echo $cssVersion; ?>">

Le principal avantage de cette technique, c'est que le fichier statique en lui-même ne doit pas changer de nom. Dans l'exemple ci-dessus il s'appelle toujours global.css.

Si vous n'utilisez pas d'outils de build pour vos CSS (type Webpack ou SASS ou Gulp, etc.) vous allez probablement vouloir toujours utiliser le même fichier avec le même nom. La méthode utilisant un query string est parfaite dans ce cas.

Cette méthode n'est pas vraiment applicable à l'objet de cet article par contre, à moins d'avoir transformé vos pages Polymer en templates PHP par exemple ou d'utiliser une solution de rendu serveur. Ces deux cas de figure sont assez velus.

Le seul inconvénient des query strings, c'est qu'il est possible que certains serveurs proxy ou navigateurs du moyen-age considèrent que nom_de_fichier.jpg et nom_de_fichier.jpg?v=1.0 soient le même fichier.

L'article CSSTricks présente d'autres solutions non discutées ici. En soi, il y a énormément de possibilités.

Utiliser un hash

Webpack est très à la mode en ce moment. Il permet de très facilement ajouter un hash du fichier lors de la génération de votre application.

En combinant ça au non-moins-très-utilisé HtmlWebpackPlugin, vous pouvez automatiquement injecter les fichiers renommés dans votre (vos) page(s) HTML.

Voyez cet article pour les explications officielles de cache busting avec Webpack et le plugin HTML.

Il me semble que VueJS et React fonctionnent de cette manière par défaut si vous les utilisez avec Webpack. Pas d'inquiétude de ce côté là donc.

Au niveau de Polymer, il n'y a à ma connaissance aucun moyen de mettre en place ce type de cache-busting avec les outils qu'ils fournissent.

Renommer en ajoutant un numéro de version

J'utilise cette méthode pour le site que vous visitez en ce moment. Si vous affichez la source de cette page, vous devriez voir que les ressources sont mentionnées avec un numéro de version dans le nom de fichier.

J'utilise également Webpack pour générer ces fichiers.

Comme les fichiers changent de nom à chaque nouvelle version du site, les navigateurs doivent les re-télécharger à chaque fois.

Dans ce cas de figure (idem si on renomme avec un hash, bien entendu) il est possible de mettre en cache les fichiers statiques pour toujours et donc de ne pas fournir d'en-têtes de contrôle de cache au niveau serveur (comportement par défaut, dois-je le rappeler).

Historiquement, j'ai du contrôle de cache sur mon serveur qui devrait être retiré. En effet, la précédente version de dkvz.eu était une app Polymer 1.0.

Comment faire avec Polymer 2.0?

Après cette intro super utile, venons-en à l'objet du présent article.

J'imagine que comme moi, vous n'êtes pas un hacker de l'extrême, et vous utilisez la Polymer CLI pour assembler votre application.

Le plus simple étant d'utiliser cette commande:

polymer build

Vous déployez ensuite l'une ou l'autre (ou les deux) versions "bundled" sur votre serveur Apache (cet article concerne Apache uniquement).

Les fichiers statiques ont évidemment toujours les mêmes noms, pas de hash, pas de numéros de versions, pas de query strings.

Question: est-ce que je pourrais au moins faire en sorte que les navigateurs vérifient si les fichiers ".html" importés dans mes pages ont été modifiés avec une requête à laquelle le serveur répondrait par une 304 si le fichier n'a pas changé?

Avec une telle solution, on aurait certes des requêtes en plus qui ne sont potentiellement pas nécessaire, mais le serveur ne doit pas nécessairement envoyer tout le contenu à chaque fois. Je trouve que c'est une bonne demi-poire.

Demander une revalidation des fichiers statiques

Au niveau du standard HTTP, normalement (mention importante pour la suite), il suffit d'ajouter deux en-têtes pour causer la revalidation automatique:

Cache-Control "max-age=0, must-revalidate"

On a donc mis la date de péremtion à maintenant-tout-de-suite-là.

Problème: Apache ne répond jamais par une réponse 304, ou du moins pas sur du contenu statique.

Attends quoi? Hein? OUAT?

Peut-être que sur votre Apache du futur c'est différent, mais (version 2.4+) nous on se tape un Apache qui a un gros soucis quand on active la compression gzip (pas activée par défaut) et que l'en-tête ETag est envoyée avec les réponses (par défaut).

Sérieusement ce bug date de 2008: https://bz.apache.org/bugzilla/show_bug.cgi?id=45023.

O_o

Nous voilà obligés d'appliquer un weurkerownd tout foireux qui consiste à utiliser mod_headers pour retirer l'en-tête etag de toutes les réponses.

Si ce n'est pas déjà fait, activez les modules expires et headers:

a2enmod expires
a2enmod header

Et faites un reload d'Apache si nécessaire.

Je vous montre ma solution complète et puis j'explique (ici dans la définition de mon virtual host):

<IfModule mod_expires.c>
    ExpiresActive On
    ExpiresDefault "access plus 64 days"
    ExpiresByType text/javascript "access plus 10 days"
    ExpiresByType application/javascript "access plus 10 days"
    ExpiresByType application/x-javascript "access plus 10 days"
    Header unset ETag
    <FilesMatch (\.json|\.html)$>
      ExpiresActive Off
      Header append Cache-Control "max-age=0, must-revalidate"
    </FilesMatch>
</IfModule>

Ce que je fais ici:

  • Activer le module expires afin d'envoyer mes fichiers aux clients avec des dates d'expiration (sinon on envoie aucune date d'expiration par défaut).
  • Ajouter des dates d'expiration par types de fichier. Les fichiers ".js" dans mon cas sont des librairies qui j'utilise en plus de Polymer, ainsi que les polyfills de Polymer et Polymer lui-même.
    • Ces fichiers changent très rarement en pratique, mais sur une app en développement actif j'ai trouvé 10 jours une bonne idée. C'est totalement arbitraire.
  • On retire l'en-tête ETag de TOUTES les réponses, à cause de l'infâme bug Apache avec la compression gzip.
  • Pour les fichiers .json (particulièrement mon fichier de traduction pour sites multi-langues) et html (les composants Polymer):
    • On désactive la date d'expiration (on a défini une date d'expiration par défaut arbitraire plus haut).
    • On ajoute l'en-tête Cache-Control signifiant qu'il faut redemander la ressource à chaque fois.

Ce qui nous donne des réponses 304 en cas de reload d'une page:

Un petit reload

A noter que les scripts ".js" qui sont sur index.html sont chaque fois re-demandés. Les autres scripts que vous utilisez (dans des composants par exemple), seront mis en cache normalement avec la date de péremption spécifiée plus haut. Il n'y aura aucune nouvelle requête pour ces fichiers.

A vrai dire je ne sais pas pourquoi il demande les fichiers ".js" qui sont sur index.html à chaque fois mais c'est un moindre mal.

L'important c'est que je peux modifier mon composant de vue "login-view.html" qui est lazy-loaded (on voit xhr dans la colonne "Cause"), et il sera automatiquement mise-à-jour chez tous les clients si je modifie le fichier sur le serveur. C'était ce-qui-était-demandé (CQED).

Le prix à payer c'est qu'il faut réaliser un certain nombre de requêtes à chaque fois, mais ces requêtes devraient être très vite bouclées. Ce qui prend du temps ce sont mes requêtes d'API (les réponses 200 sur l'image).

Si vous voulez utiliser quelque chose d'un peu moins sauvage et juste retirer le ETag pour les fichiers json et html vous pouvez utiliser ceci:

<IfModule mod_expires.c>
    ExpiresActive On
    ExpiresDefault "access plus 64 days"
    ExpiresByType text/javascript "access plus 10 days"
    ExpiresByType application/javascript "access plus 10 days"
    ExpiresByType application/x-javascript "access plus 10 days"
    <FilesMatch (\.json|\.html)&dollar;>
      Header unset ETag
      ExpiresActive Off
      Header append Cache-Control "max-age=0, must-revalidate"
    </FilesMatch>
</IfModule>

Avec cette config, je constate de très étranges choses en cela que pour certains fichiers ".js" injectés par des composants, le cache fonctionne normalement. Pour les .js sur la page index, il ne fonctionne pas du tout (résultat du bug d'Apache susmentionné).

Commentaires

Il faut JavaScript activé pour écrire des commentaires ici

Ajouter un commentaire

Votre commentaire a été ajouté
(enfin, je pense)