TypeScript allt-i-ett: Monorepo med dess smärtor och vinster
I den här artikeln kommer jag inte att jämföra monorepo och polyrepo eftersom det handlar om filosofi. Istället kommer jag att fokusera på bygg- och utvecklingserfarenheten och antar att du är bekant med JS/TS-ekosystemet.
Intro
Jag har alltid haft en dröm om monorepo.
Jag såg monorepo-metoden när jag arbetade för Airbnb, men det var bara för frontend. Med en djup kärlek till JavaScript-ekosystemet och den "glada" TypeScript-utvecklingserfarenheten började jag anpassa frontend- och backend-kod i samma språk för ungefär tre år sedan. Det var bra (för rekrytering) men inte så bra för utveckling eftersom våra projekt fortfarande var utspridda över flera repos.
Som det sägs, "bästa sättet att refaktorera ett projekt är att starta ett nytt". Så när jag startade mitt startup för ungefär ett år sedan beslutade jag mig för att använda en total monorepo-strategi: lägga frontend- och backend-projekt, till och med databasscheman, i en repo.
I den här artikeln kommer jag inte att jämföra monorepo och polyrepo eftersom det handlar om filosofi. Istället kommer jag att fokusera på bygg- och utvecklingserfarenheten och antar att du är bekant med JS/TS-ekosystemet.
Det slutliga resultatet finns tillgängligt på GitHub.
Varför TypeScript?
Ärligt talat, jag är ett fan av JavaScript och TypeScript. Jag älskar kompatibiliteten mellan dess flexibilitet och strikthet: du kan falla tillbaka till unknown
eller any
(även om vi förbjöd alla former av any
i vår kodbas), eller använda en super-strikt lint-regelsats för att anpassa kodstilen över teamet.
När vi pratade om konceptet "fullstack" tidigare, brukade vi föreställa oss minst två ekosystem och programmeringsspråk: ett för frontend och ett för backend.
En dag insåg jag plötsligt att det kunde vara enklare: Node.js är snabbt nog (tro mig, i de flesta fall är kodkvalitet viktigare än körhastighet), TypeScript är moget nog (fungerar bra i stora frontend-projekt), och monorepo-konceptet har praktiserats av många kända team (React, Babel, etc.) - så varför inte kombinera all kod, från frontend till backend? Detta kan göra så att ingenjörer kan göra jobbet utan kontextbyte i en repo och implementera en komplett funktionalitet i (nästan) ett språk.
Välja paketmanager
Som en utvecklare, och som vanligt, kunde jag inte vänta med att börja koda. Men den här gången var saker annorlunda.
Valet av paketmanager är avgörande för dev-upplevelsen i en monorepo.
Inertins smärta
Det var juli 2021. Jag började med [email protected]
eftersom jag har använt det under en lång tid. Yarn var snabbt, men snart stötte jag på flera problem med Yarn Workspaces. T.ex. inte höja beroenden korrekt, och massor av problem är taggade med "fixed in modern", vilket omdirigerar mig till v2 (berry).
"Okej, bra, jag uppgraderar nu." Jag slutade kämpa med v1 och började migrera. Men den långa migrationsguiden av berry skrämde mig, och jag gav upp efter flera misslyckade försök.
Det fungerar bara
Så forskningen om paketmanager började. Jag drogs till pnpm
efter en testkörning: snabbt som yarn, inbyggt monorepo-stöd, liknande kommandon till npm
, hårda länkar, etc. Viktigast av allt, det fungerar bara. Som en utvecklare som vill komma igång med en produkt men INTE utveckla en paketmanager, ville jag bara lägga till några beroenden och starta projektet utan att veta hur en paketmanager fungerar eller några andra fancy koncept.
Baserat på samma idé valde vi en gammal vän lerna
för att köra kommandon över paketen och publicera arbetsytepaket.
Definiera paketomfång
Det är svårt att tydligt räkna ut det slutliga omfånget av varje paket i början. Börja bara med ditt bästa försök enligt status quo, och kom ihåg att du alltid kan refaktorisera under utveckling.
Vår initiala struktur innehåller fyra paket:
core
: backend-monolittjänsten.phrases
: i18n-nyckel → phrase-resurser.schemas
: databasen och delade TypeScript-scheman.ui
: en webb-SPA som interagerar medcore
.
Teknikstack för fullstack
Eftersom vi omfamnar JavaScript-ekosystemet och använder TypeScript som vårt huvudsakliga programmeringsspråk är många val enkla (baserat på min preferens 😊):
koajs
för backend-tjänsten (core): Jag hade en svår erfarenhet av att användaasync/await
iexpress
, så jag bestämde mig för att använda något med inbyggt stöd.i18next/react-i18next
för i18n (phrases/ui): gillar dess enkla API:er och god TypeScript-stöd.react
för SPA (ui): Bara personlig preferens.
Hur är det med scheman?
Något saknas fortfarande här: databassystem och schema <-> TypeScript definitionskartläggning.
Generell vs. åsiktsdriven
Vid den tiden provade jag två populära metoder:
- Använd ORM med massor av dekoratörer.
- Använd en frågebyggare som Knex.js.
Men båda gav en konstig känsla under tidigare utveckling:
- För ORM: Jag är inte ett fan av dekoratörer, och ett annat abstrakt lager av databasen orsakar mer inlärningsansträngning och osäkerhet för teamet.
- För frågebyggare: Det är som att skriva SQL med vissa begränsningar (på ett bra sätt), men det är inte faktiskt SQL. Därför behöver vi använda
.raw()
för råa frågor i många scenarier.
Sedan såg jag denna artikel: "Stop using Knex.js: Using SQL query builder is an anti-pattern". Titeln ser aggressiv ut, men innehållet är bra. Det påminner mig starkt om att "SQL är ett programmeringsspråk", och jag insåg att jag kunde skriva SQL direkt (precis som CSS, hur kunde jag missa detta!) för att utnyttja det inhemska språket och databasens funktioner istället för att lägga till ett annat lager och minska styrkan.
Sammanfattningsvis beslutade jag att hålla mig till Postgres och Slonik (en öppen källkod Postgres-klient), som artikeln säger:
...fördelen med att låta användaren välja mellan de olika databasdialekterna är marginell och den överliggande utvecklingskostnaden för flera databaser på samma gång är betydande.
SQL <-> TypeScript
En annan fördel med att skriva SQL är att vi enkelt kan använda det som den enda sanningskällan för TypeScript-definitioner. Jag skrev en kodgenerator för att transpilera SQL-scheman till TypeScript-kod som vi kommer att använda i vår backend, och resultatet ser inte dåligt ut:
Vi kan till och med koppla jsonb
med en TypeScript-typ och bearbeta typvalidering i backend-tjänsten vid behov.
Resultat
Den slutliga beroendestrukturen ser ut som:
Du kanske märker att det är ett enkelriktat diagram, vilket enormt hjälpte oss att hålla en tydlig arkitektur och förmågan att expandera när projektet växer. Plus, koden är (i stort sett) all på TypeScript.
Dev-upplevelse
Delning av paket och konfiguration
Interna beroenden
pnpm
och lerna
gör ett fantastiskt jobb när det gäller interna arbetsytedependens. Vi använder kommandot nedan i projektroten för att lägga till systerpaket:
Detta kommer att lägga till @logto/schemas
som ett beroende till @logto/core
. Samtidigt som det behåller den semantiska versionen i package.json
för dina interna beroenden kan pnpm
också korrekt länka dem i pnpm-lock.yaml
. Resultatet kommer att se ut så här:
Konfigurationsdelning
Vi behandlar varje paket i monorepo "oberoende". Därför kan vi använda standardmetoden för konfigurationsdelning, som omfattar tsconfig
, eslintConfig
, prettier
, stylelint
och jest-config
. Se detta projekt som exempel.
Kod, lint och commit
Jag använder VSCode för daglig utveckling, och i korthet är inget annorlunda när projektet är korrekt konfigurerat:
- ESLint och Stylelint fungerar normalt.
- Om du använder VSCode ESLint-plugin, lägg till VSCode-inställningarna nedan för att göra det hedrar per-paket ESLint-konfiguration (ersätt värdet av
pattern
med din egen):
- Om du använder VSCode ESLint-plugin, lägg till VSCode-inställningarna nedan för att göra det hedrar per-paket ESLint-konfiguration (ersätt värdet av
- husky, commitlint och lint-staged fungerar som förväntat.
Kompilator och proxy
Vi använder olika kompilatorer för frontend och backend: parceljs
för UI (React) och tsc
för alla andra rena TypeScript-paket. Jag rekommenderar starkt att du provar parceljs
om du inte redan har gjort det. Det är en riktig "noll-konfig" kompilator som smidigt hanterar olika filtyper.
Parcel hostar sin egen frontend-utvecklingsserver, och produktionsutmatningen är bara statiska filer. Eftersom vi skulle vilja montera API:er och SPA under samma ursprung för att undvika CORS-problem, fungerar strategin nedan:
- I utvecklingsmiljö, använd en enkel HTTP-proxy för att omdirigera trafiken till Parcel-utvecklingsservern.
- I produktion, servera statiska filer direkt.
Du kan hitta frontend-middleware-funktionsimplementeringen här.
Vakta-läge
Vi har ett dev
-skript i package.json
för varje paket som övervakar filändringar och kompilerar om vid behov. Tack vare lerna
blir det enkelt att använda lerna exec
för att köra paketskript parallellt. Rot-skriptet kommer att se ut så här:
Sammanfattning
Idealiskt, bara två steg för en ny ingenjör/bidragsgivare för att komma igång:
- Klona repot
pnpm i && pnpm dev
Avslutande anmärkningar
Vårt team har utvecklat enligt denna metod under ett år, och vi är ganska nöjda med det. Besök vår GitHub-repo för att se den senaste formen av projektet. För att sammanfatta:
Smärtor
- Behöver vara bekant med JS/TS-ekosystemet
- Behöver välja rätt paketmanager
- Kräver en del extra en gångsinställning
Vinster
- Utveckla och underhåll hela projektet i en repo
- Förenklade kodningskompetenskrav
- Delade kodstilar, scheman, fraser och verktyg
- Förbättrad kommunikationseffektivitet
- Inga fler frågor som: Vad är API-definitionen?
- Alla ingenjörer talar samma programmeringsspråk
- CI/CD med lätthet
- Använd samma verktygskedja för byggande, testning och publicering
Denna artikel lämnar flera ämnen ofullständiga: Att ställa in repot från grunden, lägga till ett nytt paket, utnyttja GitHub Actions för CI/CD, etc. Det skulle bli för långt för denna artikel om jag utökar på var och en av dem. Kommentera gärna och låt mig veta vilket ämne du skulle vilja se i framtiden.