Migration micro-incrémentale d'un legacy avec Next.js
Publié le 16 novembre 2021
Chez Les-Tilleuls.coop, une problématique revient régulièrement : le code de production tourne sur des technologies vieillissantes et a besoin d'être migré vers des versions plus récentes ou bien un changement complet de technologie est demandé.
Plusieurs stratégies de migration existent, et nous adaptons notre réponse selon le contexte et les besoins de chaque client.
Une des stratégies les plus intéressantes est la migration incrémentale : le nouveau et l'ancien code tournent en même temps et le code peut donc être réécrit au fur et à mesure.
Le risque d'impacter les utilisateurs est amoindri et il est même possible de déployer des releases canary pour s'assurer de ne pas introduire de bugs.
Une des manières les plus simples de réaliser cette stratégie est de se baser sur la configuration du reverse proxy : l'utilisateur utilise l'ancien code ou le nouveau selon le chemin de la page sur laquelle il se trouve. Les deux codes peuvent utiliser des infrastructures différentes, mais il est nécessaire de migrer complètement une partie de l'application pour réaliser cette technique.
Dans le cas de certaines technologies, il est possible d'avoir les deux codes en même temps. C'est par exemple le cas avec AngularJS, Angular et l'outil ngUpgrade que nous avons eu l'occasion de mettre en place chez Alice's Garden, assurant la migration de leur administration au fil de l'eau sur plusieurs mois sans jamais couper leurs services. Cette stratégie est idéale puisque chaque composant peut être migré indépendamment mais peu de technologies supportent cette technique.
Cet article a pour but d'aborder une troisième manière de faire : appliquer le Strangler Fig pour migrer petit à petit des parties d'une page, en particulier pour migrer une page classique HTML + JavaScript (peu importe la technologie du backend utlisée pour générer cette page) vers une page Next.js.
J'ai choisi d'appeler cette stratégie « migration micro-incrémentale », pour exprimer ce découpage en petites parties d'une page, que l'on peut migrer indépendamment.
Cette approche partage de nombreuses similitudes avec l'architecture micro frontends et plus précisément l'architecture islands. Les composants React constituent un îlot tandis que la page legacy, préalablement découpée pour ne garder que les parties non migrées, constitue un deuxième îlot.
Mais avant d'aller plus loin, abordons rapidement ce qu'est Next.js et ce qu'il nous apporte dans cette situation.
Next.js, le framework SSR de React
Contrairement à Vue.js ou Angular, React n'est pas un framework, c'est une bibliothèque. Pour réaliser une application complète, il est souvent nécessaire d'utiliser des bibliothèques supplémentaires, par exemple pour le routage.
Dans une utilisation classique de React, de Vue.js ou d’Angular, le DOM est construit dynamiquement côté navigateur : le serveur ne renvoie quasiment pas de HTML ce qui peut poser des problèmes, en terme de SEO et de performance notamment.
Next.js propose de résoudre ces deux problèmes à la fois. C'est un framework, donc il amène notamment son propre système de routage et il gère plusieurs types de rendu : SSR (rendu HTML côté serveur avec Node.js), SSG (fichiers HTML générés dans des fichiers plats) et même composants serveur dans sa version 12.
Pourquoi ce framework nous intéresse-t-il dans le cas d'une migration micro-incrémentale ? Comme vous allez le constater dans la partie suivante, utiliser cette technique ajoute un coût en terme de performance : il faut récupérer la page legacy, la parser et la rendre avec le reste des composants.
Next.js va nous permettre dans le meilleur des cas de générer statiquement certaines pages, ce qui effacera ce surcoût par rapport à la legacy, voire améliorera les performances si les pages n'étaient pas statiques avant la migration.
À noter qu’il est possible d’utiliser la stratégie reverse proxy avec Next.js en se servant des rewrites.
Rentrons maintenant dans le vif du sujet : comment fait-on pratiquement ?
Gérer le routage
La première étape est de gérer le routage de notre application. Nous voulons avoir à la fois la possibilité d'avoir des pages totalement migrées et des pages partiellement migrées.
Le routeur de Next.js se base sur le nom des fichiers. Les pages complètement migrées seront créées de manière classique et nous allons créer une page spéciale qui va récupérer dynamiquement toutes les autres routes legacy avec la syntaxe spéciale [[...]]
.
Cela va donner :
/pages
account/profile.tsx
new.tsx
[[...legacy]].tsx
Nous voulons qu'un maximum de pages soient générées statiquement. Nous allons ainsi utiliser la fonction spéciale getStaticProps
:
const LEGACY_HOST = 'https://legacy.mywebsite.com';
type PathsParams = {
params: {
legacy?: string[];
};
};
export async function getStaticProps({ params: { legacy } }: PathsParams) {
const response = await fetch(`${LEGACY_HOST}/${legacy ? legacy.join('/') : ''}`);
const content = await response.text();
return {
props: {
html: content,
},
};
}
Pour l'instant nous nous contentons de retourner tout le code HTML de la page legacy dans la prop html
.
Vous pouvez constater qu'il est intéressant d'avoir créé au préalable un sous-domaine legacy
de votre site, qui retournera les anciennes pages.
Afin d'indiquer à Next.js quelles pages doivent être générées statiquement à la compilation, il faut utiliser la fonction getStaticPaths
:
export async function getStaticPaths() {
return {
paths: [
{ params: { legacy: [] } },
{ params: { legacy: ['booking', 'new'] } },
],
fallback: 'blocking',
};
}
Dans cet exemple, Next.js va générer deux pages statiques à la compilation : la page d'accueil et la page /booking/new
de la legacy :
Vous pouvez constater que blocking
a été choisi pour la propriété fallback
: si le chemin legacy n'est pas dans la liste des paths
, Next.js fera le rendu côté serveur lorsqu'il recevra la requête (SSR classique), puis un fichier plat sera créé. Les requêtes suivantes sur ce même chemin utiliseront le fichier.
Les pages ne seront pas regénérées tant qu'une nouvelle compilation ne sera pas réalisée. Imaginons que la page d'accueil legacy possède un bloc changeant régulièrement d'apparence. Nous pouvons dire à Next.js de regénérer à intervalle régulier la page (regénération statique incrémentale) grâce à la propriété revalidate
de l'objet retourné par getStaticProps
:
export async function getStaticProps({ params: { legacy } }: PathsParams) {
// ...
const isHome = !legacy;
return {
props: {
html: content,
},
revalidate: isHome ? 1800 : false,
};
}
Dans le cas de la page d'accueil, Next.js regénérera la page toutes les 30 minutes.
Une autre possibilité est d'utiliser une balise ESI pour le bloc à contenu changeant : la page contenant la balise est générée statiquement et un reverse proxy comme Varnish se charge de remplacer la balise par le bloc, en le mettant à jour de manière régulière.
Ce bloc pourra ensuite être migré indépendamment en gardant la balise ESI grâce à l'utilisation de React ESI.
Si certaines pages ne peuvent pas être statiques, même pendant un temps très court, il sera possible de créer les pages correspondantes et d'utiliser la fonction getServerSideProps
pour que le rendu soit effectué à chaque requête.
Attention cependant : la plupart du temps il est possible de gérer le contenu côté navigateur, par exemple dans le cas d'un utilisateur connecté, étant donné que les problématiques de SEO sont inexistantes.
Maintenant que le routage est géré, nous pouvons passer à l'étape suivante : remplacer une partie d'une page legacy par un composant React. Pour cela, nous devons tout d'abord parser le code HTML reçu.
Parser le HTML de la legacy avec Cheerio
La bibliothèque que nous devons choisir pour parser le HTML doit être capable de s'exécuter en Node.js et nous souhaitons aussi qu'elle soit performante.
La bibliothèque jsdom est par exemple à proscrire : elle interprète beaucoup trop le HTML (CSS, JS) pour nos besoins.
Mon choix s'est porté sur cheerio : la librairie se veut être très rapide puisque, contrairement à jsdom, elle n'interprète aucun contenu du DOM. En « bonus » : l'API de cheerio se base sur celle de jQuery, peut-on faire plus familier ?
Admettons que sur https://legacy.mywebsite.com
, un header soit présent dans une balise <header>
. Nous voulons migrer seulement ce header pour le remplacer par un composant React plus moderne.
Nous allons découper le HTML de la façon suivante : le <head>
sera découpé en titre, métadonnées (<meta>
), ressources externes (<link>
) et scripts, le <body>
sera quant à lui en une seule partie, avec le <header>
en moins.
Notez que nous pourrions découper le body en blocs pour pouvoir le manipuler comme nous le souhaitons dans le JSX.
Voyons ce que cela donne :
import type { CheerioAPI } from 'cheerio';
// ...
export async function getStaticProps({ params: { legacy } }: PathsParams) {
// ...
const cheerio: CheerioAPI = require('cheerio');
const $ = cheerio.load(content);
const head = $('head').first();
const title = head.find('title').first().text();
const metas = head
.find('meta')
.map((_, { attribs: { name, 'http-equiv': httpEquiv, content } }) => ({
...(name && { name }),
...(httpEquiv && { httpEquiv }),
...(content && { content }),
}))
.toArray();
const links = head
.find('link')
.map((_, { attribs: { rel, href, hreflang: hrefLang, type, sizes, crossorigin: crossOrigin } }) => ({
...(rel && { rel }),
...(href && { href }),
...(hrefLang && { hrefLang }),
...(type && { type }),
...(sizes && { sizes }),
...(undefined !== crossOrigin && { crossOrigin }),
}))
.toArray();
const scripts = head
.find('script')
.map((_, { attribs: { id, src, type, defer, async, crossorigin: crossOrigin }, children }) => ({
...(id && { id }),
...(src && { src }),
...(type && { type }),
...(undefined !== defer && { defer: true }),
...(undefined !== async && { async: true }),
...(undefined !== crossOrigin && { crossOrigin }),
text: $(children).text(),
}))
.toArray();
head.remove();
$('header').remove();
return {
props: {
title,
metas,
links,
scripts,
html: $('body').html(),
},
revalidate: isHome ? 1800 : false,
};
}
Comme vous pouvez le constater, il y a un peu de plomberie à réaliser, pour deux raisons :
- Next.js a besoin de sérialiser le contenu retourné par
getStaticProps
en JSON : il n'est pas possible d'avoir de valeursundefined
. - React utilise des props en camelCase : nous devons convertir les attributs qui sont en kebab-case.
Utilisation du contenu parsé dans le composant de migration
Le plus difficile est fait ! Nous allons maintenant utiliser les différents contenus dans notre composant. Nous allons en particulier utiliser le composant Head
de Next.js et nous allons entourer le contenu du body dans un composant Layout
se chargeant d'ajouter un header moderne.
C'est parti :
import Head from 'next/head';
import Layout from 'components/Layout';
const Legacy = ({ title, metas, links, scripts, html }: Props) => (
<>
<Head>
<title>{title}</title>
{metas.map(({ name, httpEquiv, content }, index) => (
<meta
key={`meta_${index}`}
{...(name && { name })}
{...(httpEquiv && { httpEquiv })}
{...(content && { content })}
/>
))}
{links.map(({ rel, href, hrefLang, type, sizes, crossOrigin }, index) => (
<link
key={`link_${index}`}
{...(rel && { rel })}
{...(href && { href })}
{...(hrefLang && { hrefLang })}
{...(type && { type })}
{...(sizes && { sizes })}
{...(undefined !== crossOrigin && { crossOrigin })}
/>
))}
{scripts.map(
({ id, src, type, defer, async, crossOrigin, text }, index) => (
<script
key={`script_${index}`}
{...(id && { id })}
{...(src && { src })}
{...(type && { type })}
{...(undefined !== defer && { defer })}
{...(undefined !== async && { async })}
{...(undefined !== crossOrigin && { crossOrigin })}
dangerouslySetInnerHTML={{ __html: text }}
/>
)
)}
</Head>
<Layout>
<div dangerouslySetInnerHTML={{ __html: html }}></div>
</Layout>
</>
);
export default Legacy;
Encore une fois, un peu de plomberie, qui ressemble beaucoup à celle précédemment réalisée. Cette fois, elle est nécessaire à cause d'un fonctionnement interne du composant Head
de Next.js.
Celui-ci déduplique les balises en fonction des props, y compris celles qui sont undefined
(voir la fonction unique
du composant).
Une remarque importante également : étant donné que nous utilisons la prop spéciale dangerouslySetInnerHTML
, nous avons besoin d'une div
supplémentaire qui va entourer notre contenu HTML.
Ce changement pourra dans certains cas, casser le CSS de la legacy : il faudra donc adapter le CSS pour gérer ce changement.
Une RFC proposant un composant RawHTML
existe, mais n'a pas encore été adoptée.
Pour tester que tout fonctionne, vous pouvez exécuter npm run build
puis npm run start
. En allant sur https://les-tilleuls.coop/
, notre page legacy avec son nouveau header devrait être envoyée très rapidement par le serveur.
Avec ce code, nous venons de réaliser nos fondations pour notre migration micro-incrémentale ! Le reste de la migration se fera au fur et à mesure : nous découperons de plus en plus le body pour le remplacer par de nouveaux composants React.
Conclusion
Cette architecture, bien que non nécessairement liée à Next.js, est néanmoins très dépendante au moins de l'utilisation d'un framework SSR/SSG.
L'implémentation de celle-ci dans Next.js vous a permis je l'espère de comprendre un peu plus une utilisation avancée de ce framework de référence.
Un article à venir sur le blog rentrera dans le détail sur le fonctionnement de la génération des pages statiques, suivez-nous pour être informé de sa sortie !