TypeScript tout-en-un : Monorepo avec ses douleurs et ses gains
Dans cet article, je ne comparerai pas monorepo et polyrepo car il s'agit de philosophie. Au lieu de cela, je me concentrerai sur l'expérience de construction et d'évolution et suppose que vous êtes familiarisé avec l'écosystème JS/TS.
Intro
J'ai toujours rêvé d'un monorepo.
J'ai vu l'approche monorepo en travaillant pour Airbnb, mais elle n'était destinée qu'au frontend. Avec un amour profond pour l'écosystème JavaScript et l'expérience de développement « heureuse » TypeScript, j'ai commencé à aligner le code frontend et backend dans le même langage il y a environ trois ans. C'était génial (pour l'embauche) mais pas si génial pour le développement puisque nos projets étaient encore répartis sur plusieurs repos.
Comme on dit, « la meilleure façon de refactoriser un projet est d'en démarrer un nouveau ». Donc, quand j'ai commencé ma start-up il y a environ un an, j'ai décidé d'utiliser une stratégie de monorepo totale : mettre les projets frontend et backend, même les schémas de base de données, dans un seul dépôt.
Dans cet article, je ne comparerai pas monorepo et polyrepo car il s'agit de philosophie. Au lieu de cela, je me concentrerai sur l'expérience de construction et d'évolution et suppose que vous êtes familier avec l'écosystème JS/TS.
Le résultat final est disponible sur GitHub.
Pourquoi TypeScript ?
Franchement, je suis un fan de JavaScript et TypeScript. J'aime la compatibilité de sa flexibilité et de sa rigueur : vous pouvez revenir à unknown
ou any
(bien que nous ayons banni toute forme de any
dans notre base de code), ou utiliser un ensemble de règles de lint super strict pour aligner le style de code à travers l'équipe.
Quand nous parlions du concept de « fullstack » avant, nous imaginions généralement au moins deux écosystèmes et langages de programmation : un pour le frontend et un pour le backend.
Un jour, j'ai soudainement réalisé que cela pourrait être plus simple : Node.js est assez rapide (croyez-moi, dans la plupart des cas, la qualité du code est plus importante que la vitesse d'exécution), TypeScript est assez mature (fonctionne bien dans les grands projets frontend), et le concept de monorepo a été pratiqué par de nombreuses équipes célèbres (React, Babel, etc.) - alors pourquoi ne pas combiner tout le code ensemble, du frontend au backend ? Cela peut permettre aux ingénieurs de faire le travail sans changer de contexte dans un seul repo et d'implémenter une fonctionnalité complète en (presque) un seul langage.
Choisir le gestionnaire de packages
En tant que développeur, et comme d'habitude, je ne pouvais pas attendre pour commencer à coder. Mais cette fois, les choses étaient différentes.
Le choix du gestionnaire de packages est crucial pour l'expérience de développement dans un monorepo.
La douleur de l'inertie
C'était en juillet 2021. J'ai commencé avec [email protected]
puisque je l'utilise depuis longtemps. Yarn était rapide, mais j'ai vite rencontré plusieurs problèmes avec Yarn Workspaces. Par exemple, ne pas hisser correctement les dépendances, et des tonnes de problèmes sont tagués avec « réglé dans moderne », ce qui me redirige vers la v2 (berry).
"D'accord je mets à jour maintenant." J'ai arrêté de lutter avec v1 et j'ai commencé à migrer. Mais le long guide de migration de berry m'a effrayé, et j'ai abandonné après plusieurs tentatives infructueuses.
Ça marche juste
Donc la recherche sur les gestionnaires de packages a commencé. J'ai été absorbé par pnpm
après un essai : aussi rapide que yarn, support natif de monorepo, commandes similaires à npm
, liens durs, etc. Le plus important, c'est que ça marche. En tant que développeur qui veut commencer avec un produit mais PAS développer un gestionnaire de packages, je voulais juste ajouter quelques dépendances et démarrer le projet sans savoir comment fonctionne un gestionnaire de packages ou tout autre concept fancy.
Sur la même idée, nous avons choisi un vieux compagnon lerna
pour exécuter les commandes entre les packages et publier les packages d'espace de travail.
Définir les portées des packages
Il est difficile de définir clairement la portée finale de chaque package au début. Commencez par votre meilleur essai selon le statu quo, et souvenez-vous que vous pouvez toujours refactoriser pendant le développement.
Notre structure initiale contient quatre packages :
core
: le service monolithique du backend.phrases
: clé i18n → ressources de phrases.schemas
: les schémas de base de données et les schémas TypeScript partagés.ui
: un SPA web qui interagit aveccore
.
Stack technique pour le fullstack
Comme nous embrassons l'écosystème JavaScript et utilisons TypeScript comme notre langage de programmation principal, beaucoup de choix sont évidents (selon ma préférence 😊) :
koajs
pour le service backend (core) : J'ai eu une expérience difficile en utilisantasync/await
dansexpress
, donc j'ai décidé d'utiliser quelque chose avec un support natif.i18next/react-i18next
pour i18n (phrases/ui) : j'aime sa simplicité d'APIs et son bon support TypeScript.react
pour SPA (ui) : Juste une préférence personnelle.
Et pour les schémas ?
Il manque encore quelque chose ici : système de base de données et mappage schema <-> Définition TypeScript.
Général vs. opinionné
A ce stade, j'ai essayé deux approches populaires :
- Utiliser ORM avec beaucoup de décorateurs.
- Utiliser un constructeur de requêtes comme Knex.js.
Mais les deux produisent une sensation étrange pendant le développement précédent :
- Pour ORM : Je ne suis pas un fan des décorateurs, et une autre couche d'abstraction de la base de données provoque plus d'efforts d'apprentissage et d'incertitude pour l'équipe.
- Pour le constructeur de requêtes : C'est comme écrire du SQL avec certaines restrictions (d'une bonne manière), mais ce n'est pas du vrai SQL. Ainsi, nous devons utiliser
.raw()
pour les requêtes brutes dans de nombreux scénarios.
Puis j'ai vu cet article : « Arrêtez d'utiliser Knex.js : Utiliser un constructeur de requêtes SQL est un anti-pattern ». Le titre semble agressif, mais le contenu est génial. Il me rappelle fortement que "SQL est un langage de programmation", et j'ai réalisé que je pouvais écrire du SQL directement (tout comme CSS, comment ai-je pu manquer ça !) pour utiliser les fonctionnalités du langage natif et de la base de données au lieu d'ajouter une autre couche et de réduire le pouvoir.
En conclusion, j'ai décidé de rester avec Postgres et Slonik (un client Postgres open-source), comme l'article le précise :
…le bénéfice de permettre à l'utilisateur de choisir entre les différents dialectes de base de données est marginal et le coût de développement pour plusieurs bases de données à la fois est significatif.
SQL <-> TypeScript
Un autre avantage de l'écriture SQL est que nous pouvons facilement l'utiliser comme seule source de vérité des définitions TypeScript. J'ai écrit un générateur de code pour transpiler les schémas SQL en code TypeScript que nous utiliserons dans notre backend, et le résultat semble pas mal :
Nous pouvons même connecter jsonb
avec un type TypeScript et traiter la validation de type dans le service backend si nécessaire.
Résultat
La structure de dépendance finale ressemble à :
Vous pouvez remarquer que c'est un diagramme à sens unique, qui nous a beaucoup aidé à maintenir une architecture claire et la capacité de s'élargir alors que le projet grandit. De plus, le code est (essentiellement) tout en TypeScript.
Expérience de développement
Partage de package et de config
Dépendances internes
pnpm
et lerna
font un excellent travail sur les dépendances internes de l'espace de travail. Nous utilisons la commande ci-dessous à la racine du projet pour ajouter des packages frères :
Il ajoutera @logto/schemas
comme dépendance à @logto/core
. Tout en gardant la version sémantique dans package.json
de vos dépendances internes, pnpm
peut également les lier correctement dans pnpm-lock.yaml
. Le résultat ressemblera à ceci :
Partage de config
Nous considérons chaque package dans monorepo « indépendant ». Ainsi, nous pouvons utiliser l'approche standard pour le partage de configuration, qui couvre tsconfig
, eslintConfig
, prettier
, stlyelint
, et jest-config
. Voir ce projet pour exemple.
Code, lint, et commit
J'utilise VSCode pour le développement quotidien, et en bref, rien n'est différent quand le projet est correctement configuré :
- ESLint et Stylelint fonctionnent normalement.
- Si vous utilisez le plugin ESLint de VSCode, ajoutez les paramètres VSCode ci-dessous pour qu'il respecte la config ESLint par package (remplacez la valeur de
pattern
par la vôtre) :
- Si vous utilisez le plugin ESLint de VSCode, ajoutez les paramètres VSCode ci-dessous pour qu'il respecte la config ESLint par package (remplacez la valeur de
- husky, commitlint, et lint-staged fonctionnent comme prévu.
Compilateur et proxy
Nous utilisons différents compilateurs pour le frontend et le backend : parceljs
pour UI (React) et tsc
pour tous les autres packages TypeScript purs. Je vous recommande vivement d'essayer parceljs
si vous ne l'avez pas encore fait. C'est un vrai compilateur « zéro-config » qui gère gracieusement différents types de fichiers.
Parcel héberge son propre serveur de développement frontend, et la sortie de production est juste des fichiers statiques. Comme nous aimerions monter des APIs et des SPA sous la même origine pour éviter les problèmes de CORS, la stratégie ci-dessous fonctionne :
- En environnement de développement, utilisez un simple proxy HTTP pour rediriger le trafic vers le serveur de développement Parcel.
- En production, servez directement les fichiers statiques.
Vous pouvez trouver l'implémentation de la fonction de middleware frontend ici.
Mode de surveillance
Nous avons un script dev
dans package.json
pour chaque package qui surveille les changements de fichier et recompile lorsque c'est nécessaire. Grâce à lerna
, les choses deviennent faciles en utilisant lerna exec
pour exécuter les scripts de packages en parallèle. Le script racine ressemblera à ceci :
Résumé
Idéalement, seulement deux étapes pour un nouvel ingénieur/contributeur pour commencer :
- Cloner le dépôt
pnpm i && pnpm dev
Notes de clôture
Notre équipe a développé selon cette approche pendant un an, et nous en sommes plutôt contents. Visitez notre dépôt GitHub pour voir la dernière forme du projet. Pour résumer :
Douleurs
- Besoin de se familiariser avec l'écosystème JS/TS
- Besoin de choisir le bon gestionnaire de packages
- Nécessite une configuration supplémentaire à usage unique
Gains
- Développer et maintenir l'ensemble du projet dans un seul dépôt
- Simplification des compétences en codage requises
- Styles de code, schémas, phrases, et utilitaires partagés
- Amélioration de l'efficacité de la communication
- Plus de questions comme : Quelle est la définition de l'API ?
- Tous les ingénieurs parlent le même langage de programmation
- CI/CD avec facilité
- Utilisez la même chaîne d'outils pour la construction, les tests, et la publication
Cet article laisse plusieurs sujets non couverts : Configuration du dépôt à partir de zéro, ajout d'un nouveau package, utilisation des actions GitHub pour le CI/CD, etc. Ce serait trop long pour cet article si j'élargissais chacun d'entre eux. N'hésitez pas à commenter et à me faire savoir quel sujet vous aimeriez voir à l'avenir.