• typescript
  • monorepo

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.

Gao
Gao
Founder

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 con core.

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 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.
  • 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):
  • 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:

  1. Clona il repository
  2. 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.