Contenu principal

PWA : Intercepter les requĂȘtes HTTP et les mettre en cache

dimanche 24 décembre 2017

Le terme Progressive Web Apps (PWA) vient de Frances Berriman et Alex Russel. Le but derriĂšre ce nouveau mot est de promouvoir une façon de penser et de concevoir des sites webs. Ce n’est donc pas une technologie. Le but de ce terme est purement marketing afin de convaincre les gens de faire mieux : s’il apparaĂźt dans tous les fils de discussion, on est bien obligĂ© de s’y intĂ©resser Ă  un moment ou Ă  un autre. Et ils y arrivent plutĂŽt bien. On le voit partout.

Voici donc une sĂ©rie d’articles oĂč j’essaierai de vous prĂ©senter ce que c’est et surtout comment le mettre en place pour vos utilisateurs. Accrochez-vous, ça fait pas mal de choses Ă  dĂ©couvrir :

  1. Rendre un site web disponible grĂące aux Services Workers
  2. Déclarer un Service Worker et gérer son cycle de vie
  3. Intercepter les requĂȘtes HTTP et les mettre en cache (Vous ĂȘtes ici.)
  4. Proposer une expérience hors ligne (à paraßtre)

Dans les articles prĂ©cĂ©dents, je prĂ©sentais ce qu’est une PWA et un Service Worker. Puis, nous avons vu comment dĂ©clarer un Service Worker et gĂ©rer la liaison avec une page web.

Maintenant, il est temps d’utiliser tout ça pour commencer Ă  amĂ©liorer l’expĂ©rience de nos utilisateurs. Pour cela, nous allons faire en sorte qu’un utilisateur puisse revenir sur notre site et accĂ©der au contenu quel que soit l’état de sa connexion internet.

Je vais ainsi commencer par prĂ©senter la fonctionnalitĂ© phare des Service Workers qui permet d’atteindre ce but : l’interception de requĂȘtes. Ensuite, nous verrons comment nous pouvons coupler cela Ă  la Cache API pour rĂ©utiliser des requĂȘtes dĂ©jĂ  Ă©mises et ainsi Ă©viter d’ĂȘtre dĂ©pendant de la connexion internet.

A la fin de cet article vous aurez donc toutes les billes Ă  votre disposition pour rendre votre site disponible. Cependant, nous n’en serons encore qu’à la prĂ©sentation des dĂ©tails techniques de ces fonctionnalitĂ©s. Il faudra attendre le dernier article pour voir comment assembler tout ça pour proposer une version hors ligne de votre site Ă  vos utilisateurs.

Intercepter les requĂȘtes HTTP

Commençons donc par l’interception de requĂȘtes. L’idĂ©e de cette fonctionnalitĂ© est de se positionner entre le site web et internet pour pouvoir Ă©viter de passer par internet quand c’est possible.

PrĂ©cĂ©demment, nous avons vu qu’il y a des Ă©vĂšnements sur lesquels nous pouvons nous brancher dans un Service Worker. Il y a par exemple install et activate qui offrent la possibilitĂ© de gĂ©rer le cycle de vie du Service Worker. Pour mettre en place l’interception de requĂȘtes, il existe un autre Ă©vĂšnement : fetch. Celui-ci est Ă©mis Ă  chaque fois qu’une page liĂ©e Ă  votre Service Worker Ă©met une requĂȘte HTTP.

Ainsi, en se branchant à fetch, nous pouvons voir ce qui est demandé par la page (event.request) et modifier la réponse en utilisant event.respondWith.

Ainsi, si je veux faire en sorte d’intercepter la requĂȘte vers /toto dans ma page, il faut que j’écrive le code suivant :

// fichier /service-worker.js

// On se branche sur chaque requĂȘte Ă©mise
self.addEventListener('fetch', (event) => {
	const requestUrl = new URL(event.request.url);

	// Si la requĂȘte est bien celle que l'on
	// veut simuler
	if (requestUrl.pathname === '/toto') {
		// Alors, on modifie la réponse
		event.respondWith(
			// Ici je crée une réponse à partir
			// de rien qui contient uniquement
			// "Hello Toto"
			new Response(new Blob(['Hello Toto'], { type: 'text/html' }), {
				status: 200,
				statusText: 'OK',
				headers: {
					'Content-Type': 'text/html'
				}
			})
		);
	}
});

Retrouvez cet exemple ici.

Si vous executez cet exemple, et que vous allez sur /toto, votre navigateur affichera “Hello Toto”. Si ce n’est pas le cas, c’est que votre Service Worker n’a pas Ă©tĂ© dĂ©clarĂ© (cf. l’article prĂ©cĂ©dent pour voir comment dĂ©clarer le Service Worker).

Il nous est donc possible d’intercepter une requĂȘte et afficher du contenu sans passer par le serveur. En l’occurence, c’est une page HTML que l’on affiche, mais cela pourrait ĂȘtre n’importe quoi : des fichiers CSS, des fichiers JS, des requĂȘtes API, etc.

Prévoir une solution de secours

LĂ  tout de suite, vous ne vous en rendez peut-ĂȘtre pas compte Ă©tant donnĂ© qu’on ne renvoie qu’un “Hello Toto”, mais c’est trĂšs puissant comme fonctionnalitĂ©.

Il devient par exemple assez tentant de vouloir ajouter des traitements sur les requĂȘtes au niveau du Service Worker. On peut rapidement en devenir dĂ©pendant. Cependant il est trĂšs important de se dĂ©brouiller pour que le site fonctionne sans Service Worker.

Il est par exemple tout Ă  fait possible que le navigateur de l’utilisateur ne supporte pas encore les Service Workers ou que ceux-ci soient dĂ©sactivĂ©s. Mais il est aussi possible que votre page se retrouve dĂ©tachĂ©e du Service Worker pour on ne sait quelle raison (a.k.a. un bug).

Ainsi, il est interdit de faire en sorte que certaines requĂȘtes ne fonctionnent que si un Service Worker est prĂ©sent. Il faudra plutĂŽt mettre en place un serveur qui fournisse /toto pour que la requĂȘte fonctionne aussi lorsque le Service Worker est absent. Ainsi, s’il y a un Service Worker, on amĂ©liore les performances ressenties. S’il n’y en a pas, on a toujours accĂšs au contenu.

Intercepter pour mettre en cache

Ok, mais l’interception de requĂȘte pour faire des rĂ©ponses sorties de nulle part, ce n’est pas trĂšs intĂ©ressant dans la vie de tous les jours. Attaquons nous donc au coeur du sujet.

Notre but initial est de rendre notre site web disponible, afin que l’utilisateur continue d’accĂšder au site en Ă©tant hors ligne (ou lorsque le WiFi a sautĂ© ). Pour y arriver, nous allons rĂ©utiliser les donnĂ©es dĂ©jĂ  rĂ©cupĂ©rĂ©es depuis le serveur et les servir Ă  nouveau si l’utilisateur fait la mĂȘme requĂȘte. C’est le principe de mise en cache.

Cache API

Ca tombe bien, il y a une API prĂ©vue pour ça : la Cache API. Celle-ci est disponible via la variable caches. L’idĂ©e derriĂšre cette API est de pouvoir stocker une rĂ©ponse pour une requĂȘte donnĂ©e. Ainsi, quand on reçoit une nouvelle requĂȘte, on peut vĂ©rifier s’il y a dĂ©jĂ  une rĂ©ponse stockĂ©e ou non.

// Stockage d'une réponse pour
// une requĂȘte spĂ©cifique
// https://developer.mozilla.org/fr/docs/Web/API/Cache/put
cache.put(request, response.clone()).then(() => {
	console.log(`
    Réponse mise en cache et
    associĂ©e Ă  la requĂȘte donnĂ©e.
  `);
});

// RĂ©cupĂ©ration d'une requĂȘte
// depuis le cache
// https://developer.mozilla.org/fr/docs/Web/API/Cache/match
cache.match(request).then((response) => {
	if (response) {
		console.log('Réponse déjà en cache.', response);
	} else {
		console.log(`
      Pas encore de cache pour cette requĂȘte
    `);
	}
});

// Suppression d'un élément
// du cache
// https://developer.mozilla.org/fr/docs/Web/API/Cache/delete
cache.delete(request).then(() => {
	console.log(`
    Cache supprimĂ© pour la requĂȘte donnĂ©e.
  `);
});

A noter qu’on a bien fait attention Ă  cloner la requĂȘte avant de la stocker (.clone()). Cela permet d’éviter tout effet de bord lors de la rĂ©cupĂ©ration du corps de la requĂȘte (cf. Response.clone() sur MDN).

Un deuxiĂšme point Ă  noter est qu’il est possible de sĂ©parer les diffĂ©rents de types de caches. Cela facilite notamment la suppression d’une partie du cache de maniĂšre ciblĂ©e. Il est par exemple souvent pertinent d’avoir deux caches diffĂ©rents pour les assets et les requĂȘtes API.

Lexique : Je vais rĂ©guliĂšrement parler d’assets et de requĂȘtes API. Dans le cadre de cet article, il faudra comprendre ces termes comme suit :

  • Asset : tout fichier statique permettant d’afficher votre page web (javascript, css, images, etc.)
  • RequĂȘte API : toute requĂȘte qui permet de rĂ©cupĂ©rer le contenu dynamique de la page. GĂ©nĂ©ralement il s’agit d’objets JSON, mais ça peut ĂȘtre du HTML, du GraphQL, etc.

Ainsi, si l’on veut gĂ©rer les requĂȘtes/rĂ©ponses d’une partie du cache seulement, il faut utiliser open :

caches.open('Nom du cache').then((cache) => {
	// Ici on a accÚs aux méthodes
	// cache.put, cache.match et
	// cache.delete
});

Mise en place du cache des requĂȘtes

Maintenant que nous sommes capables de mettre des choses dans le cache, il faut se demander quoi mettre en cache, dans quel contexte, etc. On va parler de stratégies.

C’est intimement liĂ© Ă  la nature du site web. Potentiellement, on pourrait imaginer une infinitĂ© de stratĂ©gies diffĂ©rentes. Cela dit, les principales stratĂ©gies sont :

  • Network Only : on ne veut pas de cache car l’opĂ©ration est critique/ne peut pas fonctionner hors ligne. Si ce n’est qu’une partie de l’application, il est important d’expliquer clairement au niveau de l’interface pourquoi la fonctionnalitĂ© n’est pas disponible.
  • Cache First : on rĂ©cupĂšre en prioritĂ© depuis le cache. S’il n’y a pas encore de cache, on va chercher sur le rĂ©seau et on stocke la rĂ©ponse dans le cache. L’intĂ©rĂȘt est qu’une fois qu’on a mis quelque chose en cache, on est capable de le servir trĂšs rapidement Ă  l’utilisateur. La performance ressentie s’en retrouve grandement amĂ©liorĂ©e.
  • Network First : on rĂ©cupĂšre en prioritĂ© depuis le rĂ©seau. Si le rĂ©seau ne rĂ©pond pas, on sert le cache afin d’afficher du contenu. Cela permet d’afficher du contenu qui n’est peut-ĂȘtre plus Ă  jour, mais qui a le mĂ©rite d’ĂȘtre lĂ .
  • Stale While Revalidate : on rĂ©cupĂšre le cache et on l’envoie. Le contenu est ainsi directement disponible. Ensuite, on va chercher la requĂȘte sur le rĂ©seau pour que ce soit Ă  jour la prochaine fois qu’on fait la requĂȘte.

Ces quelques méthodes sont disponibles dans Workbox. Vous pouvez aussi retrouver un tout en tas de stratégies dans The offline cookbook de Jake Archibald qui est une trÚs bonne resource sur le sujet.

Comme vous pouvez le voir, chaque stratégie a ses avantages et ses inconvénients. Pour choisir laquelle est la bonne, personnellement, je me pose les questions suivantes :

  • Est-ce que la ressource doit ĂȘtre dĂ©livrĂ©e trĂšs rapidement ? Dans ce cas je privilĂ©gie le cache en premier.
  • Est-ce que la fiabilitĂ© est plus importante que la vitesse ? Dans ce cas, je privilĂ©gie le rĂ©seau.

Pour faire simple, la rapidité passe par le cache. La fiabilité passe par le réseau.

Cas d’application : Cache First

Dans cet article, je n’aurai ni le temps ni la place de dĂ©tailler chacune des stratĂ©gies. Cependant, en partant de l’implĂ©mentation d’une stratĂ©gie, il est gĂ©nĂ©ralement possible de l’adapter en modifiant l’ordre d’execution pour en arriver Ă  une autre.

L’implĂ©mentation que nous allons voir ensemble est Cache First. Pour rappel, le principe est de voir si une requĂȘte est dĂ©jĂ  prĂ©sente dans le cache pour pouvoir la servir la plus rapidement possible. Si elle n’y est pas, on fait la requĂȘte rĂ©seau et on met Ă  jour le cache.

const ASSETS_CACHE_NAME = 'assets';

// En premier, deux méthodes d'aide
// pour faciliter la lecture de
// `getResponseFromCacheFirst`

const getResponseFromCache = (cacheName, request) => {
	// On ouvre le bon cache
	return caches.open(cacheName).then((cache) => {
		// Et on récupÚre la réponse
		// correspondant Ă  la requĂȘte
		return cache.match(request);
	});
};

const setResponseCache = (cachename, request, response) => {
	// On ouvre le bon cache
	return caches.open(cacheName).then((cache) => {
		// Et on stocke la nouvelle
		// rĂ©ponse pour la requĂȘte donnĂ©e
		return cache.put(request, response);
	});
};

// Mise en place de la stratégie
// Cache First pour la requĂȘte
// donnée
const getResponseFromCacheFirst = (cacheName, request) => {
	// Cette méthode permet de récupérer la
	// rĂ©ponse d'une requĂȘte. Si celle-ci est
	// déjà en cache, on répond avec le cache
	// en prioritĂ©. Sinon, on fait la requĂȘte,
	// on met en cache la réponse, puis on
	// renvoie la réponse.

	// Récupération depuis le cache
	const response = getResponseFromCache(cacheName, request).then((response) => {
		if (response) {
			// Si la requĂȘte est dĂ©jĂ  en cache,
			// on renvoie la réponse trouvée
			return response;
		} else {
			// Sinon, on fait la vraie requĂȘte
			return fetch(request).then((response) => {
				// Une fois qu'on a reçu la
				// réponse, on met en cache
				// pour la prochaine fois.
				// On n'oublie pas de cloner
				// la réponse pour pouvoir la
				// mettre en cache.
				setResponseCache(cacheName, request, response.clone());

				// Et on retourne la réponse
				return response;
			});
		}
	});

	return response;
};

self.addEventListener('fetch', (event) => {
	const requestUrl = new URL(event.request.url);

	// Quand on intercepte une requĂȘte,
	// si c'est un asset, on applique la
	// stratégie Cache First
	if (requestUrl.pathname.startsWith('/assets')) {
		event.respondWith(getResponseFromCacheFirst(ASSETS_CACHE_NAME, event.request));
	}
});

Retrouvez cet exemple ici

Et voilà, on a mis en place notre premiÚre stratégie de cache ! :)

Pour vĂ©rifier que vous avez bien compris le fonctionnement de ce code, un bon exercice serait de le reprendre et d’implĂ©menter une autre stratĂ©gie.

Ne pas intercepter n’importe quoi

Dans l’exemple ci-dessus, vous pouvez constater que je n’ai mis en cache que les assets. Il est trĂšs important de ne toucher qu’aux URLs que l’on maĂźtrise. Sinon, on peut se retrouver dans des situations dĂ©licates.

Imaginons un instant que nous n’ayons pas mis de filtre et que nous interceptions toutes les requĂȘtes pour les mettre en cache. Dans ce cas, au rafraĂźchissement de la page, on n’irait plus chercher les infos sur le serveur. Tout est dĂ©jĂ  en cache et va trĂšs vite. Cool !

Cependant, un dĂ©tail auquel on n’a pas pensĂ©, c’est qu’au chargement de la page, on fait une requĂȘte qui va chercher le nombre de notifications non lues. Etant donnĂ© qu’il n’y a pas de filtre au niveau de la mise en cache, cette requĂȘte se retrouve elle aussi en cache. Ainsi, si une nouvelle notification arrive, on rĂ©cupĂšre tout de mĂȘme l’ancienne valeur. Oups.

Il faut donc faire trĂšs attention. D’autant plus qu’on n’est pas toujours maĂźtre de tout ce qui est sur son site web. C’est par exemple le cas pour les outils d’analytics, les librairies externes, etc. Mais c’est aussi le cas quand une autre Ă©quipe travaille sur une autre partie de l’application.

En tant que dĂ©veloppeur, lorsque l’on met en place son propre Service Worker, croyez-moi, on finit toujours par perdre du temps sur ce genre d’erreurs. Heureusement, les DevTools de nos navigateurs sont lĂ  vous aider Ă  repartir d’un Ă©tat stable :

  • Sur Firefox, ouvrez un nouvel onglet Ă  l’URL about:debugging#workers et cliquez sur unregister sur le service worker qui vous intĂ©resse.
  • Sur Chrome, dans les DevTools (F12), allez dans l’onglet Application > ``Service Workerset cliquez surUnregister`.

Cela dit, pour les utilisateurs de votre site, il n’y a pas vraiment de solution miracle. ils seront obligĂ©s d’attendre la mise Ă  jour votre Service Worker et la remise Ă  plat du cache


Mais comment faire cette remise à plat ?

Mettre Ă  jour le cache

MĂȘme si nous avons tout bien fait, il y a toujours un moment oĂč nous souhaitons invalider/vider le cache. Cela peut se produire quand on publie une nouvelle version du site par exemple. C’est aussi le cas quand le contenu Ă  afficher change dans le temps (nouveau commentaire, notification, etc.).

L’idĂ©e est alors d’appeler cache.delete(). Mais oĂč et quand faut-il le faire ?

Comme d’habitude, la rĂ©ponse est : ça dĂ©pend. En effet, si c’est une requĂȘte API ou un asset, vraisemblablement, ce sera trĂšs diffĂ©rent.

Les assets, par exemple, ont de fortes chances d’ĂȘtre mis Ă  jour au mĂȘme moment que le Service Worker. L’idĂ©e est alors de tenir une liste de tous les assets Ă  aller rĂ©cupĂ©rer lors de l’installation de votre nouveau Service Worker.

Au contraire, si des requĂȘtes API ont Ă©tĂ© mises en cache, le sujet devient tout de suite beaucoup plus complexe. C’est tout un systĂšme de synchronisation qu’il faut mettre en place si on veut ĂȘtre sĂ»r d’avoir toujours la derniĂšre version. C’est donc un sujet suffisamment difficile pour mĂ©riter un ou plusieurs articles Ă  lui tout seul. Cependant, parfois le fait de choisir la bonne stratĂ©gie de cache peut suffire (Network First ?).

Mettre Ă  jour le cache des assets

Afin de ne pas trop nous éparpiller, je me contenterai dans cet article de vous présenter une implémentation possible pour la mise en cache de fichiers statiques.

Evidemment, cela dépend de vos assets, mais en général cela se fait en trois étapes :

  1. Pour chaque version, on liste la totalité des assets à télécharger.
  2. Lors de la tĂąche d’installation du Service Worker, on met en cache tous les nouveaux assets. Ainsi, lorsque le Service Worker est activĂ©, on n’est pas dĂ©pendant des pages qui ont dĂ©jĂ  Ă©tĂ© visitĂ©es sur le site : tout est directement disponible.
  3. Lors de la tĂąche d’activation du Service Worker, on retire tous les caches qui ne sont plus utiles afin d’éviter que le cache grossisse indĂ©finiment.

Cela donnerait donc quelque chose qui ressemblerait à ça :

const ASSETS_CACHE_NAME = 'assets';

// (1) On recense l'ensemble des assets
// dans une variable.
const assetsList = ['/static/css/main.ez84s6df.css', '/static/js/main.aze4sd31.js'];

// (2) Méthode permettant de mettre en
// cache un asset s'il n'est pas déjà
// en cache
const cacheAsset = (url) => {
	// On travaille dans le cache dédié aux
	// assets
	return caches.open(ASSETS_CACHE_NAME).then((cache) => {
		const request = new Request(url);

		// On regarde si l'asset est déjà
		// en cache (ex: déjà utilisé
		// dans le précédent Service Worker)
		return cache.match(request).then((response) => {
			if (!response) {
				// Si pas de cache existant
				// on récupÚre l'asset
				return fetch(request).then((response) => {
					// Et on le met en cache
					return cache.put(request, response.clone());
				});
			}
		});
	});
};

self.addEventListener('install', (event) => {
	// (2) On considĂšre qu'un Service Worker est
	// installé une fois que tous les assets
	// ont été mis en cache
	event.waitUntil(Promise.all(assetsList.map((url) => cacheAsset(url))));
});

// (3) Méthode permettant de retirer du
// cache tous les assets qui ne sont plus
// utilisés
const removeUnusedAssets = () => {
	return caches.open(ASSETS_CACHE_NAME).then((cache) => {
		// On rĂ©cupĂšre toutes les requĂȘtes
		// stockées dans le cache
		return cache.keys().then((requests) => {
			// On ne veut retirer que les
			// requĂȘtes qui ne sont plus
			// dans `assetsList`
			const unusedRequests = requests.filter((request) => {
				const requestUrl = new URL(request.url);
				return assetsList.indexOf(requestUrl.pathname) === -1;
			});

			// Et on retire ces requĂȘtes
			// une par une
			return Promise.all(
				unusedRequests.map((request) => {
					return cache.delete(request);
				})
			);
		});
	});
};

self.addEventListener('activate', (event) => {
	// (3) Une fois qu'un Service Worker est
	// utilisé il faut penser à nettoyer le
	// cache pour qu'il ne grossisse pas
	// indéfiniment
	event.waitUntil(removeUnusedAssets());
});

Cette implĂ©mentation est plus complexe que les ressources que l’on a l’habitude de trouver sur le sujet. En effet, souvent, c’est plutĂŽt le nom de cache qui va changer de version en version. Ainsi, Ă  l’installation, on rĂ©cupĂšre tous les assets sans distinction et on les met dans le cache assets-vXXX. Et Ă  l’activation, on supprime tous les caches sauf assets-vXXX. C’est d’ailleurs l’exemple proposĂ© dans MDN.

Cependant l’avantage avec l’implĂ©mentation que je vous ai montrĂ© est qu’on Ă©conomise des requĂȘtes serveur. On ne va pas chercher deux fois la mĂȘme URL. L’inconvĂ©nient, par contre, c’est qu’on considĂšre que si une URL mise en cache a le mĂȘme nom, c’est qu’elle n’a pas changĂ©. Il faut donc bien faire attention Ă  utiliser la bonne implĂ©mentation au bon moment.

Avec les mĂ©thodes de cache busting aujourd’hui, c’est assez sĂ»r d’utiliser cette implĂ©mentation sur les fichiers JS/CSS. Mais ça peut par exemple ĂȘtre piĂ©geux de le faire sur un fichier HTML qui ressemble Ă  un asset mais qui change Ă  chaque fois que les fichiers JS/CSS importĂ©s changent.

Toute fois, si vous avez bien compris l’implĂ©mentation de cette stratĂ©gie, je pense que vous serez capables d’implĂ©menter n’importe quelle stratĂ©gie de mise Ă  jour du cache.

Conclusion

Si on rĂ©capitule, nous avons interceptĂ© une requĂȘte et nous sommes maintenant capables de renvoyer des informations du cache plutĂŽt que d’aller rĂ©cupĂ©rer les donnĂ©es depuis le serveur.

Ainsi, potentiellement, si les bonnes requĂȘtes ont Ă©tĂ© mises en cache, il est maintenant possible sur vos sites webs d’afficher du contenu malgrĂ© une connexion hasardeuse. Mieux que ça, grĂące aux Service Workers, vous ĂȘtes capables de dĂ©tecter les mises Ă  jour de vos sites et prĂ©parer le cache pour les futures utilisations de votre site.

Si vous avez tout compris, et que vous vous sentez à l’aise pour utiliser chacun de ces outils, alors ma mission est finie !

Cela dit, mĂȘmes si nous sommes rentrĂ©s dans les dĂ©tails techniques, nous sommes restĂ©s trĂšs vagues en rĂ©pondant Ă  beaucoup de questions par “ça dĂ©pend”. Nous avons donc les armes pour la suite mais je ne pense pas que tout le monde se sente prĂȘt Ă  faire un site web accessible hors ligne dĂšs demain.

Pour cette raison, j’ai prĂ©vu une derniĂšre partie Ă  cette sĂ©rie d’article : une mise en pratique de tout ce qu’on a vu pour transformer un site web standard en un site web qui propose une expĂ©rience hors ligne.

Cependant, cette derniÚre partie demandera certainement autant de préparation que les trois premiÚres réunies ! Je vous donne donc rendez-vous courant janvier pour lire la suite.

En attendant, je vous souhaite Ă  tous d’excellentes fĂȘtes ! Si vous avez des questions ou des commentaires, n’hĂ©sitez pas Ă  m’en faire part sur Twitter ou Github. Je me ferai une joie de vous rĂ©pondre.


Sources complémentaires :


Si vous voulez suivre mes publications, il paraĂźt que j'ai un feed RSS, Mastodon et un Twitter.

Si vous pensez à d'autres méthodes que vous voudriez que je mette en place (pigeon voyageur, avion en papier, etc.), n'hésitez pas à me les proposer :)