💁🏻♀️ Warum Gato GraphQL ein Monorepo braucht, und wie es optimiert ist
Vor ein paar Tagen habe ich den Artikel Alle deine PHP-Pakete zusammen in einem Monorepo hosten veröffentlicht, in dem ich erkläre, warum wir ein Monorepo zur Verwaltung unseres PHP-Quellcodes verwenden möchten und wie das mit dem Monorepo Builder geht.
Hier möchte ich diesen Artikel ergänzen und etwas ausführlicher erklären, warum der Quellcode von GatoGraphQL/GatoGraphQL (der Gato GraphQL, seine zugrundeliegende GraphQL-Engine und die Komponentenmodell-Architektur, auf der er basiert, hostet) in einem Monorepo gehostet werden muss, und welche Optimierungen ich daran vorgenommen habe.
Warum Gato GraphQL ein Monorepo braucht
Um CMS-Agnostizismus zu unterstützen, wurde der Quellcode von Gato GraphQL und zugehörigen Projekten in eine Vielzahl von Paketen aufgeteilt, die über Composer verwaltet werden. Insgesamt wurden über 100 Pakete erstellt! (Aktuell sind es über 200.)
Die große Anzahl an Paketen fügt beim Zusammenstellen über Composer keine zusätzliche Komplexität hinzu: Wir führen einfach composer install aus, und alles funktioniert. Es wird jedoch problematisch für die Entwicklung, wenn jedes einzelne Paket in seinem eigenen Repository lebt, wegen der Versionierung.
Jedes Paket muss versioniert werden, und jede Version eines Pakets hängt von einer bestimmten Version eines anderen Pakets ab. Bei so vielen Paketen wäre es ein Albtraum, beim Erstellen von PRs zu konfigurieren, wie alle Versionen voneinander abhängen – vergleichbar mit einem Teller Spaghetti-Code, bei dem man das Ende einer Nudel sieht, aber nicht weiß, wo sie endet.

Die Wahrheit ist, dass es so schwierig wurde, alle Versionen der verschiedenen Branches aller beteiligten Repositories zu verknüpfen, dass ich diesen Prozess komplett übersprungen habe – den Code direkt auf den master-Branch jedes Repos gepusht habe und dann bei jedem die Version dev-master verwendet habe.
Das war nicht korrekt. Der Wechsel zum Monorepo-Modell, bei dem der gesamte Code in GatoGraphQL/GatoGraphQL gehostet wird, hat das Problem effektiv gelöst.
Willkommener Nebeneffekt: Niedrigere Hürde für Beiträge
Wie ich im Artikel erwähnt habe, hat damals, als das Projekt ein Repo pro Paket verwendete, ein Mitwirkender das Projekt verlassen, bevor er überhaupt angefangen hatte, weil er die Arbeitsumgebung nicht einrichten konnte.
Bevor ich auf das Monorepo umgestiegen bin, war die Einrichtung der Entwicklungsumgebung sehr schwierig. Da ich der Autor war, konnte ich alle Repos klonen und sie alle in einem einzigen VSCode-Workspace zusammenfügen, sodass es für mich irgendwie funktionierte.
Ich habe versucht, die Einrichtung derselben Umgebung für potenzielle Mitwirkende zu erleichtern, über dieses Bash-Skript. Aber ehrlich gesagt hätte das nie funktionieren können – es war von Anfang an ein verlorener Kampf, und niemand konnte anfangen, zum Projekt beizutragen.
Mit dem Monorepo kann ich ruhig schlafen, in dem Wissen, dass ich Mitwirkende nicht mit unsinniger Bürokratie abschrecken werde, falls sie sich je beteiligen möchten.
Das Monorepo optimieren
Wie ich im Artikel erwähnt habe, besteht der Vorteil der Nutzung der Monorepo Builder-Bibliothek gegenüber den Alternativen darin, dass sie in PHP gebaut ist und dass wir sie erweitern können.
Zum Beispiel startet die Matrix in der GitHub Action beim Push auf master und beim Aufteilen des Monorepos normalerweise eine Runner-Instanz pro Paket, um dessen Code mit seinem eigenen Repository zu synchronisieren (zur Verteilung über Packagist).
Da GatoGraphQL/GatoGraphQL über 200 Pakete enthält, bedeutete das, dass über 200 Runner-Instanzen gestartet wurden.

Das Problem hier ist, dass GitHub dir ein Limit von 20 parallel laufenden Jobs auferlegt. Da alle Actions in eine Warteschlange gestellt werden, musste ich warten, bis sie fertig waren, bevor ich weitere Actions ausführen konnte.
Außerdem stellt GitHub gelegentlich keinen Runner sofort bereit und lässt dich bis zu einem späteren Zeitpunkt warten:

All das bedeutet Wartezeit. Bei über 200 Paketen konnte das Zusammenführen eines einzelnen PRs bis zu 1 Stunde dauern! Das war ein Problem, das gelöst werden musste.
Das Erweitern des Monorepos mit benutzerdefinierten Befehlen kann das Problem lösen.
Den Monorepo builder erweitern
Normalerweise erhalten wir beim Ausführen des folgenden Befehls die Liste aller Pakete im Repo:
vendor/bin/monorepo-builder packages-json
Aber dann dachte ich: Es ist nicht notwendig, alle Pakete zu synchronisieren, sondern nur diejenigen, die Code enthalten, der im PR geändert wurde.
Wenn wir die Liste der geänderten Dateien herausfinden können, können wir berechnen, welche die geänderten Pakete sind, die sie enthalten. Mit anderen Worten: git diff ausführen und die Ergebnisse an den Befehl packages-json übergeben, über einen filter-Input, wie folgt:
vendor/bin/monorepo-builder packages-json --filter=modified_file_1 --filter=modified_file_2 --filter=...Nun akzeptiert der mit dem Monorepo Builder gelieferte packages-json-Befehl keinen filter-Input. Deshalb müssen wir ihn mit unseren benutzerdefinierten Befehlen erweitern.
Der Monorepo builder verwendet Symfonys DependencyInjection, sodass er durch das Injizieren neuer Services in seinen Container erweitert werden kann. Tatsächlich ist die Konfigurationsdatei monorepo-builder.php bereits ein Service-Konfigurator.
Also habe ich den Monorepo builder mit einem neuen Befehl namens package-entries-json erweitert, der den filter-Input unterstützt:
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;
}
}Er wird in den Service-Container folgendermaßen injiziert:
return static function (ContainerConfigurator $containerConfigurator): void {
$services = $containerConfigurator->services();
$services->defaults()->autowire()->autoconfigure();
$services->set(PackageEntriesJsonCommand::class);
}Jetzt ist der neue Befehl package-entries-json für den GitHub Action-Workflow verfügbar.
Die Liste der geänderten Dateien in der GitHub Action abrufen
Schauen wir uns nun an, wie wir den Workflow aktualisieren.
Ich verwende praktischerweise die Action technote-space/get-diff-action, die das git diff aller in der PR geänderten Dateien liefert:
# git diff to generate matrix with modified packages only
- uses: technote-space/get-diff-action@v4
with:
PATTERNS: layers/*/*/*/**Aus diesen Ergebnissen (gespeichert unter ${{ env.GIT_DIFF }}) generiere ich dann den Aufruf des benutzerdefinierten Befehls package-entries-json und setze ihn 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))"Die resultierenden Pakete werden dann verwendet, um die Matrix zu erstellen:
outputs:
matrix: ${{ steps.output_data.outputs.matrix }}Es funktioniert hervorragend! In diesem Fall wurden nur zwei Pakete geändert, sodass nur 2 Instanzen in der Matrix gestartet wurden:

Jetzt kann das Zusammenführen des PRs nur noch wenige Minuten dauern (statt 1 Stunde) – ich bin also wieder ein glücklicher Entwickler.
Weitere Optimierungen / Herausforderungen
Es gibt noch eine weitere Situation, in der ich Zeit bei der GitHub Action sparen kann: bei der Ausführung der PHPUnit-Tests.
Derzeit wird jedes Mal, wenn ein neues Stück Code hochgeladen wird, die gesamte Testbatterie für alle Pakete ausgeführt. Aber auch das lässt sich optimieren.
Angenommen, das Monorepo enthält 3 Pakete: A, B und C, wobei B von A abhängt und C von B abhängt.
Wenn wir dann den Code eines einzelnen Pakets allein ändern, variieren die Tests, die ausgeführt werden müssen:
- Code von A ändern: A, B und C müssen getestet werden
- Code von B ändern: B und C müssen getestet werden
- Code von C ändern: C muss getestet werden
Die Optimierung hängt dann davon ab, die Liste der geänderten Pakete zu erhalten (wie bei der vorherigen Optimierung) und Tests für diese und für alle Pakete auszuführen, die von ihnen abhängen.
Allerdings habe ich derzeit keine Information darüber, wie jedes Paket im Monorepo von den anderen abhängt.
Obwohl die Root-Datei composer.json alle lokalen Pakete enthält, kann ich ihre Abhängigkeiten nicht über Composer abrufen, indem ich composer info ${ package_name } ausführe, da sie im replace-Abschnitt statt in require definiert wurden.
Alternativ könnte ich in den Unterordner jedes Pakets wechseln, composer install ausführen und dann composer info machen. Aber composer install über 200 Mal auszuführen wäre scheer Wahnsinn.
Daher habe ich dieses Szenario noch nicht optimiert. Ich habe bisher das Issue erstellt und hoffe, irgendwann eine Lösung zu finden.
Fazit
Ich muss sagen, dass ich äußerst froh bin, den Monorepo Builder entdeckt zu haben. Ich glaube nicht, dass ich in der Lage wäre, den Quellcode von Gato GraphQL sonst zu verwalten.
Ich sage nicht, dass jedes Projekt ihn verwenden sollte. Aber wenn du über 200 Pakete hast, wie in meinem Fall, oder möglicherweise sogar über 20, dann vereinfacht er das Leben absolut.
Die Verwaltung des Monorepos erfordert etwas Zeit und Aufwand für Einrichtung und Wartung, aber ich spare diese Zeit und diesen Aufwand jeden Tag mehrmals, einfach durch die laufende Entwicklung.