Blog

💁🏻‍♀️ Waarom Gato GraphQL een Monorepo nodig heeft, en hoe het geoptimaliseerd is

Leonardo Losoviz
Door Leonardo Losoviz ·

Een paar dagen geleden publiceerde ik het artikel Hosting all your PHP packages together in a monorepo, waarin ik uitleg waarom je een monorepo wilt gebruiken om je PHP-codebase te beheren en hoe je dat doet via de Monorepo Builder.

Hier wil ik dat artikel aanvullen door iets meer in detail uit te leggen waarom de GatoGraphQL/GatoGraphQL codebase (die Gato GraphQL host, de onderliggende GraphQL-engine en de component-model-architectuur waarop het is gebaseerd) op een monorepo moet worden gehost, en welke optimalisaties ik daarin heb doorgevoerd.

Waarom Gato GraphQL een monorepo nodig heeft

Om CMS-agnosticisme te ondersteunen werd de codebase van Gato GraphQL en verwante projecten opgesplitst in een groot aantal pakketten, beheerd via Composer. In totaal werden er meer dan 100 pakketten aangemaakt! (Momenteel is dat aantal al meer dan 200.)

Het grote aantal pakketten voegt geen extra complexiteit toe bij het samenvoegen via Composer: je voert gewoon composer install uit en alles werkt. Het wordt echter problematisch bij de ontwikkeling als elk afzonderlijk pakket in zijn eigen repository leeft, vanwege versiebeheer.

Elk pakket moet worden geversioneerd, en elke versie van een pakket is afhankelijk van een bepaalde versie van een ander pakket. Met zoveel pakketten zou het configureren van alle onderlinge versieafhankelijkheden bij het aanmaken van PR's een nachtmerrie worden, vergelijkbaar met een bord spaghetticode, waarbij je de punt van één sliertje ziet, maar niet weet waar het eindigt.

Op zoek naar het andere uiteinde

De waarheid is dat het koppelen van alle versies van de meerdere branches van alle betrokken repositories zo moeilijk werd, dat ik dit proces volledig oversloeg en de code rechtstreeks naar de master branch van elk repo pushte, om vervolgens in elk repo de dev-master versie te gebruiken.

Dat was niet goed. Overstappen op het monorepo-model, waarbij alle code in GatoGraphQL/GatoGraphQL wordt gehost, heeft het probleem effectief opgelost.

Welkom neveneffect: lagere drempel voor bijdragen

Zoals ik in het artikel vermeldde, verliet destijds, toen het project één repo per pakket gebruikte, een bijdrager het project nog voordat hij was begonnen, omdat hij de werkomgeving niet kon instellen.

Voordat we overstapten op de monorepo was het instellen van de ontwikkelomgeving erg moeilijk. Omdat ik de auteur was, kon ik alle repo's klonen en ze allemaal samenvoegen in één VSCode-workspace, zodat het bij mij min of meer werkte.

Ik probeerde het voor potentiële bijdragers makkelijker te maken om dezelfde omgeving in te stellen via dit bash-script. Maar eerlijk gezegd kon dat nooit werken — het was een verloren strijd van het begin af aan, en niemand kon beginnen met bijdragen aan het project.

Met de monorepo kan ik 's nachts gerust slapen, wetende dat ik bijdragers niet zal afschrikken met onredelijke bureaucratie, mocht iemand ooit betrokken willen raken.

De monorepo optimaliseren

Zoals ik in het artikel vermeldde, is het voordeel van de Monorepo Builder-bibliotheek ten opzichte van alternatieven dat het is gebouwd met PHP en dat je het kunt uitbreiden.

Wanneer je bijvoorbeeld naar master pusht en de monorepo splitst, start de matrix in de GitHub Action normaal gesproken één runner-instantie per pakket om de code te synchroniseren met de eigen repository (voor distributie via Packagist).

Omdat GatoGraphQL/GatoGraphQL meer dan 200 pakketten bevat, betekende dit dat er meer dan 200 runner-instanties werden gestart.

Meer dan 200 pakketten verwerken

Het probleem is dat GitHub je beperkt tot 20 gelijktijdig uitgevoerde taken. Omdat alle acties in een wachtrij worden geplaatst, moest ik wachten tot ze klaar waren voordat ik andere acties kon uitvoeren.

Bovendien provisiert GitHub af en toe niet meteen een runner en laat het je wachten tot een later moment:

Wachten tot runners beschikbaar zijn

Dit alles vertaalt zich in wachttijd. Met meer dan 200 pakketten kon het mergen van één PR wel een uur duren! Dit was een probleem dat opgelost moest worden.

De monorepo uitbreiden met aangepaste commando's kan het probleem oplossen.

De Monorepo Builder uitbreiden

Normaal gesproken, wanneer je het volgende commando uitvoert, krijg je de lijst van alle pakketten in de repo:

vendor/bin/monorepo-builder packages-json

De lijst van alle pakketten in de repo ophalen

Maar toen dacht ik: er is geen reden om alle pakketten te synchroniseren, maar alleen de pakketten die code bevatten die in de PR is gewijzigd.

Als we de lijst van gewijzigde bestanden kunnen achterhalen, kunnen we berekenen welke pakketten ze bevatten. Met andere woorden: git diff uitvoeren en de resultaten doorgeven aan het commando packages-json via een filter-input, zoals dit:

vendor/bin/monorepo-builder packages-json --filter=modified_file_1 --filter=modified_file_2 --filter=...

Het packages-json-commando dat bij de Monorepo Builder wordt geleverd, accepteert geen filter-input. Dit is dus het moment waarop we het moeten uitbreiden met onze eigen commando's.

De Monorepo Builder gebruikt Symfony's DependencyInjection, zodat het uitgebreid kan worden door nieuwe services in de container te injecteren. Het configuratiebestand monorepo-builder.php is al een service configurator.

Ik heb de Monorepo Builder dan ook uitgebreid met een nieuw commando genaamd package-entries-json, dat de filter-input ondersteunt:

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;
  }
}

Het wordt in de service-container geïnjecteerd als volgt:

return static function (ContainerConfigurator $containerConfigurator): void {
    $services = $containerConfigurator->services();
    $services->defaults()->autowire()->autoconfigure();
    $services->set(PackageEntriesJsonCommand::class);
}

Nu is het nieuwe commando package-entries-json beschikbaar voor de GitHub Action-workflow.

De lijst van gewijzigde bestanden ophalen in de GitHub Action

Laten we nu kijken hoe we de workflow moeten bijwerken.

Ik gebruik handig de actie technote-space/get-diff-action, die de git diff geeft van alle gewijzigde bestanden in de PR:

# git diff to generate matrix with modified packages only
- uses: technote-space/get-diff-action@v4
  with:
    PATTERNS: layers/*/*/*/**

Op basis van deze resultaten (opgeslagen onder ${{ env.GIT_DIFF }}) genereer ik vervolgens de aanroep naar het aangepaste commando package-entries-json en stel het in als 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))"

De resulterende pakketten worden vervolgens gebruikt om de matrix aan te maken:

outputs:
  matrix: ${{ steps.output_data.outputs.matrix }}

Het werkt geweldig! In dit geval waren er slechts twee pakketten gewijzigd, dus werden er ook maar 2 instanties gestart in de matrix:

De lijst van gewijzigde pakketten ophalen

Nu kan het mergen van een PR slechts een paar minuten duren (in plaats van 1 uur), dus ben ik weer een blije ontwikkelaar.

Verdere optimalisaties/uitdagingen

Er is nog een situatie waarin ik tijd kan besparen bij GitHub Actions: bij het uitvoeren van de PHPUnit-tests.

Op dit moment wordt, telkens wanneer een nieuw stuk code wordt geüpload, de volledige testbatterij voor alle pakketten uitgevoerd. Maar ook dit kan geoptimaliseerd worden.

Stel dat de monorepo 3 pakketten bevat: A, B en C, waarbij B afhankelijk is van A en C afhankelijk is van B.

Als we dan code van slechts één pakket wijzigen, variëren de tests die uitgevoerd moeten worden:

  • Code van A wijzigen: A, B en C moeten getest worden
  • Code van B wijzigen: B en C moeten getest worden
  • Code van C wijzigen: C moet getest worden

De optimalisatie hangt dan af van het ophalen van de lijst van gewijzigde pakketten (zoals bij de vorige optimalisatie) en het uitvoeren van tests voor die pakketten én alle pakketten die ervan afhankelijk zijn.

Ik beschik momenteel echter niet over de informatie over hoe elk pakket in de monorepo van de andere afhankelijk is.

Hoewel de root composer.json alle lokale pakketten bevat, kan ik hun afhankelijkheden niet via Composer ophalen door composer info ${ package_name } uit te voeren, omdat ze zijn gedefinieerd in de replace-sectie, in plaats van require.

Als alternatief zou ik in elke submap van een pakket kunnen stappen, composer install uitvoeren en vervolgens composer info doen. Maar composer install meer dan 200 keer uitvoeren zou pure waanzin zijn.

Ik heb dit scenario dan ook nog niet geoptimaliseerd. Tot nu toe heb ik een issue aangemaakt en hoop ik uiteindelijk een oplossing te vinden.

Afsluiting

Ik moet zeggen dat ik enorm blij ben met de ontdekking van de Monorepo Builder. Ik denk niet dat ik de codebase van Gato GraphQL op een andere manier zou kunnen beheren.

Ik beweer niet dat elk project het zou moeten gebruiken. Maar als je meer dan 200 pakketten hebt, zoals in mijn geval, of zelfs meer dan 20, dan maakt het je leven absoluut eenvoudiger.

Het beheren van de monorepo kost wat tijd en moeite om in te stellen en te onderhouden, maar die tijd en moeite verdien ik dagelijks vele malen terug, puur door de doorlopende ontwikkeling.


Abonneer je op onze nieuwsbrief

Blijf op de hoogte van alle updates over Gato GraphQL.