paint-brush
Monorepository dans TypeScript : l'histoire de la façon dont nous avons tout cassé et améliorépar@devfamily
1,788 lectures
1,788 lectures

Monorepository dans TypeScript : l'histoire de la façon dont nous avons tout cassé et amélioré

par dev.family13m2023/05/05
Read on Terminal Reader

Trop long; Pour lire

dev.family travaille sur un projet intéressant depuis près de six mois et continue toujours. Nous avons démarré le projet en tant que programme de crypto-fidélisation qui offre aux utilisateurs finaux des récompenses pour certaines actions, et les clients reçoivent des analyses sur ces mêmes utilisateurs. Tout est écrit dans un seul langage - TypeScript. Nous avons 8 référentiels, où certains doivent communiquer entre eux.
featured image - Monorepository dans TypeScript : l'histoire de la façon dont nous avons tout cassé et amélioré
dev.family HackerNoon profile picture
0-item
1-item
2-item

Bonjour à tous, dev.family est en contact. Nous aimerions vous parler d'un projet intéressant sur lequel nous travaillons depuis près de six mois et que nous poursuivons toujours. Pendant ce temps, il s'est passé beaucoup de choses, beaucoup de choses ont changé. Nous avons découvert quelque chose d'intéressant pour nous-mêmes, réussi à combler les bosses.


Quelle sera notre histoire ?

  • Qu'avons-nous fait
  • Comment nous avons commencé
  • Où cela nous a-t-il mené
  • Quels problèmes avons-nous rencontrés
  • Pourquoi monorepo
  • Pourquoi PNPM
  • Pourquoi TS Comment ça marche maintenant
  • Combien nous avons rendu nos vies plus faciles

Un peu sur le projet

Alors, sur quoi travaillons-nous encore ? En fait, cette question est devenue à un moment donné très pertinente, comme elle l'était, par exemple, pour le propriétaire de la société McDonalds à un moment donné. Nous avons démarré le projet en tant que programme de crypto-fidélisation qui offre aux utilisateurs finaux des récompenses pour certaines actions, et les clients reçoivent des analyses sur ces mêmes utilisateurs. Oui, c'est assez superficiel, mais ce n'est pas grave.


Début des travaux

Il a fallu développer des modules Shopify pour se connecter aux boutiques Shopify, un portail pour les marques, une extension pour Google Chrome, une application mobile + un serveur avec une base de données (enfin, nulle part sans eux). En général, avec ce dont nous avons besoin, nous avons décidé et commencé à travailler. Le projet étant immédiatement supposé de grande envergure, tout le monde a compris qu'il pouvait pousser comme des haricots magiques à retardement.



Il a été décidé de tout faire "correctement" et "selon toutes les normes". Autrement dit, tout est écrit dans un seul langage - TypeScript. Pour que tout le monde écrive de la même manière, et qu'il n'y ait pas de modifications inutiles dans les fichiers, les linters (beaucoup de linters), pour que tout soit "facile" à réutiliser, mettez TOUT dans des modules séparés, et pour qu'ils ne volent pas sous le jeton d'accès Github.

Alors on a commencé :

  • Référentiel pour les linters et ts config séparé (guide de style)

  • Référentiel pour une application mobile (react native) et une extension Chrome (react.js) (ensemble, puisqu'ils reprennent la même fonctionnalité, ne s'adressant qu'à des utilisateurs différents)

  • Un autre dépôt pour le portail

  • Deux référentiels pour les modules Shopify

  • Référentiel pour les éléments de blockchain Référentiel d'API (express.js) Référentiel pour l'infrastructure


Un exemple de nos dépôts à l'époque


Huh ... pense que j'ai tout énuméré. Cela s'est avéré un peu trop, mais d'accord, continuons à rouler. Oh oui, pourquoi deux référentiels ont-ils été alloués aux modules Shopify ? Parce que le premier référentiel est les modules d'interface utilisateur. Il y a toute la beauté de nos bébés et de leurs réglages. Et le second est les intégrations-Shopify. Il s'agit en fait de son implémentation dans Shopify lui-même avec tous les fichiers liquides. Au total, nous avons 8 dépôts, où certains devraient communiquer entre eux.


Puisque nous parlons de développement en TypeScript, nous avons également besoin de gestionnaires de packages pour installer des modules, des bibliothèques. Mais nous travaillions tous de manière indépendante dans nos référentiels, et peu importe ce qu'il fallait utiliser. Par exemple, en développant une application mobile sur React Native, je n'ai pas réfléchi trop longtemps et j'ai gardé YARN1. Quelqu'un peut être plus habitué à utiliser le bon vieux NPM, tandis que d'autres aiment tout ce qui est nouveau et utilisent le nouveau YARN3. Ainsi, quelque part il y avait NPM, quelque part YARN1 et quelque part YARN3.


Nous avons donc tous commencé à faire nos candidatures. Et presque immédiatement, le plaisir a commencé, mais pas si complet. Premièrement, certains ne pensaient pas à quoi servait TypeScript et utilisaient "Any" partout où ils étaient trop paresseux, ou là où ils "ne comprenaient pas" comment ils ne pouvaient pas l'écrire. Quelqu'un n'a pas réalisé toute la puissance de TypeScript et le fait que, dans certains endroits, tout peut être rendu beaucoup plus facile. Par conséquent, les types sont sortis des dimensions cosmiques. Oui, j'ai oublié de le dire, nous avons décidé d'utiliser Hasura GraphQL comme base de données. La saisie manuelle de toutes les réponses ressemblait parfois à autre chose. Et dans un cas, certains ont même écrit en bon vieux Javascript. Oui, la situation s'est avérée cool: le premier gars a encore une fois mis "Any" pour ne pas trop forcer, le second écrit des toiles de types de ses propres mains, et le troisième n'écrit toujours pas de types du tout.



Plus tard, il s'avère que dans les cas où nous avons répété la logique, et, dans le bon sens, elle aurait dû être retirée dans un paquet séparé - personne n'allait le faire. Tout le monde écrit et écrit du code pour lui-même, pour tout le reste - cracher d'un haut clocher.

Où cela nous a-t-il mené ?

Qu'avons-nous ? Nous avons 8 référentiels avec différentes applications. Certains sont nécessaires partout, d'autres communiquent entre eux. Par conséquent, nous créons tous des fichiers .NPMrc, prescrivons des crédits, créons un jeton github, puis via le package manager-module. En général un léger embêtement, bien que désagréable, mais rien d'anormal.


Uniquement dans le cas de la mise à jour de quelque chose dans le package, vous devez mettre à jour sa version, puis la télécharger, puis la mettre à jour dans votre application/module, et alors seulement vous verrez ce qui a changé. Mais c'est totalement inapproprié ! Surtout si vous pouvez simplement changer la couleur quelque part. De plus, une partie du code est répétée et non réutilisée, mais simplement réécrite discrètement. Si nous parlons d'une application mobile et d'une extension de navigateur, le magasin redux et tout le travail avec l'API y sont complètement répétés, quelque chose est juste complètement réécrit ou légèrement modifié.


Au total, ce qu'il nous reste : un tas de dépôts, un lancement d'applications/modules assez long, beaucoup de choses identiques écrites par les mêmes personnes, beaucoup de temps passé à tester et introduire de nouvelles personnes dans le projet, et d'autres problèmes découlant de ce qui précède.



En bref, cela nous a amenés au fait que les tâches ont été effectuées pendant très longtemps. Bien sûr, cela a conduit à des délais non respectés, il était assez difficile d'introduire quelqu'un de nouveau dans le projet, ce qui a encore une fois affecté la vitesse de développement. Tout allait être assez morne et long, dans certains cas, grâce à webpack pour cela.


Puis il est devenu clair que nous nous éloignions loin de là où nous nous efforcions, mais qui sait où. Après avoir analysé toutes les erreurs, nous avons pris un certain nombre de décisions, qui seront discutées maintenant.

Pourquoi monorepo ?

Probablement, la chose la plus importante qui a beaucoup influencé à l'avenir a été la prise de conscience que nous ne construisons pas une application spécifique, mais une plate-forme. Nous avons plusieurs types d'utilisateurs, il existe différentes applications pour eux, mais ils fonctionnent au sein de la même plate-forme. Nous avons donc immédiatement clos le problème avec un grand nombre de référentiels : si nous travaillons sur une plate-forme, pourquoi la diviser en référentiels alors qu'il est plus facile de travailler sur un seul.


Je tiens à dire que travailler dans un monorepo nous a beaucoup facilité la vie. Certaines applications ou modules avaient une relation directe entre eux, et maintenant vous pouvez travailler dessus sereinement sur la même branche dans le même référentiel. Mais c'est loin d'être le principal avantage.


Nous allons continuer. Nous avons tout déplacé dans un seul référentiel. Cool! Nous avons continué à travailler au même rythme jusqu'à la réutilisation. En fait, c'est une « règle de bon goût » que nous avons dans notre travail. Réalisant que dans certains endroits, nous utilisons les mêmes algorithmes, fonctions, code et, dans certains endroits, des packages séparés que nous avons installés via github, nous avons décidé que tout cela "ne sentait pas très bon" et avons commencé à tout mettre dans des packages séparés au sein d'un monorepo à l'aide des espaces de travail.


Les espaces de travail (espaces de travail) sont des ensembles de fonctions dans la cli NPM, avec lesquels vous pouvez gérer plusieurs packages à partir d'un seul package racine de niveau supérieur.


En fait, ce sont des packages au sein d'un package qui sont liés via un gestionnaire de packages spécifique (tout YARN / NPM / PNPM), puis utilisés dans un autre package. A vrai dire, nous n'avons pas tout réécrit immédiatement sur les espaces de travail, mais nous l'avons fait au besoin.

Voici à quoi ça ressemble :

A partir d'un seul fichier


{ "type": "module", "name": "package-name-1", ... "types": "./src/index.ts", "exports": { ".": "./src/index.ts" }, },


Vers un autre fichier


{ "type": "module", "name": "package-name-2", ... "dependencies": { "package-name-1": "workspace:*", }, },


Un exemple utilisant PNPM


Rien de compliqué, en fait si vous y réfléchissez : écrivez quelques commandes et lignes, puis utilisez ce que vous voulez et où vous voulez. Mais "il y a une mise en garde, camarades". Plus tôt, j'ai écrit que tout le monde utilisait le gestionnaire de paquets qu'il voulait. Bref, nous avons un référentiel avec différents gestionnaires. À certains endroits, c'était drôle quand quelqu'un écrivait qu'il ne pouvait pas lier tel ou tel paquet, ayant à l'esprit le fait qu'il utilise NPM, et il y a YARN.

J'ajouterai que le problème n'était pas dû à différents gestionnaires, mais parce que les gens utilisaient les mauvaises commandes ou configuraient quelque chose de mal. Par exemple, certaines personnes via YARN 3 ont simplement créé un lien YARN et c'est tout, mais pour YARN 1, cela n'a pas fonctionné comme elles le souhaitaient en raison du manque de rétrocompatibilité.

Après être passé au monorepo


Pourquoi le PNPM ?

À ce stade, il est devenu clair qu'il est préférable d'utiliser le même gestionnaire de packages. Mais vous devez choisir laquelle, donc à ce moment-là, nous n'avons envisagé que 2 options - YARN et PNPM . Nous avons tout de suite abandonné NPM, car il était plus lent que les autres et plus laid. Il y avait un choix entre PNPM et YARN.


YARN a d'abord bien fonctionné - c'était plus rapide, plus simple et plus compréhensible, c'est pourquoi tout le monde l'utilisait alors. Mais la personne qui a créé YARN a quitté Facebook et le développement des versions suivantes a été transféré à d'autres. C'est ainsi que YARN 2 et YARN 3 sont apparus sans rétrocompatibilité avec le premier. De plus, en plus du fichier yarn.lock, ils génèrent un dossier yarn, qui pèse parfois comme node_modules et stocke les caches en lui-même.


Par conséquent, nous, comme de nombreux autres développeurs, avons tourné notre attention vers PNPM. Il s'est avéré aussi pratique que le premier YARN à son époque. Les espaces de travail peuvent être facilement utilisés ici, certaines commandes ont le même aspect que dans le premier YARN. De plus, honteusement-hoist s'est avéré être une option supplémentaire intéressante - il est plus pratique d'installer node_modules partout à la fois que d'aller dans un dossier à chaque fois et d'installer PNPM.


Turborepo et réutilisation du code

De plus, nous avons décidé d'essayer turborepo. Turborepo est un outil CI/CD qui possède son propre ensemble d'options, cli et configuration via le fichier turbo.json. Installé et configuré aussi facilement que possible. Nous avons mis une copie globale du turbo cli à travers


PNPM add turbo --global.


Ajouter turbo.json au projet


turbo.json


{ "$schema": "https://turbo.build/schema.json", "pipeline": { "build": { "dependsOn": ["^build"] } } }


Après cela, nous pouvons utiliser toutes les fonctions disponibles de turborepo. Nous avons été plus attirés par ses fonctionnalités et la possibilité de l'utiliser dans un monorepo.

Ce qui nous a accroché :

  • Constructions incrémentielles (constructions incrémentielles - la collecte des constructions est assez pénible, Turborepo se souviendra de ce qui a été construit et ignorera ce qui a déjà été calculé) ;

  • Hachage sensible au contenu (hachage sensible au contenu - Turborepo examine le contenu des fichiers, et non les horodatages, pour déterminer ce qui doit être construit) ;

  • Mise en cache à distance (hachage à distance - partagez un cache de construction à distance avec l'équipe et CI/CD pour des constructions encore plus rapides.);

  • Pipelines de tâches (un pipeline de tâches qui définit les relations entre les tâches, puis optimise quoi et quand créer.).

  • Exécution parallèle (Effectue des builds en utilisant chaque cœur avec un parallélisme maximal, sans gaspiller de CPU inactifs).


Nous avons également pris la recommandation d'organiser un monorepo de la documentation et l'avons implémentée dans notre plateforme. Autrement dit, nous divisons tous nos packages en applications et packages. Pour ce faire, nous créons également le fichier PNPM-workspace.yaml et écrivons :


PNPM-workspace.yaml

packages:

'apps/**/*'

'packages/**/*'


Ici vous pouvez voir un exemple de notre structure avant et après :



Nous avons maintenant un monorep avec des espaces de travail personnalisés et une réutilisation pratique du code. J'ajouterai quelques points supplémentaires que nous avons fait en parallèle. J'ai mentionné deux choses plus tôt : nous avions une extension chrome, et nous avons décidé de créer une plate-forme.


Étant donné que notre plateforme fonctionnait en priorité avec Shopify, nous avons décidé qu'au lieu d'une extension pour Chrome ou en plus de celle-ci, il serait bien de faire un autre module pour Shopify, qui peut être simplement installé sur le site, afin de ne pas une seule fois forcer à nouveau les gens à télécharger une application mobile ou une extension Chrome. Mais il doit complètement répéter l'extension. Au départ, nous les faisions en parallèle, mais nous nous sommes rendu compte que nous faisions quelque chose de mal, car nous avons simplement dupliqué le code. Dans tous les sens, nous écrivons la même chose à différents endroits. Mais comme nous avons maintenant tous les espaces de travail et la réutilisation configurés, nous avons facilement tout déplacé dans un seul package, que nous avons appelé dans le module Shopify et l'extension Chrome. Ainsi, nous nous sommes fait gagner beaucoup de temps.


Maintenant, ceci et index.html sont l'ensemble de l'extension Chrome



La deuxième chose qui nous a fait gagner beaucoup de temps a été l'élimination du webpack et, à certains endroits, des builds en général. Quel est le problème avec Webpack ? En fait, il y a deux points critiques : la complexité et la rapidité. Ce que nous avons choisi est vite. Pourquoi? Il est plus facile à configurer, il gagne rapidement en popularité et possède déjà un grand nombre de plugins fonctionnels, et un exemple des docks suffit pour l'installation. En comparaison, la construction sur le webpack de notre extension web Chrome a pris environ 15 secondes, sur vite.js



environ 7 secondes (avec génération de fichier dts).



Sentir la différence. Qu'en est-il du rejet des builds ? Tout est simple, il s'est avéré que nous n'en avions pas vraiment besoin, car ce sont des modules réutilisables et dans package.json, dans les exportations, vous pouvez simplement remplacer dist/index.js par src/index.ts.


Comment c'était


{... "exports": { "import": "./dist/built-index.js" }, ... }


Comment c'est maintenant


{ ... "types": "./src/index.ts", "exports": { ".": "./src/index.ts" }, ... }


Ainsi, nous nous sommes débarrassés de la nécessité d'exécuter PNPM watch pour suivre les mises à jour d'application liées à ces modules, et de créer PNPM pour extraire les mises à jour. Je ne pense pas que cela vaille la peine d'expliquer combien de temps cela nous a fait gagner.

En fait, l'une des raisons pour lesquelles nous avons collecté des builds était TypeScript, plus précisément les fichiers index.d.ts. Pour que lors de l'import de nos modules/packages, nous sachions quels types sont attendus dans certaines fonctions ou quels types d'autres vont nous retourner, comme ici :


Tous les paramètres attendus sont immédiatement visibles


Mais étant donné que vous pouvez simplement exporter depuis index.tsx, il y avait une autre raison d'abandonner les builds.

TypeScript + GraphQL

Mais encore, pourquoi TypeScript ? Je pense que cela n'a aucun sens maintenant de décrire tous les avantages de TS : la sécurité des types, la facilitation du processus de développement grâce au typage, la présence d'interfaces et de classes, le code open source, les erreurs commises lors de la modification du code sont visibles immédiatement, et non à l'exécution , et ainsi de suite.


Comme je l'ai dit au tout début, nous avons décidé de tout écrire dans une seule langue afin que si quelqu'un cesse de travailler ou parte, nous puissions soutenir ou assurer. Nous avons d'abord choisi JS. Mais JS n'est pas très sécurisé, et sans tests sur de gros projets c'est assez pénible. Par conséquent, nous avons opté pour TS. Comme l'a montré la pratique, c'est très pratique dans monorepo, car vous pouvez simplement exporter des fichiers *.ts, et lors de l'utilisation de composants, les données attendues et leurs types sont immédiatement clairs.


Mais l'une des principales fonctionnalités utiles était la génération automatique de types pour les requêtes et les mutations GraphQl. Pour tous ceux qui ne sont pas très connaisseurs, GraphQl est une technologie qui vous permet d'accéder à la base de données via la même requête (pour obtenir des données) et mutation (pour modifier des données), et ressemble à ceci :


query getShop {shop { shopName shopLocation } }


Contrairement à l'API REST, où jusqu'à ce que vous la receviez, vous ne saurez pas ce qui vous arrivera, ici vous déterminez vous-même les données dont vous avez besoin.


Revenons à notre président élu. Nous avons utilisé Hasura, qui est un wrapper GraphQL au-dessus de PostgreSQL. Puisque nous travaillons avec TS, nous devons dans le bon sens saisir les données des requêtes et celles que nous envoyons à la charge utile. Si nous parlons du code de l'exemple ci-dessus, il ne devrait y avoir aucun problème, en quelque sorte. Mais en pratique, une requête peut atteindre une centaine de lignes, plus certains champs peuvent venir ou non, ou avoir des types de données différents. Et dactylographier de telles toiles est une tâche très longue et ingrate.


Alternative? Bien sûr que j'ai! Laissez les types être générés via des commandes. Dans notre projet, nous avons fait ce qui suit :


  • Nous avons utilisé les librairies suivantes : graphql et graphql-request

  • Tout d'abord, des fichiers avec une résolution *.graphql ont été créés, dans lesquels des requêtes et des mutations ont été écrites.


    Par exemple:


test.graphql


query getAllShops {test_shops { identifier name location owner_id url domain type owner { name owner_id } } }


  • Ensuite, nous avons créé codegen.yaml


codegen.yaml


schema: ${HASURA_URL}:headers: x-hasura-admin-secret: ${HASURA_SECRET}

emitLegacyCommonJSImports: false

config: gqlImport: graphql-tag#gql scalars: numeric: string uuid: string bigint: string timestamptz: string smallint: number

generates: src/infrastructure/api/graphQl/operations.ts: documents: 'src/**/*.graphql' plugins: - TypeScript - TypeScript-operations - TypeScript-graphql-request


Là, nous avons indiqué où nous allions, et à la fin - où nous enregistrons le fichier avec l'API générée (src/infrastructure/api/graphQl/operations.ts) et d'où nous recevons nos requêtes (src/**/*. graphql).


Après cela, un script a été ajouté à package.json qui a généré les mêmes types pour nous :


package.json


{... "scripts": { "generate": "HASURA_URL=http://localhost:9696/v1/graphql HASURA_SECRET=secret graphql-codegen-esm --config codegen.yml", ... }, ... }


Ils indiquaient l'URL à laquelle le script avait accédé pour obtenir des informations, le secret et la commande elle-même.


  • Enfin, nous créons le client :


import { GraphQLClient } from "graphql-request"; import { getSdk } from "./operations.js"; export const createGraphQlClient = ({ getToken }: CreateGraphQlClient) => { const graphQLClient = new GraphQLClient('your url goes here...'); return getSdk(graphQLClient); };


Ainsi, nous obtenons une fonction qui génère un client avec toutes les requêtes et mutations. Le bonus dans operations.ts pose tous nos types que nous pouvons exporter et utiliser, et il y a un typage complet de toute la requête : on sait ce qu'il faut donner et ce qui va venir. Vous n'avez pas besoin de penser à autre chose, sauf pour exécuter la commande et profiter de la beauté de la frappe.

Conclusion

Ainsi, nous nous sommes débarrassés d'un grand nombre de référentiels inutiles et de la nécessité de pousser constamment les moindres changements afin de vérifier comment les choses fonctionnent. Au lieu de cela, ils en ont proposé un dans lequel tout est structuré, décomposé selon son objectif, et tout est facilement réutilisé. Nous nous sommes donc simplifié la vie et avons réduit le temps pour introduire de nouvelles personnes au projet, pour lancer la plateforme et les modules/applications séparément. Tout a été tapé, et maintenant plus besoin d'aller dans chaque dossier et de voir ce que veut telle ou telle fonction/composant. En conséquence, le temps de développement a été réduit.



En conclusion, je tiens à dire qu'il ne faut jamais être pressé. Il vaut mieux comprendre ce que vous faites et comment le faire plus facilement que de vous compliquer délibérément la vie. Les problèmes sont partout et toujours, tôt ou tard, ils sortiront quelque part, puis une complication délibérée vous tirera dans le genou, mais n'aidera en aucune façon.

L'équipe dev.family était avec vous, à bientôt !