Nederlands
  • typescript
  • monorepo

TypeScript alles-in-één: Monorepo met zijn pijnen en winsten

In dit artikel zal ik geen vergelijking maken tussen monorepo en polyrepo, aangezien het draait om filosofie. In plaats daarvan zal ik me concentreren op de bouw- en ontwikkelervaring en ga ik ervan uit dat je bekend bent met het JS/TS-ecosysteem.

Gao
Gao
Founder

Intro

Ik had altijd een droom van monorepo.

Ik zag de monorepo-aanpak terwijl ik voor Airbnb werkte, maar het was alleen voor de frontend. Met een diepe liefde voor het JavaScript-ecosysteem en de "gelukkige" TypeScript-ontwikkelingservaring, begon ik ongeveer drie jaar geleden front-end- en back-endcode op elkaar af te stemmen in dezelfde taal. Het was geweldig (voor het aannemen van personeel), maar niet zo geweldig voor de ontwikkeling, aangezien onze projecten nog steeds verspreid waren over meerdere repo's.

Zoals het gezegde luidt: "de beste manier om een project te refactoren is om een nieuw project te starten". Dus toen ik ongeveer een jaar geleden mijn startup begon, besloot ik een totale monorepo-strategie te gebruiken: frontend- en backendprojecten, zelfs databaseschema’s, in één repo plaatsen.

In dit artikel zal ik geen vergelijking maken tussen monorepo en polyrepo, aangezien het draait om filosofie. In plaats daarvan zal ik me concentreren op de bouw- en ontwikkelervaring en ga ik ervan uit dat je bekend bent met het JS/TS-ecosysteem.

Het eindresultaat is beschikbaar op GitHub.

Waarom TypeScript?

Eerlijk gezegd ben ik een fan van JavaScript en TypeScript. Ik hou van de compatibiliteit van zijn flexibiliteit en strengheid: je kunt terugvallen op unknown of any (hoewel we elke vorm van any in onze codebase verboden hebben), of een superstrikte lintregelset gebruiken om de codestijl binnen het team op elkaar af te stemmen.

Toen we het eerder hadden over het concept van "fullstack", stelden we ons meestal ten minste twee ecosystemen en programmeertalen voor: één voor frontend en één voor backend.

Op een dag realiseerde ik me ineens dat het eenvoudiger kon: Node.js is snel genoeg (geloof me, meestal is codekwaliteit belangrijker dan uitvoersnelheid), TypeScript is volwassen genoeg (werkt goed in grote frontendprojecten), en het monorepo-concept is door een aantal beroemde teams in de praktijk gebracht (React, Babel, enz.) - dus waarom zouden we niet alle code samenvoegen, van frontend tot backend? Dit kan ingenieurs de taken laten uitvoeren zonder contextwisseling in één repo en een complete functie in (bijna) één taal implementeren.

Keuze van pakketbeheerder

Als ontwikkelaar, zoals gewoonlijk, kon ik niet wachten om te beginnen met coderen. Maar dit keer was het anders.

De keuze van de pakketbeheerder is cruciaal voor de ontwikkelervaring in een monorepo.

De pijn van traagheid

Het was juli 2021. Ik begon met [email protected] omdat ik het al lange tijd gebruik. Yarn was snel, maar al snel stuitte ik op verschillende problemen met Yarn Workspaces. Bijvoorbeeld, het niet correct hosten van dependencies, en talloze problemen zijn gemarkeerd met "fixed in modern", wat mij doorverwees naar v2 (berry).

"Oke, prima, ik ben nu aan het upgraden." Ik stopte met worstelen met v1 en begon te migreren. Maar de lange migratiehandleiding van berry maakte me bang en ik gaf het op na verschillende mislukte pogingen.

Het werkt gewoon

Dus begon het onderzoek naar pakketbeheerders. Ik raakte gefascineerd door pnpm na een proefversie: snel als yarn, native monorepo-ondersteuning, vergelijkbare commando's als npm, harde koppelingen, enz. Het belangrijkste is dat het gewoon werkt. Als ontwikkelaar die wil beginnen met een product maar GEEN pakketbeheerder wil ontwikkelen, wilde ik gewoon enkele dependencies toevoegen en het project starten zonder te weten hoe een pakketbeheerder werkt of andere ingewikkelde concepten.

Gebaseerd op dezelfde gedachte, kozen we voor een oude vriend lerna om commando's uit te voeren over de pakketten en werkruimtepakketten te publiceren.

Definiëren van pakketscopes

Het is moeilijk om in het begin de definitieve scope van elk pakket duidelijk in kaart te brengen. Begin gewoon met je beste poging volgens de huidige situatie en onthoud dat je altijd kunt refactoreren tijdens de ontwikkeling.

Onze initiële structuur bevat vier pakketten:

  • core: de backend-monolietservice.
  • phrases: i18n sleutels → zinbronnen.
  • schemas: de database en gedeelde TypeScript-schema's.
  • ui: een web-SPA die met core communiceert.

Tech stack voor fullstack

Aangezien we het JavaScript-ecosysteem omarmen en TypeScript gebruiken als onze belangrijkste programmeertaal, zijn veel keuzes eenvoudig (gebaseerd op mijn voorkeur 😊):

  • koajs voor de backend-service (core): Ik had een moeilijke ervaring met het gebruik van async/await in express, dus besloot ik iets te gebruiken met native ondersteuning.
  • i18next/react-i18next voor i18n (phrases/ui): ik hou van de eenvoud van de API's en de goede TypeScript-ondersteuning.
  • react voor SPA (ui): Gewoon persoonlijke voorkeur.

Hoe zit het met schema's?

Er ontbreekt hier nog iets: databasesysteem en schema ⟷ TypeScript-definitiemapping.

Algemeen vs. bevooroordeeld

Op dat moment probeerde ik twee populaire benaderingen:

  • Gebruik ORM met veel decorators.
  • Gebruik een querybouwer zoals Knex.js.

Maar ze gaven beide een vreemd gevoel tijdens eerdere ontwikkelingen:

  • Voor ORM: Ik ben geen fan van decorators, en een andere abstractielaag van de database veroorzaakt meer inspanning om te leren en onzekerheid voor het team.
  • Voor querybuilder: Het is als het schrijven van SQL met enkele beperkingen (op een goede manier), maar het is geen echte SQL. Dus moeten we .raw() gebruiken voor rauwe queries in veel scenario's.

Toen zag ik dit artikel: "Stop using Knex.js: Using SQL query builder is an anti-pattern". De titel lijkt agressief, maar de inhoud is geweldig. Het herinnert me er sterk aan dat "SQL een programmeertaal is", en ik realiseerde me dat ik direct SQL kon schrijven (net zoals CSS, hoe kon ik dit missen!) om van de native taal- en databasefuncties gebruik te maken in plaats van een andere laag toe te voegen en de kracht te verminderen.

Kortom, ik besloot vast te houden aan Postgres en Slonik (een open-source Postgres-client), zoals het artikel vermeldt:

... het voordeel van het toestaan dat de gebruiker kan kiezen tussen verschillende databasedialecten is minimaal en de overhead van het ontwikkelen voor meerdere databases tegelijk is significant.

SQL ⟷ TypeScript

Een ander voordeel van het schrijven van SQL is dat we het gemakkelijk kunnen gebruiken als de enige bron van waarheid voor TypeScript-definities. Ik schreef een codegenerator om SQL-schema's naar TypeScript-code te transpilen die we in onze backend zullen gebruiken, en het resultaat ziet er niet slecht uit:

We kunnen zelfs jsonb koppelen met een TypeScript-type en indien nodig typevalidatie verwerken in de backend-service.

Resultaat

De uiteindelijke afhankelijkheidsstructuur ziet er als volgt uit:

Je merkt misschien op dat het een eenrichtingsdiagram is, wat ons enorm heeft geholpen om een duidelijke architectuur en de mogelijkheid om het project uit te breiden terwijl het groeit te behouden. Bovendien is de code (in principe) helemaal in TypeScript.

Ontwikkelaarservaring

Pakket- en configuratiedeling

Interne afhankelijkheden

pnpm en lerna doen geweldig werk op het gebied van interne werkruimteafhankelijkheden. We gebruiken het onderstaande commando in de projectroot om sibling-pakketten toe te voegen:

Het zal @logto/schemas toevoegen als een afhankelijkheid aan @logto/core. Terwijl je de semantische versie in package.json van je interne afhankelijkheden behoudt, kan pnpm ze ook correct koppelen in pnpm-lock.yaml. Het resultaat zal er als volgt uitzien:

Configuratie delen

We behandelen elk pakket in de monorepo als "onafhankelijk". Hierdoor kunnen we de standaard aanpak voor configuratiedeling gebruiken, die tsconfig, eslintConfig, prettier, stylelint en jest-config dekt. Zie dit project als voorbeeld.

Code, lint en commit

Ik gebruik VSCode voor dagelijkse ontwikkeling, en kort gezegd, er is niets anders als het project juist is geconfigureerd:

  • ESLint en Stylelint werken normaal.
    • Als je de VSCode ESLint-plugin gebruikt, voeg dan de VSCode-instellingen hieronder toe om het per-pakket ESLint config te eerbiedigen (vervang de waarde van pattern door die van jezelf):
  • husky, commitlint en lint-staged werken zoals verwacht.

Compiler en proxy

We gebruiken verschillende compilers voor frontend en backend: parceljs voor UI (React) en tsc voor alle andere pure TypeScript-pakketten. Ik raad je sterk aan parceljs te proberen als je dat nog niet hebt gedaan. Het is een echte "zero-config" compiler die verschillende bestandstypen gracieus afhandelt.

Parcel host zijn eigen frontend dev server, en de productie-uitvoer zijn gewoon statische bestanden. Omdat we graag API's en SPA onder dezelfde oorsprong willen monteren om CORS-problemen te vermijden, werkt de onderstaande strategie:

  • In de ontwikkelomgeving gebruik je een eenvoudige HTTP-proxy om het verkeer om te leiden naar de Parcel dev server.
  • In productie serveer je statische bestanden direct.

Je vindt de implementatie van de frontend-middlewarefunctie hier.

Watch-modus

We hebben een dev script in package.json voor elk pakket dat de bestandswijzigingen in de gaten houdt en opnieuw compileert wanneer nodig. Dankzij lerna wordt alles eenvoudig met lerna exec om pakket scripts parallel uit te voeren. Het rootscript ziet er als volgt uit:

Samenvatting

Ideaal gesproken zijn er slechts twee stappen voor een nieuwe engineer/contributor om te beginnen:

  1. Clone de repo
  2. pnpm i && pnpm dev

Afsluitende aantekeningen

Ons team ontwikkelt al een jaar volgens deze aanpak en we zijn er behoorlijk tevreden mee. Bezoek onze GitHub-repo om de nieuwste staat van het project te zien. Kort samengevat:

Pijnen

  • Bekendheid met het JS/TS-ecosysteem is vereist
  • De juiste pakketbeheerder kiezen is nodig
  • Vereist wat extra eenmalige opzet

Voordelen

  • Ontwikkel en onderhoud het hele project in één repo
  • Vereenvoudigde coderingsvaardigheidseisen
  • Gedeelde codestijlen, schema's, zinnen en hulpprogramma's
  • Verbeterde communicatie-efficiëntie
    • Geen vragen meer zoals: Wat is de API-definitie?
    • Alle ingenieurs spreken dezelfde programmeertaal
  • CI/CD met gemak
    • Gebruik dezelfde toolchain voor bouwen, testen en publiceren

Dit artikel laat verschillende onderwerpen onbesproken: Het repo vanaf nul instellen, een nieuw pakket toevoegen, GitHub Actions gebruiken voor CI/CD, enz. Het zou te lang worden voor dit artikel als ik ze allemaal zou uitwerken. Voel je vrij om te reageren en laat me weten welk onderwerp je in de toekomst zou willen zien.