TypeScript all-in-one: Monorepo con i suoi dolori e guadagni
In questo articolo, non paragonerò monorepo e polyrepo poiché si tratta di filosofia. Invece, mi concentrerò sull'esperienza di costruzione ed evoluzione e presuppongo che tu sia familiare con l'ecosistema JS/TS.
Introduzione
Ho sempre avuto un sogno di monorepo.
Ho visto l'approccio monorepo mentre lavoravo per Airbnb, ma era solo per il frontend. Con un grande amore per l'ecosistema JavaScript e l'esperienza di sviluppo "felice" di TypeScript, ho iniziato a allineare il codice frontend e backend nello stesso linguaggio da circa tre anni fa. È stato fantastico (per l'assunzione) ma non tanto per lo sviluppo poiché i nostri progetti erano ancora sparsi su più repository.
Come si dice, “il modo migliore per rifattorizzare un progetto è iniziare un nuovo progetto”. Quindi quando ho iniziato la mia startup circa un anno fa, ho deciso di utilizzare una strategia monorepo totale: mettere i progetti frontend e backend, anche gli schemi del database, in un solo repository.
In questo articolo, non paragonerò monorepo e polyrepo poiché si tratta di filosofia. Invece, mi concentrerò sull'esperienza di costruzione ed evoluzione e presuppongo che tu sia familiare con l'ecosistema JS/TS.
Il risultato finale è disponibile su GitHub.
Perché TypeScript?
Sinceramente parlando, sono un fan di JavaScript e TypeScript. Amo la compatibilità della sua flessibilità e rigore: puoi tornare a unknown
o any
(sebbene abbiamo bandito qualsiasi forma di any
nel nostro codice), o utilizzare un insieme di regole lint super-strict per allineare lo stile del codice in tutto il team.
Quando parlavamo del concetto di "fullstack" prima, immaginavamo almeno due ecosistemi e linguaggi di programmazione: uno per il frontend e uno per il backend.
Un giorno, mi sono reso conto che poteva essere più semplice: Node.js è abbastanza veloce (credimi, nella maggior parte dei casi, la qualità del codice è più importante della velocità di esecuzione), TypeScript è abbastanza maturo (funziona bene in grandi progetti frontend), e il concetto di monorepo è stato praticato da un mucchio di squadre famose (React, Babel, ecc.) - quindi perché non combiniamo tutto il codice insieme, dal frontend al backend? Questo può far sì che gli ingegneri facciano il loro lavoro senza cambio di contesto in un solo repository e implementino una funzionalità completa in (quasi) un solo linguaggio.
Scegliere il gestore di pacchetti
Come sviluppatore, e come al solito, non vedevo l'ora di iniziare a programmare. Ma questa volta, le cose erano diverse.
La scelta del gestore di pacchetti è fondamentale per l'esperienza di sviluppo in un monorepo.
Il dolore dell'inerzia
Era luglio 2021. Ho iniziato con [email protected]
poiché lo uso da molto tempo. Yarn era veloce, ma presto ho incontrato diversi problemi con Yarn Workspaces. Ad esempio, non collegamento correttamente delle dipendenze, e tonnellate di problemi sono segnati con “fixed in modern”, che mi reindirizza alla v2 (berry).
"Okay bene sto aggiornando ora." Ho smesso di lottare con v1 e ho iniziato a migrare. Ma la lunga guida alla migrazione di berry mi ha spaventato, e ho rinunciato dopo diversi tentativi falliti.
Funziona semplicemente
Quindi la ricerca sui gestori di pacchetti ha avuto inizio. Sono stato assorbito da pnpm
dopo una prova: veloce come yarn, supporto nativo per monorepo, comandi simili a npm
, link rigidi, ecc. Più importantemente, funziona semplicemente. Come sviluppatore che vuole iniziare con un prodotto ma NON sviluppare un gestore di pacchetti, volevo solo aggiungere alcune dipendenze e avviare il progetto senza sapere come funziona un gestore di pacchetti o altri concetti fantasia.
Basato sulla stessa idea, abbiamo scelto un vecchio amico lerna
per l'esecuzione di comandi nei pacchetti e la pubblicazione di pacchetti di spazio di lavoro.
Definire l'ambito dei pacchetti
È difficile capire chiaramente l'ambito finale di ciascun pacchetto all'inizio. Basta iniziare con il tuo miglior tentativo secondo lo status quo, e ricorda che puoi sempre rifattorizzare durante lo sviluppo.
La nostra struttura iniziale contiene quattro pacchetti:
core
: il servizio monolite del backend.frasi
: chiave i18n → risorse di frase.schemi
: il database e gli schemi TypeScript condivisi.ui
: un SPA web che interagisce concore
.
Tech stack per fullstack
Dato che stiamo abbracciando l'ecosistema JavaScript e usando TypeScript come nostro principale linguaggio di programmazione, molte scelte sono semplici (basate sulla mia preferenza 😊):
koajs
per il servizio backend (core): ho avuto un'esperienza difficile usandoasync/await
inexpress
, quindi ho deciso di usare qualcosa con supporto nativo.i18next/react-i18next
per i18n (frasi/ui): mi piace la sua semplicità di API e il buon supporto di TypeScript.react
per SPA (ui): Solo preferenza personale.
E gli schemi?
Qualcosa qui è ancora mancante: sistema di database e mappatura schema <-> Definizione TypeScript.
Generale v.s. opinionato
A quel punto, ho provato due approcci popolari:
- Usa ORM con un sacco di decoratori.
- Usa un generatore di query come Knex.js.
Ma entrambi producono una sensazione strana durante lo sviluppo precedente:
- Per ORM: Non sono un fan dei decoratori, e un altro strato astratto del database provoca più sforzi di apprendimento e incertezza per il team.
- Per il generatore di query: È come scrivere SQL con alcune restrizioni (in senso buono), ma non è SQL effettivo. Quindi dobbiamo usare
.raw()
per le query raw in molti scenari.
Poi ho visto questo articolo: “Stop using Knex.js: Using SQL query builder is an anti-pattern”. Il titolo sembra aggressivo, ma il contenuto è fantastico. Mi ricorda fortemente che "SQL è un linguaggio di programmazione", e ho capito che potevo scrivere SQL direttamente (proprio come CSS, come ho potuto dimenticare questo!) per sfruttare il linguaggio nativo e le caratteristiche del database invece di aggiungere un altro strato e ridurre il potere.
In conclusione, ho deciso di attenermi a Postgres e Slonik (un client Postgres open source), come afferma l'articolo:
...il vantaggio di permettere all'utente di scegliere tra i diversi dialetti del database è marginale e l'impegno nello sviluppo per più database contemporaneamente è significativo.
SQL <-> TypeScript
Un altro vantaggio della scrittura SQL è che possiamo facilmente usarlo come unica fonte di verità delle definizioni TypeScript. Ho scritto un generatore di codice per traspilare gli schemi SQL nel codice TypeScript che useremo nel nostro backend, e il risultato sembra non male:
Possiamo anche collegare jsonb
con un tipo TypeScript e processare la validazione del tipo nel servizio backend se necessario.
Risultato
La struttura finale delle dipendenze sembra così:
Potresti notare che è un diagramma in una direzione, che ci ha aiutato molto a mantenere un'architettura chiara e la capacità di espandere man mano che il progetto cresce. Inoltre, il codice è (fondamentalmente) tutto in TypeScript.
Esperienza di sviluppo
Condivisione di pacchetti e configurazioni
Dipendenze interne
pnpm
e lerna
fanno un ottimo lavoro sulle dipendenze interne dello spazio di lavoro. Usiamo il comando qui sotto nella radice del progetto per aggiungere pacchetti fratelli:
Aggiungerà @logto/schemas
come dipendenza a @logto/core
. Mentre mantiene la versione semantica in package.json
delle tue dipendenze interne, pnpm
può anche collegarle correttamente in pnpm-lock.yaml
. Il risultato sarà simile a questo:
Condivisione configurazioni
Trattiamo ogni pacchetto nel monorepo "indipendente". Quindi possiamo utilizzare l'approccio standard per la condivisione delle configurazioni, che copre tsconfig
, eslintConfig
, prettier
, stlyelint
, e jest-config
. Guarda questo progetto per esempio.
Codice, lint, e commit
Uso VSCode per lo sviluppo quotidiano, e in breve, non cambia nulla quando il progetto è configurato correttamente:
- ESLint e Stylelint funzionano normalmente.
- Se stai usando il plugin ESLint di VSCode, aggiungi le impostazioni di VSCode in basso per farlo rispettare la configurazione ESLint per pacchetto (sostituisci il valore di
pattern
con il tuo):
- Se stai usando il plugin ESLint di VSCode, aggiungi le impostazioni di VSCode in basso per farlo rispettare la configurazione ESLint per pacchetto (sostituisci il valore di
- husky, commitlint e lint-staged funzionano come previsto.
Compilatore e proxy
Stiamo usando diversi compilatori per frontend e backend: parceljs
per UI (React) e tsc
per tutti gli altri pacchetti TypeScript puri. Ti consiglio vivamente di provare parceljs
se non lo hai ancora fatto. È un vero compilatore "zero-config" che gestisce con grazia diversi tipi di file.
Parcel ospita il proprio server di sviluppo frontend, e l'output di produzione sono solo file statici. Poiché ci piacerebbe montare API e SPA sotto la stessa origine per evitare problemi di CORS, la strategia qui sotto funziona:
- In ambiente di sviluppo, si utilizza un semplice proxy HTTP per reindirizzare il traffico al server di sviluppo Parcel.
- In produzione, servire direttamente file statici.
Puoi trovare l'implementazione della funzione del middleware frontend qui.
Modalità Watch
Abbiamo uno script dev
in package.json
per ogni pacchetto che osserva i cambiamenti del file e riesegue la compilazione quando necessario. Grazie a lerna
, le cose diventano facili usando lerna exec
per eseguire script del pacchetto in parallelo. Lo script radice sembrerà così:
Riassunto
Idealmente, solo due passaggi per un nuovo ingegnere/contributore per iniziare:
- Clona il repository
pnpm i && pnpm dev
Note finali
Il nostro team ha sviluppato con questo approccio per un anno, e ne siamo molto soddisfatti. Visita il nostro repository GitHub per vedere l'ultimo aspetto del progetto. Per concludere:
Dolori
- Bisogno di essere familiari con l'ecosistema JS/TS
- Bisogno di scegliere il giusto gestore di pacchetti
- Richiede una configurazione aggiuntiva one-time
Guadagni
- Sviluppare e mantenere l'intero progetto in un solo repository
- Semplificati requisiti di competenze di codifica
- Stili di codice condivisi, schemi, frasi e utilità
- Migliorata l'efficienza della comunicazione
- Non più domande come: Qual è la definizione di API?
- Tutti gli ingegneri parlano nello stesso linguaggio di programmazione
- CI/CD con facilità
- Usa la stessa catena di strumenti per la costruzione, il test e la pubblicazione
Questo articolo lascia diversi argomenti non coperti: impostare il repository da zero, aggiungere un nuovo pacchetto, sfruttare GitHub Actions per CI/CD, ecc. Sarebbe troppo lungo per questo articolo se ampliassi ciascuno di essi. Sentiti libero di commentare e fammi sapere quale argomento ti piacerebbe vedere in futuro.