L’anno scorso in Proton, siamo passati da un’architettura polyrepo a una monorepo per semplificare la gestione dei pacchetti che fanno parte del nostro stack di applicazioni web front-end. Da tempo stavamo affrontando problemi e, dopo aver valutato le nostre opzioni, abbiamo deciso che una monorepo sarebbe stata la soluzione più adatta. Questo articolo spiega i problemi che abbiamo affrontato con la nostra configurazione polyrepo, esplora i vantaggi di una configurazione monorepo e descrive il nostro percorso da polyrepo a monorepo.
Prima di andare avanti, quando dico “polyrepo” e “monorepo”, intendo questo:
- Polyrepo: Un sistema di moduli di codice sorgente che hanno dipendenze reciproche ma sono istanze separate di repository di controllo versione.
- Monorepo: Un sistema di moduli di codice sorgente che hanno dipendenze reciproche ma che risiedono tutti sotto una singola istanza di repository di controllo versione.
D’ora in poi, userò “repository Git” o semplicemente “repository” invece di “repository di controllo versione”. E, per essere chiari, Git non è un prerequisito dell’architettura monorepo.
L’inizio
Proton è partita con un client di posta elettronica, Proton Mail(new window), come unica applicazione ma si è poi evoluta in un fornitore di servizi per la privacy offrendo una vasta gamma di prodotti, inclusi applicazioni web per Proton Mail, Proton Calendar(new window), Proton Drive(new window) e il Proton Account che le collega tutte. Aggiungendo nuove applicazioni al nostro stack, il numero di repository Git che manteniamo è cresciuto proporzionalmente, con un repository per applicazione. Tuttavia, abbiamo creato repository oltre a quelli richiesti per le nostre applicazioni. Come puoi immaginare, le nostre app devono condividere la stessa funzionalità, aspetto e sensazione, anche se sono prodotti diversi. Di conseguenza, abbiamo utilizzato repository per codice che era condiviso tra prodotti.
Ad esempio, avevamo un repository separato per i componenti React condivisi. Questo era il risultato di un’evoluzione naturale dei nostri sistemi esistenti. Tuttavia, condividere codice tra codebase è diventato sempre più complesso con l’aggiunta di ulteriori applicazioni e prodotti, rendendo difficile la gestione dei pacchetti sotto questa struttura multi-repository. Ci sono diverse ragioni per cui questo sistema non si è scalato bene.
Il nostro problema principale con il nostro polyrepo
Durante e dopo la nostra transizione a una monorepo, abbiamo iniziato a vedere come potessimo trarre vantaggio dalla sua architettura. Tuttavia, un problema in particolare — la ripetizione inutile e dispendiosa di compiti amministrativi — ci ha spinto a considerare questa opzione monorepo in primo luogo. Ogni volta che l’implementazione di una funzionalità richiedeva modifiche a più progetti per essere completata (ad esempio, aggiungendo un componente React per una nuova funzionalità all’interno dell’applicazione Proton Mail), i compiti amministrativi in Git erano estremamente impraticabili da eseguire. Per preparare una singola funzionalità, dovevamo replicare le operazioni di Git — creazione di branch, commit, apertura di richieste di merge, revisione, rebasing, ecc. — su molti repository.
Abbiamo poi scoperto l’idea dei “cambiamenti atomici”, che ci ha colpito, anche se rappresentava un cambiamento nella nostra filosofia. L’idea principale dietro ai cambiamenti atomici è che invece di avere modifiche limitate a una preoccupazione tecnica del tuo(i) progetto(i), si raggruppano le modifiche per il loro significato come blocchi di modifica alla funzionalità del tuo prodotto. Non c’è motivo di suddividere le modifiche che influenzano intrinsecamente i nostri componenti dell’interfaccia utente condivisi e (ad esempio) l’applicazione Proton Mail se tutti affrontano la stessa preoccupazione. Tali cambiamenti semanticamente connessi dovrebbero essere:
- Raggruppati sotto lo stesso cambiamento, diff e commit
- Valutabili contemporaneamente (non in due richieste di merge separate)
- Reversibili come un’unica unità.
Un monorepo ci permette di realizzare questo poiché supporta naturalmente le modifiche atomiche sotto forma di commit Git.
Nel polyrepo, testare il codice prima di accettarlo e unirlo al ramo principale si è dimostrato difficile, specialmente dal punto di vista dell’automazione CI/CD. Le build dovevano includere versioni delle dipendenze non presenti sul ramo principale del rispettivo repository. Tuttavia, con un po’ di hacking e astuzia CI/CD, siamo riusciti a portare a termine il lavoro ed è stato possibile mandare avanti le funzionalità attraverso il ciclo di vita dello sviluppo con successo.
Inoltre, non stavamo utilizzando semver e hosting del registro per versionare i nostri pacchetti (e ancora non lo facciamo), che sarebbe stato un modo per affrontare alcune di queste problematiche. Tuttavia, semver sarebbe stato ben lontano da una soluzione definitiva per le nostre esigenze e comporta le proprie difficoltà, come la complessità nella gestione dei pacchetti ospitati, la loro pubblicazione e la versione al momento del consumo.
L’architettura del repository polyrepo presenta molti altri piccoli inconvenienti scomodi dati i nostri bisogni. Approfondirò ulteriormente i problemi che abbiamo affrontato mentre discuto i vantaggi del nostro monorepo. Per maggior contesto, la nostra architettura polyrepo presentava problemi oltre all’esperienza dello sviluppatore, inclusi problemi tecnici intrinseci. Un esempio tangibile era che non potevamo eseguire rollback a versioni precedenti su base cross-repository. Se una nuova funzionalità che interessava più repository veniva unita e poi si scoprisse avere un problema, era difficile eseguire rollback automaticamente poiché nessuna operazione singola poteva eseguire un rollback su storie Git separate contemporaneamente.
Queste problematiche si accumulavano lentamente e divenne evidente che avevamo bisogno di una soluzione. Dopo alcune valutazioni, quella soluzione si è rivelata essere la migrazione a un’architettura monorepo.
Valutando le nostre opzioni
Con la decisione di migrare confermata, abbiamo dovuto ideare un piano.
A quel tempo, avevamo circa 15 sviluppatori nel team Front-end che lavoravano sul nostro stack di applicazioni web. Inoltre, molte persone di altri team, come Crypto o Back-end, contribuivano frequentemente ai nostri repository. Avere molte persone che lavoravano attivamente su questi repository significava che la migrazione fisica doveva avvenire velocemente e l’implementazione doveva essere robusta una volta completata. Altrimenti, rischiavamo di bloccare il lavoro dei nostri colleghi per un lungo periodo di tempo.
Per garantire un’implementazione robusta, abbiamo dedicato molto tempo a ricercare diversi strumenti e sperimentare con prove di concetto. Verificavamo come si comportava una determinata opzione o se potevamo farla funzionare come desideravamo. Abbiamo esplorato diversi gestori di pacchetti (specificamente, npm, yarn, pnpm), versionamento semantico con un registro ospitato, diversi tipi di installazione delle dipendenze, gestione dei lockfile e altro ancora.
Alla fine, abbiamo deciso di andare molto sul semplice. Abbiamo scelto Yarn (Berry) e Yarn Workspaces, un unico lockfile alla radice del monorepo, nessun versionamento semantico e nessuna zero-install. Abbiamo preso queste decisioni perché volevamo il minor sovraccarico possibile, strumenti maturi e che il nostro team fosse già familiare con tali strumenti.
Tutti i potenziali benefici di un monorepo
Un momento chiave durante la nostra ricerca sui monorepo è stato capire che, mentre questa architettura avrebbe certamente risolto i problemi che stavamo affrontando, questi sistemi offrivano molto di più. I monorepo hanno offerto molti vantaggi che non avevamo necessariamente considerato, la maggior parte incentrati sulla collaborazione tra sviluppatori.
Abbiamo sostenuto che l’architettura monorepo avrebbe incentivato le persone a collaborare a progetti di cui non sono necessariamente proprietari rendendo tutto il codice visibile, dando così potere agli sviluppatori di implementare correzioni semplici. Invece di essere costretti a cercare aiuto perché stai guardando una scatola nera, potresti essere in grado di implementare tu stesso il cambiamento necessario poiché tutto il codice sarebbe facilmente accessibile.
I monorepo avrebbero anche probabilmente reso possibile il refactoring su larga scala, poiché saremmo stati in grado di cambiare grandi parti di diversi progetti con commit unificati. Poiché tutto il codice sorgente interdipendente sarebbe ora ospitato nello stesso repository Git, la disponibilità e la posizione nel file system di qualsiasi pezzo di codice sarebbe prevedibile. Questo renderebbe possibile fornire utility per eseguire qualsiasi azione necessaria per lavorare con il monorepo localmente o nell’integrazione continua (CI), ad esempio, configurazione dell’ambiente, server di sviluppo, build, controlli, collegamento simbolico automatico, gestione dei lockfile e altro ancora. Eravamo davvero entusiasti, per dirlo in modo soft.
Dopo aver trovato uno schema monorepo che ci soddisfaceva, abbiamo preparato una presentazione per il resto del team, presentato le nostre scoperte e il proof-of-concept, raccolto feedback e apportato modifiche. Volevamo assicurarci di non creare una configurazione con cui qualcuno non sarebbe stato in grado o felice di lavorare. È stato ben accolto, e abbiamo deciso di procedere.
La migrazione fisica
Mentre ci preparavamo alla migrazione, il nostro obiettivo principale era evitare di interrompere il lavoro in corso. Abbiamo scritto uno script che avrebbe preso tutti i repository esistenti dalla nostra configurazione polyrepo, fuso le loro storie Git in una storia unica e colmato le lacune necessarie per realizzare il monorepo completo. Questo script poteva generare il nostro intero monorepo all’esecuzione di un comando, il che significava che potevamo creare il monorepo in qualsiasi istante, indipendentemente dallo stato attuale del polyrepo. Questo era molto meglio che dover chiudere lo sviluppo mentre costruivamo manualmente il monorepo dal polyrepo.
La piena implementazione ha anche visto una completa riscrittura della nostra CI per tutti i controlli e i deployment delle app e dei pacchetti, che è stata una parte abbastanza grande della transizione. Esplorare come adeguare e scrivere la CI per un monorepo sarà trattato in un proprio articolo in un secondo momento.
Una volta che tutto era pronto e configurato, abbiamo fissato una data per la migrazione: un sabato. Abbiamo scelto un giorno del fine settimana in modo che le persone potessero andare a casa, lasciare il lavoro alle spalle il venerdì, poi tornare il lunedì seguente e trovare ciò su cui stavano lavorando ora all’interno del monorepo.
A questo punto, consideravamo il polyrepo deprecato perché non volevamo mantenere storie Git multiple e in conflitto continuamente. Per assicurarci che nessun lavoro andasse perso, abbiamo compilato un elenco di tutti i rami attivi che le persone volevano salvare e trasferire (abbiamo aggiunto supporto per questo nel nostro script di creazione del monorepo).
Dall’altro lato
Per quanto il piano su carta possa sembrare irrealisticamente ambizioso, per noi ha funzionato abbastanza agevolmente! Durante la prima settimana dopo la migrazione, alcune pipeline sono fallite e alcuni pezzi incompleti di codice sono stati lasciati indietro nella configurazione polyrepo e hanno dovuto essere trasferiti manualmente dopo la transizione. A parte questi e alcuni altri piccoli intoppi, tutto è andato bene. Nessuno è stato seriamente impedito di continuare il proprio lavoro, e ora che la migrazione è completa, nessuno si è voltato indietro.
Abbiamo scoperto che il monorepo offre ancora più vantaggi di quanto previsto dalla migrazione. È molto più facile formare le persone sulla nostra base di codice ora, grazie all’impostazione con un clic per un ambiente di sviluppo locale. Si è sviluppata una piccola comunità interna attorno ad esso, e non comprende solo membri del team Front-end di Proton. Include chiunque sia interessato all’architettura monorepo e chiunque lavori con il nostro. In questa comunità, parliamo di:
- Monorepo in generale (e il nostro monorepo WebClient(new window) in particolare)
- Affrontare le problematiche relative ai monorepo quando si cerca aiuto
- Proporre miglioramenti al flusso di lavoro del nostro monorepo.
Più importante di tutto, ora parliamo tutti la stessa lingua per quanto riguarda il flusso di lavoro e l’amministrazione di Git. Dato che ora è tutto un unico repository Git, abbiamo anche normalizzato le linee guida per Git tra i diversi team di sviluppo front-end e configurato universalmente le regole del nostro strumento di hosting Git che si estende su tutto il monorepo (ad esempio, le regole di merge).
Conclusione
Retrospettivamente, l’implementazione di questo monorepo ha superato le nostre aspettative. È una buona soluzione per le nostre esigenze, e siamo felici di averla scelta! Il miglioramento dell’esperienza degli sviluppatori ha portato a un notevole incremento della produttività. Non è ancora la soluzione definitiva, e ci sono molte sfide che ne derivano, ma per noi, i benefici ottenuti superano di gran lunga le difficoltà. Speriamo che questa architettura di base dei pacchetti regga e ci permetta di scalare e aggiungere qualsiasi altro pacchetto richiesto con facilità per il futuro prevedibile.
Il repository Git discusso in questo articolo è open source e può essere trovato su https://github.com/ProtonMail/WebClients(new window).