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.
GaoFounder
Smetti di sprecare settimane sull'autenticazione degli utenti
Lancia app sicure più velocemente con Logto. Integra l'autenticazione degli utenti in pochi minuti e concentrati sul tuo prodotto principale.
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.
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.
"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.
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.
È 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.
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 usando async/await in express, 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.
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.
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.
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.
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:
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.
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):
husky, commitlint e lint-staged funzionano come previsto.
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.
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ì:
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.