27 novembre 2024
Comment mettre en place un monorepo avec Turborepo et Yarn ?
13 minutes de lecture
Dans l'écosystème JavaScript, il est courant de travailler avec plusieurs packages pour un projet, ou même plusieurs projets gravitant autour de plusieurs packages. C'est une situation que l'on retrouve régulièrement dans les projets open source, ou dans les entreprises ayant des équipes travaillant sur des projets différents. Cela peut vite devenir compliqué à gérer, surtout si vous avez plusieurs projets qui partagent des dépendances. Dans cet article, nous allons voir comment faciliter la gestion de vos packages avec un monorepo Turbo et les workspaces Yarn.
Histoire des monorepos
Monolithes
Historiquement, les cycles de vie des projets JavaScript étaient gérés en monolithes, avec un dépôt unique pour l'ensemble du projet. Cette approche a montré ses limites, notamment en termes de maintenabilité, de performances et de collaboration.
Google affirmait, il y a quelques années maintenant, utiliser des monolithes. Why Google Stores Billions of Lines of Code in a Single Repository
Multi-repos
S'en est suivi, tournant 2010, avec l'apparition de npm
: les multi-repos. Une approche qui consiste à séparer chaque package dans un dépôt dédié. Cela a permis de résoudre certains problèmes d'échelle, mais en a créé d'autres, notamment en termes de gestion des dépendances et de cohérence des versions. Chaque package devait attendre que ses dépendances soient publiées avant de pouvoir les utiliser. Poussée par le succès des architectures micro-services, cette approche est encore beaucoup utilisée aujourd'hui.
Monorepos
Dans les années 2015, les package managers ont commencé à intégrer nativement des fonctionnalités de workspaces, permettant de gérer plusieurs packages dans un même dépôt. C'est le cas de yarn
et de npm
entre autres, ce qui a permis de démocratiser les monorepos. C'est une approche très populaire auprès des mainteneurs de projets open-source, car elle permet de gérer plusieurs packages dans un même dépôt, tout en gardant une cohérence des versions et des dépendances. Le souci qui peut apparaître avec cette approche, c'est la lenteur des installations et des builds, surtout à grande échelle. On remarque rapidement des similitudes avec les deux approches précédentes, le côté centralisé des monolithes et l'autonomie des packages des multi-repos.
À partir de 2018, nous voyons apparaître des outils comme Nx
ou encore Turbo
, qui permettent de gérer les monorepos de manière plus efficace. Ces outils permettent de mettre en cache les dépendances partagées et l'orchestration de scripts (build, test, etc), d'améliorer les performances et de faciliter la gestion des monorepos. Nous reviendrons plus en détail sur Turbo dans la suite de cet article.
Pourquoi un monorepo ?
Vous l'aurez compris, chaque approche a ses avantages et ses inconvénients. Le choix d'un monorepo dépendra de votre contexte, de vos besoins et de vos contraintes. Voici quelques avantages à utiliser un monorepo :
- Collaboration améliorée : les développeurs peuvent partager du code entre les packages et travailler simultanément sur plusieurs packages.
- Gestion centralisée des dépendances : cela assure une cohérence dans les versions et facilite la gestion des dépendances partagées.
- Maintenance simplifiée : la maintenance des packages, la mise à jour des dépendances et la gestion des versions sont plus efficaces.
- Déploiement indépendant : chaque package peut être déployé séparément.
Mettre en place un monorepo avec Turbo et Yarn
Maintenant que nous avons vu les avantages d'un monorepo, nous allons voir comment en mettre un en place avec Turbo et Yarn. Au fur et à mesure de l'article, nous approfondirons les concepts clés de chaque outil et nous verrons comment les utiliser ensemble. L'idée est de pouvoir avoir une compréhension globale de l'architecture d'un monorepo et de pouvoir l'adapter à votre contexte.
Pour l'exemple nous mettrons en place un monorepo contenant une application React, une application Next.js, un package de composants React et une CLI (simple console.log
). La structure de notre projet est la suivante :
monorepo
├── apps
│ ├── app-react # @premieroctet/app-react
│ │ └── package.json
│ └── app-nextjs # @premieroctet/app-nextjs
│ └── package.json
├── packages
│ ├── components # @premieroctet/components
│ │ └── package.json
│ └── cli # @premieroctet/cli
│ └── package.json
├── package.json
└── yarn.lock
Le package manager Yarn
Dans la suite de cet article, nous utiliserons Yarn 2+ pour gérer les dépendances de notre monorepo. Pour vos monorepos, je vous recommande d'utiliser Yarn 2+ car il y a eu beaucoup d'améliorations par rapport à Yarn 1.x notamment autour des workspaces.
Initialisation du monorepo
Nous allons donc commencer par initialiser notre monorepo avec Yarn. Pour cela, nous allons créer un nouveau dossier et initialiser un nouveau projet Node.
mkdir monorepo
cd monorepo
yarn set version berry # Use last version of Yarn
yarn init -w
L'option -w
permet d'initialiser un workspace dans le fichier package.json
. Elle permet
également de définir le dossier packages
comme dossier par défaut pour les packages.
Dans le fichier package.json
créé, nous allons ajouter les workspaces pour définir les dossiers apps
et packages
comme workspaces.
{
"name": "monorepo",
"version": "1.0.0",
"private": true,
"workspaces": ["apps/*", "packages/*"],
"packageManager": "yarn@4.5.1"
}
Les workspaces
On crée donc les dossiers apps
et packages
(ce dernier étant déjà créé par Yarn) pour y stocker nos applications et nos packages.
Pour les applications React et Next.js, nous allons utiliser respectivement les commandes create-react-app
et create-next-app
pour les initialiser.
cd monorepo/apps
yarn create react-app app-react
yarn create next-app app-next
Pour les packages components
et cli
, nous allons créer un dossier pour chaque package et initialiser un nouveau projet Node.
cd monorepo/packages
mkdir components
mkdir cli
cd components
yarn init -y
cd ../cli
yarn init -y
Voilà toutes nos applications et packages sont initialisés. Nous allons, avant de poursuivre, nommer ces projets en les préfixant avec un nom de workspace (ici avec @premieroctet
) dans le fichier package.json
pour les distinguer de leurs noms dans l'architecture de fichiers et mieux les identifier.
Maintenant que nous avons renommé nos projets, nous allons faire un premier yarn install
à la racine de notre projet pour installer les dépendances de tous les packages. On constate que Yarn crée des liens symboliques des projets qui font partie du workspaces. Et que le node_modules
est à la racine du monorepo.
node_modules
à la racine
Pour réduire le nombre de dépendances installées, Yarn 2+ utilise un cache global pour stocker les dépendances partagées entre les packages. Cela permet de réduire le temps d'installation des dépendances et de réduire l'espace disque utilisé.
Gestion des conflits de dépendances
Vu qu'il n'y a qu'un node_modules
à la racine, comment gérer les conflits de dépendances entre
les packages ?
Si l'on reprend notre exemple, imaginons que l'application @premieroctet/app-react
utilise react@18
et que l'application @premieroctet/app-next
utilise react@19
. Alors Yarn va installer react@19
à la racine du monorepo et créer un node_modules
dans l'application @premieroctet/app-react
avec react@18
. Cela permet de gérer les conflits de dépendances entre les packages. Dans ce cas-là, c'est l'ordre topologique qui détermine la priorité des dépendances. Cependant, si un troisième package utilise react@18
, alors Yarn va installer react@18
à la racine du monorepo et créer un node_modules
dans le package @premieroctet/app-next
avec react@19
vu qu'il est "minoritaire".
Utiliser les composants dans les applications
Maintenant que nous avons initialisé nos applications et nos packages, nous allons voir comment les utiliser dans nos applications.
Commençons par ajouter des composants dans notre package @premieroctet/components
. Pour cela, nous allons créer un composant Button
dans le fichier packages/components/src/Button.js
.
import React from 'react'
const Button = ({ children }) => {
return <button>{children}</button>
}
export default Button
Puis définir un point d'entrée dans le fichier packages/components/src/index.js
.
export { default as Button } from './Button'
Initialiser typescript dans le package @premieroctet/components
afin de pouvoir utiliser les types dans les applications.
cd monorepo/packages/components
yarn add -D typescript
npx tsc --init
Ajouter les options du compilateur dans le fichier packages/components/tsconfig.json
.
{
"compilerOptions": {
"declaration": true,
"outDir": "dist",
...
}
}
Et enfin définir le point d'entrée dans le fichier packages/components/package.json
et ajouter un script pour générer le dossier dist
.
{
"name": "@premieroctet/components",
"version": "1.0.0",
"main": "dist/index.js",
"scripts": {
"dev": "tsc -w",
"build": "tsc"
}
...
}
cd monorepo/packages/components
yarn build
Nous pouvons désormais ajouter les dépendances de nos packages dans nos applications.
cd monorepo/apps/app-react
yarn add @premieroctet/components
Dans le fichier apps/app-react/package.json
on peut voir que Yarn a ajouté une dépendance à notre package @premieroctet/components
.
"@premieroctet/components": "workspace:^"
Le caractère générique permet de résoudre la version du package lors du yarn npm publish
ou
yarn pack
. Vous pouvez en utiliser d'autres en fonction de vos besoins, voir la
documentation.
On peut désormais importer les composants de notre package @premieroctet/components
dans notre application @premieroctet/app-react
.
Pour l'instant @premieroctet/components
ne possède pas de peerDependencies
, si c'était le cas il faudrait que l'application @premieroctet/app-react
les ajoute dans son package.json
ou quelle possède déjà les dépendances qui matchent les peerDependencies
de @premieroctet/components
.
On peut aussi utiliser les scripts de notre package @premieroctet/cli
dans nos applications. Pour cela, nous allons ajouter une commande cli
dans le fichier packages/cli/src/index.js
.
#!/usr/bin/env node
console.log('Hello from @premieroctet/cli')
Puis définir le point d'entrée dans le fichier packages/cli/package.json
et ajouter un script pour lancer la commande cli
.
{
"name": "@premieroctet/cli",
"version": "1.0.0",
"bin": {
"cli": "dist/index.js"
},
"scripts": {
"dev": "tsc -w",
"build": "tsc"
}
...
}
cd monorepo/packages/cli
yarn build
On peut désormais utiliser la commande cli
de notre package @premieroctet/cli
dans nos applications.
cd monorepo/apps/app-react
yarn @premieroctet/cli cli
Et même ajouter des scripts dans le package.json
de nos applications pour lancer les scripts de nos packages.
cd monorepo/apps/app-react
yarn add -D @premieroctet/cli
{
"scripts": {
"cli": "cli"
}
...
}
Les commandes yarn workspaces
Avec la commande yarn workspaces
, on peut exécuter des commandes dans tous les workspaces ou dans un workspace spécifique.
yarn workspaces foreach --all run build
yarn workspace @premieroctet/components run build
L'option -pt
permet de lancer les commandes après que ses dépendances aient elles-mêmes lancé la
commande définie.
Vous pouvez retrouver toutes les commandes disponibles dans la documentation de Yarn 2+.
Les limites des workspaces yarn
À ce stade de notre monorepo, nous avons utilisé seulement les workspaces de Yarn pour gérer nos packages. Cependant, il existe des limites à cette approche, la plus importante étant la performance des commandes comme yarn build
ou yarn test
qui sont exécutées pour chaque package. Cela peut vite devenir un problème si vous avez beaucoup de packages ou si les commandes sont longues à exécuter.
Une seconde limite est l'orchestration des commandes entre les packages. Par exemple, si vous avez une commande qui dépend d'une autre commande dans un autre package, il faudra gérer manuellement l'ordre d'exécution des commandes.
Nous allons donc voir comment Turbo peut nous aider à résoudre ces problèmes.
Turborepo
Installation
Commençons par installer Turbo dans notre monorepo à la racine du workspace. Et créer un fichier de configuration turbo.json
pour définir les tâches.
cd monorepo
yarn add -D turbo
{
"$schema": "https://turbo.build/schema.json",
"tasks": {}
}
Pour plus d'informations sur l'installation de Turbo, vous pouvez consulter la documentation.
Orchestration de tâches
Dans notre exemple nous pouvons considérer les tâches suivantes :
build
: pour builder tous les packages - tous les projetsdev
: pour lancer les applications en mode développement - app-react et app-nextdeploy
: pour déployer les applications - app-react et app-nextcli
: pour lancer le script du package@premieroctet/cli
- app-reactpublish
: pour la publication des packages - component et cli
L'idée est de pouvoir lancer les commandes sans avoir à se déplacer dans chaque projet et de gérer l'ordre d'exécution des commandes.
Dans le fichier de configuration turbo.json
, nous allons définir les tâches pour chaque commande.
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", "build/**", ".next/**", "!.next/cache/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"deploy": {
"dependsOn": ["^build"]
},
"@premieroctet/app-react#cli": {
"dependsOn": ["@premieroctet/cli#build"]
},
"publish": {
"dependsOn": ["^build"]
}
}
}
dependsOn
permet de définir les dépendances entre les tâches, ici on définit que la tâchebuild
doit être exécutée après les builds des dépendances.outputs
permet de définir les fichiers générés par la tâche, ces fichiers seront utilisés pour déterminer si la tâche doit être exécutée ou si le cache peut être utilisé.cache
permet de désactiver le cache pour la tâche.persistent
permet de garder la tâche en cours d'exécution.
Dans le cas de dépendances cycliques, il ne sera pas possible d'utiliser le dependsOn
pour définir l'ordre d'exécution des tâches.
Pour les tâches deploy
et publish
, qui ne sont pas définis dans l'ensemble des packages, Turbo n'exécutera que les tâches définies.
Dans le cas de la tâche cli
, on peut préciser le package sur lequel on veut exécuter la tâche. On définit aussi la dépendance avec la tâche build
du package @premieroctet/cli
.
Pour lancer les tâches, on utilise la commande turbo
suivi du nom de la tâche.
yarn turbo run build
Il est possible de préciser les packages sur lesquels on veut exécuter les tâches en utilisant le
flag --filter
ou en préfixant le nom de la tâche avec le nom du package (e.g.
@premieroctet/app-react#dev
). Très pratique lorsqu'on veut réduire le nombre de tâches à
exécuter dans les intégrations continues ou lorsqu'on veut tester une tâche sur un package
spécifique. Voir la
documentation.
Pour le confort des développeurs, on peut ajouter des scripts dans le package.json
pour lancer les tâches Turbo.
{
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"deploy": "turbo run deploy",
"cli": "turbo run @premieroctet/app-react#cli",
"publish": "turbo run publish"
}
...
}
Le cache de Turbo
Pour optimiser les temps d'exécution, Turbo s'appuie sur un système de cache pour ne pas reproduire les tâches déjà exécutées lorsque les outputs
n'ont pas changé. Cela permet de gagner en performances lors des builds et des tests. Le cache est stocké dans le dossier .turbo
à la racine du workspace.
Il est possible dans le cadre de gros monorepos de stocker le cache sur un serveur dédié pour réduire le temps de partage du cache entre les développeurs. Voir Remote Caching.
Conseils pour un monorepo réussi
Vous l'aurez compris, la gestion d'un monorepo peut vite devenir complexe si elle n'est pas bien maîtrisée. Voici quelques points à prendre en compte pour éviter les erreurs et faciliter la gestion de votre monorepo :
-
Éviter les dépendances cycliques : cela peut vite devenir un casse-tête à gérer, surtout si vous avez beaucoup de packages, et même bloquer vos tâches Turbo.
-
⚠️ Définir explicitement les dépendances : il est important de définir les dépendances de vos packages dans le
package.json
pour éviter les conflits de dépendances. Principalement si vous utilisez les monorepos pour publier des packages, commeyarn workspaces
centralise les dépendances, il est possible que des dépendances (explicitement définies dans unpackage-a
) soient présentes dans lenode_modules
et par conséquent utilisables dans un autre package (package-b
) sans être définies dans lepackage.json
depackage-b
. Vous n'aurez pas d'erreurs de compilation dans votre workspace, cependant si une personne utilisepackage-b
dans un autre projet, il y aura des erreurs de compilation car les dépendances seront manquantes. -
Aligner les versions des dépendances : pour réduire la taille de vos
node_modules
et éviter les conflits de dépendances, il est préférable dans la mesure du possible d'aligner les versions des dépendances entre vos packages. -
Utiliser les
peerDependencies
: si vous avez des dépendances partagées entre vos packages, il est préférable de les définir dans lespeerDependencies
pour éviter les conflits de dépendances. Dans notre exemple, une bonne pratique serait de définirreact
etreact-dom
dans lespeerDependencies
de@premieroctet/components
. On pourra ainsi avoir une seule version dereact
etreact-dom
pour toutes nos applications et un warning pendant l'installation si l'application ne possède pas les dépendances nécessaires. Attention cependant, lespeerDependencies
ne sont pas installées automatiquement, si votre workspace contient déjà une version de vospeerDependencies
c'est celle-ci qui sera utilisée. -
BONUS - Cloner une partie du monorepo : si vous avez un gros monorepo versionné et que vous voulez travailler sur une partie du monorepo, vous pouvez utiliser la commande
git clone --depth=1 <url>
pour cloner seulement le dernier commit de votre monorepo. Si vous voulez approfondir cette technique, je vous invite à lire l'article : Get up to speed with partial clone and shallow clone.
Conclusion
Vous l'aurez compris la fusion des monorepos Turbo et des workspaces Yarn est une solution très puissante pour gérer vos projets. Cela permet de garder une cohérence des versions et des dépendances, de faciliter la maintenance et le déploiement de vos packages. Cependant, il est important de bien maîtriser les outils que vous utilisez et de suivre les bonnes pratiques pour éviter les erreurs et faciliter la gestion de votre monorepo.
En conclusion si vous avez des projets qui partagent des dépendances, ou si vous avez des équipes qui travaillent sur des projets différents, je vous recommande d'utiliser un monorepo avec Turbo et les workspaces Yarn. Cela vous permettra de gagner en performances, en maintenabilité et en améliorera la collaboration.
👋