💁🏻♀️ Pourquoi Gato GraphQL a besoin d'un Monorepo, et comment il est optimisé
Il y a quelques jours, j'ai publié l'article Héberger tous vos packages PHP ensemble dans un monorepo, expliquant pourquoi nous pourrions vouloir utiliser un monorepo pour gérer notre code source PHP, et comment le faire via le Monorepo Builder.
Ici, j'aimerais compléter cet article en expliquant plus en détail pourquoi le code source de GatoGraphQL/GatoGraphQL (qui héberge Gato GraphQL, son moteur GraphQL sous-jacent et l'architecture de modèle de composants sur laquelle il repose) doit être hébergé sur un monorepo, ainsi que les optimisations que j'y ai apportées.
Pourquoi Gato GraphQL a besoin d'un monorepo
Afin de prendre en charge l'agnosticisme CMS, le code source de Gato GraphQL et des projets associés a été divisé en une multitude de packages, gérés via Composer. Au total, plus de 100 packages ont été créés ! (Actuellement, le nombre dépasse 200.)
Le grand nombre de packages n'ajoute pas de complexité supplémentaire pour les assembler tous via Composer : nous exécutons simplement composer install, et tout fonctionne. Cependant, cela devient problématique pour le développement lorsque chaque package vit dans son propre dépôt, en raison du versionnage.
Chaque package doit être versionné, et chaque version d'un package dépendra d'une version d'un autre package. Avec autant de packages, configurer la façon dont toutes les versions dépendent les unes des autres lors de la création de PR deviendrait un cauchemar, ressemblant à une assiette de code spaghetti, où l'on voit le bout d'une nouille, mais on ne sait pas où elle se termine.

La vérité, c'est que c'est devenu si difficile de lier toutes les versions des multiples branches de tous les dépôts impliqués que j'ai fini par sauter complètement ce processus, en poussant le code directement vers la branche master sur chaque dépôt, puis en dépendant de la version dev-master sur chacun d'eux.
Ce n'était pas correct. Passer au modèle monorepo, en hébergeant tout le code dans GatoGraphQL/GatoGraphQL, a effectivement résolu le problème.
Effet secondaire bienvenu : une barrière plus basse pour les contributions
Comme je l'ai mentionné dans l'article, à l'époque où le projet utilisait un dépôt par package, un contributeur a abandonné le projet avant même de l'avoir rejoint, en raison de son incapacité à configurer l'environnement de travail.
Avant de passer au monorepo, la configuration de l'environnement de développement était très difficile. Étant l'auteur, je pouvais me débrouiller pour cloner tous les dépôts et les ajouter tous ensemble dans un seul espace de travail VSCode, donc ça fonctionnait à peu près pour moi.
J'ai essayé de faciliter la configuration du même environnement pour les contributeurs potentiels, via ce script bash. Mais sérieusement, ça ne pouvait jamais fonctionner, c'était une bataille perdue d'avance, et personne ne pouvait commencer à contribuer au projet.
Avec le monorepo, je peux dormir sur mes deux oreilles, sachant que je ne rejetterai pas les contributeurs avec une bureaucratie déraisonnable, s'ils souhaitent un jour s'impliquer.
Optimiser le monorepo
Comme je l'ai mentionné dans l'article, l'avantage d'utiliser la bibliothèque Monorepo Builder par rapport aux alternatives est qu'elle est construite avec PHP et que nous pouvons l'étendre.
Par exemple, lors d'un push vers master et du découpage du monorepo, la matrice dans la GitHub Action lance normalement une instance de runner par package, pour synchroniser son code avec son propre dépôt (pour la distribution via Packagist).
Comme GatoGraphQL/GatoGraphQL contient plus de 200 packages, cela signifiait que plus de 200 instances de runners étaient lancées.

Le problème ici est que GitHub vous donne une limite de 20 jobs s'exécutant en parallèle. Comme toutes les actions sont placées dans une file d'attente, je devais attendre qu'elles se terminent pour continuer à exécuter d'autres actions.
De plus, de temps à autre, GitHub ne provisionne pas immédiatement un runner et vous fait attendre jusqu'à une heure ultérieure :

Tout cela se traduit par un temps d'attente. Avec plus de 200 packages, fusionner une seule PR pouvait prendre jusqu'à 1 heure ! C'est un problème qui nécessitait d'être résolu.
Étendre le monorepo avec des commandes personnalisées peut résoudre le problème.
Étendre le Monorepo builder
Normalement, lors de l'exécution de la commande suivante, nous obtenons la liste de tous les packages dans le dépôt :
vendor/bin/monorepo-builder packages-json
Mais j'ai alors pensé : il n'est pas nécessaire de synchroniser tous les packages, mais seulement ceux contenant du code qui a été modifié dans la PR.
Si nous pouvons obtenir la liste des fichiers modifiés, nous pouvons calculer quels sont les packages modifiés qui les contiennent. En d'autres termes : exécuter git diff, et alimenter les résultats dans la commande packages-json, via une entrée filter, comme ceci :
vendor/bin/monorepo-builder packages-json --filter=modified_file_1 --filter=modified_file_2 --filter=...Or, la commande packages-json fournie avec le Monorepo Builder n'accepte pas d'entrée filter. C'est donc là que nous devons l'étendre avec nos commandes personnalisées.
Le Monorepo builder utilise DependencyInjection de Symfony, il peut donc être étendu en injectant de nouveaux services dans son conteneur. En effet, le fichier de configuration monorepo-builder.php est déjà un configurateur de services.
J'ai donc étendu le Monorepo builder avec une nouvelle commande nommée package-entries-json, qui prend en charge l'entrée 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;
}
}Elle est injectée dans le conteneur de services comme ceci :
return static function (ContainerConfigurator $containerConfigurator): void {
$services = $containerConfigurator->services();
$services->defaults()->autowire()->autoconfigure();
$services->set(PackageEntriesJsonCommand::class);
}Désormais, la nouvelle commande nommée package-entries-json sera disponible pour le workflow de GitHub Action.
Obtenir la liste des fichiers modifiés dans la GitHub Action
Voyons maintenant comment mettre à jour le workflow.
J'utilise judicieusement l'action technote-space/get-diff-action, qui fournit le git diff de tous les fichiers modifiés dans la PR :
# git diff to generate matrix with modified packages only
- uses: technote-space/get-diff-action@v4
with:
PATTERNS: layers/*/*/*/**À partir de ces résultats (stockés sous ${{ env.GIT_DIFF }}), je génère ensuite l'appel à la commande personnalisée package-entries-json, et je le définis comme sortie :
- 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))"Les packages résultants sont ensuite utilisés pour créer la matrice :
outputs:
matrix: ${{ steps.output_data.outputs.matrix }}Ça fonctionne très bien ! Dans ce cas, seulement deux packages ont été modifiés, et donc seulement 2 instances ont été lancées dans la matrice :

Désormais, la fusion de la PR ne prend que quelques minutes (contre 1 heure auparavant), et je suis à nouveau un développeur heureux.
Autres optimisations/défis
Il y a une autre situation dans laquelle je peux réduire le temps de la GitHub Action : lors de l'exécution des tests PHPUnit.
Actuellement, chaque fois qu'un nouveau morceau de code est téléchargé, la batterie complète de tests pour tous les packages est exécutée. Mais là encore, cela peut être optimisé.
Disons que le monorepo contient 3 packages : A, B et C, où B dépend de A, et C dépend de B.
Alors, si nous modifions le code d'un seul package, les tests qui doivent être exécutés varieront :
- Modifier le code de A : doit tester A, B et C
- Modifier le code de B : doit tester B et C
- Modifier le code de C : doit tester C
L'optimisation dépendra alors de l'obtention de la liste des packages modifiés (comme dans l'optimisation précédente), et de l'exécution des tests pour ceux-ci et pour tous les packages qui en dépendent.
Cependant, je ne dispose actuellement pas de l'information sur la façon dont chaque package du monorepo dépend des autres.
Bien que le composer.json racine contienne tous les packages locaux, je ne peux pas obtenir leurs dépendances via Composer en exécutant composer info ${ package_name }, car ils ont été définis dans la section replace, au lieu de require.
Alternativement, je pourrais entrer dans le sous-dossier de chaque package, exécuter composer install, puis faire composer info. Mais exécuter composer install plus de 200 fois serait de la pure folie.
Par conséquent, je n'ai pas encore optimisé ce scénario. J'ai jusqu'à présent créé l'issue, et j'espère finalement trouver une solution.
Conclusion
Je dois dire que je suis extrêmement heureux d'avoir découvert le Monorepo Builder. Je ne pense pas que je serais capable de gérer le code source de Gato GraphQL autrement.
Je ne dis pas que chaque projet devrait l'utiliser. Mais quand vous avez plus de 200 packages, comme dans mon cas, ou éventuellement même plus de 20, alors cela simplifie absolument votre vie.
Gérer le monorepo demande un peu de temps et d'effort pour l'installer et le maintenir, mais j'économise ce temps et cet effort plusieurs fois chaque jour, rien qu'avec le développement continu.