💁🏻♀️ Perché Gato GraphQL ha bisogno di un Monorepo, e come è ottimizzato
Qualche giorno fa ho pubblicato l'articolo Ospitare tutti i tuoi package PHP insieme in un monorepo, in cui spiego perché potremmo voler utilizzare un monorepo per gestire il nostro codice sorgente PHP, e come farlo tramite il Monorepo Builder.
Qui vorrei integrare quell'articolo, spiegando un po' più in dettaglio perché il codice sorgente di GatoGraphQL/GatoGraphQL (che ospita Gato GraphQL, il suo motore GraphQL sottostante e l'architettura a modello di componenti su cui si basa) deve essere ospitato su un monorepo, e le ottimizzazioni che vi ho apportato.
Perché Gato GraphQL ha bisogno di un monorepo
Per supportare l'agnosticismo rispetto al CMS, il codice sorgente di Gato GraphQL e dei progetti associati è stato suddiviso in una moltitudine di package, gestiti tramite Composer. In totale, sono stati creati oltre 100 package! (Attualmente, il numero supera i 200.)
Il gran numero di package non aggiunge complessità extra per assemblarli tutti insieme tramite Composer: eseguiamo semplicemente composer install, e tutto funziona. Tuttavia, diventa problematico per lo sviluppo quando ogni singolo package vive nel proprio repository, a causa del versionamento.
Ogni package deve essere versionato, e ogni versione di un package dipenderà da qualche versione di un altro package. Con così tanti package, configurare il modo in cui tutte le versioni dipendono l'una dall'altra durante la creazione delle PR diventerebbe un incubo, simile a un piatto di codice spaghetti, dove si vede la punta di un fusillo, ma non si sa dove finisce.

La verità è che è diventato così difficile collegare tutte le versioni dei molteplici branch di tutti i repository coinvolti, che ho finito per saltare completamente questo processo, facendo il push del codice direttamente sul branch master di ogni repo, e poi dipendendo dalla versione dev-master su ciascuno di essi.
Non era corretto. Passare al modello monorepo, ospitando tutto il codice in GatoGraphQL/GatoGraphQL, ha effettivamente risolto il problema.
Effetto collaterale benvenuto: una barriera più bassa per i contributi
Come ho menzionato nell'articolo, ai tempi in cui il progetto usava un repo per package, un contributore ha abbandonato il progetto ancora prima di unirsi, per la sua incapacità di configurare l'ambiente di lavoro.
Prima di passare al monorepo, configurare l'ambiente di sviluppo era molto difficile. Essendo l'autore, riuscivo a clonare tutti i repo e ad aggiungerli tutti insieme in un unico workspace VSCode, quindi in qualche modo funzionava per me.
Ho provato a rendere più facile la configurazione dello stesso ambiente per i potenziali contributori, tramite questo script bash. Ma seriamente, non avrebbe mai potuto funzionare, era una battaglia persa fin dall'inizio, e nessuno poteva iniziare a contribuire al progetto.
Con il monorepo, posso dormire sonni tranquilli, sapendo che non respingerò i contributori con una burocrazia irragionevole, se mai vorranno essere coinvolti.
Ottimizzare il monorepo
Come ho menzionato nell'articolo, il vantaggio di usare la libreria Monorepo Builder rispetto alle alternative è che è costruita con PHP, e che possiamo estenderla.
Per esempio, quando si esegue un push su master e si suddivide il monorepo, la matrice nella GitHub Action normalmente avvia un'istanza di runner per package, per sincronizzare il suo codice con il proprio repository (per la distribuzione tramite Packagist).
Poiché GatoGraphQL/GatoGraphQL contiene oltre 200 package, ciò significava che venivano lanciate oltre 200 istanze di runner.

Il problema qui è che GitHub ti impone un limite di 20 job in esecuzione in parallelo. Poiché tutte le azioni vengono messe in una coda, dovevo aspettare che terminassero, per continuare a eseguire altre azioni.
Inoltre, di tanto in tanto GitHub non fornisce immediatamente un runner, e ti fa aspettare fino a un momento successivo:

Tutto questo si traduce in tempo di attesa. Con oltre 200 package, unire una singola PR poteva richiedere fino a 1 ora! Questo è un problema che doveva essere risolto.
Estendere il monorepo con comandi personalizzati può risolvere il problema.
Estendere il Monorepo builder
Normalmente, eseguendo il seguente comando, otterremo la lista di tutti i package nel repo:
vendor/bin/monorepo-builder packages-json
Ma poi ho pensato: non c'è bisogno di sincronizzare tutti i package, ma solo quelli che contengono codice che è stato modificato nella PR.
Se possiamo scoprire la lista dei file modificati, possiamo calcolare quali sono i package modificati che li contengono. In altre parole: eseguire git diff, e fornire i risultati al comando packages-json, tramite un input filter, in questo modo:
vendor/bin/monorepo-builder packages-json --filter=modified_file_1 --filter=modified_file_2 --filter=...Ora, il comando packages-json fornito con il Monorepo Builder non accetta un input filter. Quindi è qui che dobbiamo estenderlo con i nostri comandi personalizzati.
Il Monorepo builder utilizza DependencyInjection di Symfony, quindi può essere esteso iniettando nuovi servizi nel suo container. Infatti, il file di configurazione monorepo-builder.php è già un configuratore di servizi.
Così ho esteso il Monorepo builder con un nuovo comando chiamato package-entries-json, che supporta l'input filter:
final class PackageEntriesJsonCommand extends AbstractSymplifyCommand
{
private PackageEntriesJsonProvider $packageEntriesJsonProvider;
public function __construct(PackageEntriesJsonProvider $packageEntriesJsonProvider)
{
$this->packageEntriesJsonProvider = $packageEntriesJsonProvider;
parent::__construct();
}
protected function configure(): void
{
$this->setDescription('Provides package entries in json format. Useful for GitHub Actions Workflow');
$this->addOption(
Option::FILTER,
null,
InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY,
'Filter the packages to those from the list of files. Useful to split monorepo on modified packages only',
[]
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
/** @var string[] $fileFilter */
$fileFilter = $input->getOption(Option::FILTER);
$packageEntries = $this->packageEntriesJsonProvider->providePackageEntries($fileFilter);
// must be without spaces, otherwise it breaks GitHub Actions json
$json = Json::encode($packageEntries);
$this->symfonyStyle->writeln($json);
return ShellCode::SUCCESS;
}
}Viene iniettato nel container di servizi in questo modo:
return static function (ContainerConfigurator $containerConfigurator): void {
$services = $containerConfigurator->services();
$services->defaults()->autowire()->autoconfigure();
$services->set(PackageEntriesJsonCommand::class);
}Ora, il nuovo comando chiamato package-entries-json sarà disponibile per il workflow della GitHub Action.
Ottenere la lista dei file modificati nella GitHub Action
Vediamo ora come aggiornare il workflow.
Uso convenientemente l'action technote-space/get-diff-action, che fornisce il git diff di tutti i file modificati nella PR:
# git diff to generate matrix with modified packages only
- uses: technote-space/get-diff-action@v4
with:
PATTERNS: layers/*/*/*/**Da questi risultati (memorizzati sotto ${{ env.GIT_DIFF }}) genero quindi la chiamata al comando personalizzato package-entries-json, e lo imposto come output:
- id: output_data
name: Calculate matrix for packages
run: |
quote=\'
clean_diff="$(echo "${{ env.GIT_DIFF }}" | sed -e s/$quote//g)"
packages_in_diff="$(echo $clean_diff | grep -E -o 'layers/[A-Za-z0-9_\-]*/[A-Za-z0-9_\-]*/[A-Za-z0-9_\-]*/' | sort -u)"
echo "[Packages in diff] $(echo $packages_in_diff | tr '\n' ' ')"
filter_arg="--filter=$(echo $packages_in_diff | sed -e 's/ / --filter=/g')"
echo "::set-output name=matrix::$(vendor/bin/monorepo-builder package-entries-json $(echo $filter_arg))"I package risultanti vengono poi utilizzati per creare la matrice:
outputs:
matrix: ${{ steps.output_data.outputs.matrix }}Funziona alla grande! In questo caso, solo due package sono stati modificati, e quindi sono state lanciate solo 2 istanze nella matrice:

Ora, unire la PR potrebbe richiedere solo pochi minuti (rispetto a 1 ora), quindi sono di nuovo uno sviluppatore felice.
Ulteriori ottimizzazioni/sfide
C'è un'altra situazione in cui posso ridurre il tempo della GitHub Action: durante l'esecuzione dei test PHPUnit.
Attualmente, ogni volta che viene caricato un nuovo pezzo di codice, viene eseguita l'intera batteria di test per tutti i package. Ma anche qui, questo può essere ottimizzato.
Diciamo che il monorepo contiene 3 package: A, B e C, dove B dipende da A, e C dipende da B.
Allora, se modifichiamo il codice di un solo package, i test che richiedono l'esecuzione varieranno:
- Modificare il codice di A: si devono testare A, B e C
- Modificare il codice di B: si devono testare B e C
- Modificare il codice di C: si deve testare C
L'ottimizzazione dipenderà quindi dall'ottenere la lista dei package modificati (come nell'ottimizzazione precedente), ed eseguire i test per essi e per tutti i package che dipendono da essi.
Tuttavia, attualmente non possiedo l'informazione su come ogni package nel monorepo dipenda dagli altri.
Anche se il composer.json radice contiene tutti i package locali, non posso ottenere le loro dipendenze tramite Composer eseguendo composer info ${ package_name }, perché sono stati definiti nella sezione replace, invece che in require.
In alternativa, potrei entrare nella sottocartella di ogni package, eseguire composer install, e poi fare composer info. Ma eseguire composer install oltre 200 volte sarebbe pura follia.
Pertanto, non ho ancora ottimizzato questo scenario. Finora ho creato la issue, e spero di trovare prima o poi una soluzione.
Conclusione
Devo dire che sono estremamente felice di aver scoperto il Monorepo Builder. Non penso che sarei in grado di gestire il codice sorgente di Gato GraphQL altrimenti.
Non sto dicendo che ogni progetto dovrebbe usarlo. Ma quando hai oltre 200 package, come nel mio caso, o magari anche più di 20, allora semplifica assolutamente la vita.
Gestire il monorepo richiede un po' di tempo e sforzo per la configurazione e la manutenzione, ma risparmio quel tempo e quello sforzo molteplici volte ogni giorno, semplicemente con lo sviluppo continuo.