Blog

💁🏻‍♀️ Dlaczego Gato GraphQL potrzebuje Monorepo i jak jest zoptymalizowane

Leonardo Losoviz
Autor: Leonardo Losoviz ·

Kilka dni temu opublikowałem artykuł Hosting all your PHP packages together in a monorepo, wyjaśniając, dlaczego możemy chcieć używać monorepo do zarządzania naszym kodem PHP i jak to zrobić za pomocą Monorepo Builder.

Tutaj chciałbym uzupełnić tamten artykuł, wyjaśniając nieco bardziej szczegółowo, dlaczego kod źródłowy GatoGraphQL/GatoGraphQL (który hostuje Gato GraphQL, jego podstawowy silnik GraphQL oraz architekturę modelu komponentów, na której jest oparty) musi być przechowywany w monorepo, oraz jakie optymalizacje w nim wprowadzono.

Dlaczego Gato GraphQL potrzebuje monorepo

Aby wspierać agnostycyzm CMS, kod źródłowy Gato GraphQL i powiązanych projektów został podzielony na wiele pakietów zarządzanych przez Composer. Łącznie powstało ponad 100 pakietów! (Obecnie ich liczba przekracza 200.)

Duża liczba pakietów nie dodaje żadnej dodatkowej złożoności przy ich łączeniu za pomocą Composera: wystarczy uruchomić composer install i wszystko działa. Jednak staje się to problematyczne podczas tworzenia oprogramowania, gdy każdy pojedynczy pakiet żyje we własnym repozytorium, ze względu na wersjonowanie.

Każdy pakiet musi być wersjonowany, a każda wersja pakietu będzie zależeć od jakiejś wersji innego pakietu. Przy tak wielu pakietach konfigurowanie wzajemnych zależności wersji podczas tworzenia PR-ów stałoby się koszmarem, przypominającym talerz kodu spaghetti, gdzie widać czubek jednego makaronu, ale nie wiadomo, gdzie się kończy.

Szukanie drugiego końca

Prawda jest taka, że łączenie wszystkich wersji z wielu branchy wszystkich zaangażowanych repozytoriów stało się tak trudne, że po prostu pomijałem ten proces w całości, wypychając kod bezpośrednio do brancha master w każdym repo, a następnie polegając na wersji dev-master w każdym z nich.

To nie było właściwe. Przejście na model monorepo, hostując cały kod w GatoGraphQL/GatoGraphQL, skutecznie rozwiązało problem.

Mile widziany efekt uboczny: niższa bariera dla kontrybucji

Jak wspomniałem w artykule, w czasach gdy projekt używał jednego repo na pakiet, jeden kontrybutor porzucił projekt jeszcze przed dołączeniem, bo nie był w stanie skonfigurować środowiska roboczego.

Przed przejściem na monorepo konfigurowanie środowiska deweloperskiego było bardzo trudne. Ponieważ byłem autorem, potrafiłem sklonować wszystkie repo i dodać je razem do jednego workspace'a VSCode, więc u mnie jakoś działało.

Próbowałem ułatwić potencjalnym kontrybutorów konfigurację tego samego środowiska za pomocą tego skryptu bash. Ale szczerze mówiąc, to nigdy nie mogło zadziałać — była to przegrana bitwa od samego początku i nikt nie mógł zacząć wnosić wkładu do projektu.

Dzięki monorepo mogę spać spokojnie w nocy, wiedząc, że nie będę odrzucać kontrybutorów nieuzasadnioną biurokracją, jeśli kiedykolwiek zechcą się zaangażować.

Optymalizacja monorepo

Jak wspomniałem w artykule, zaletą używania biblioteki Monorepo Builder w porównaniu z alternatywami jest to, że jest zbudowana w PHP i można ją rozszerzać.

Na przykład, przy pushu do master i splitowaniu monorepo, matrix w GitHub Action zazwyczaj uruchamia jedną instancję runnera na pakiet, aby zsynchronizować jego kod z własnym repozytorium (do dystrybucji przez Packagist).

Ponieważ GatoGraphQL/GatoGraphQL zawiera ponad 200 pakietów, oznaczało to, że uruchamiano ponad 200 instancji runnera.

Przetwarzanie ponad 200 pakietów

Problem polega na tym, że GitHub narzuca limit 20 zadań uruchomionych równolegle. Ponieważ wszystkie akcje trafiają do kolejki, musiałem czekać na ich zakończenie, żeby kontynuować wykonywanie kolejnych akcji.

Dodatkowo od czasu do czasu GitHub nie przydziela runnera natychmiast i każe czekać do późniejszego momentu:

Oczekiwanie na dostępność runnerów

Wszystko to przekłada się na czas oczekiwania. Przy ponad 200 pakietach merge'owanie jednego PR-a mogło trwać nawet 1 godzinę! To był problem wymagający rozwiązania.

Rozszerzenie monorepo o niestandardowe polecenia może rozwiązać ten problem.

Rozszerzanie Monorepo builder

Normalnie, wykonując poniższe polecenie, otrzymujemy listę wszystkich pakietów w repo:

vendor/bin/monorepo-builder packages-json

Pobieranie listy wszystkich pakietów w repo

Ale wtedy pomyślałem: nie ma potrzeby synchronizowania wszystkich pakietów, a jedynie tych zawierających kod, który został zmodyfikowany w PR-ze.

Jeśli możemy ustalić listę zmodyfikowanych plików, możemy wyliczyć, które pakiety je zawierają. Innymi słowy: uruchomić git diff i przekazać wyniki do polecenia packages-json przez wejście filter, w taki sposób:

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

Polecenie packages-json dostarczone z Monorepo Builder nie przyjmuje wejścia filter. Właśnie tutaj musimy je rozszerzyć o nasze niestandardowe polecenia.

Monorepo builder używa DependencyInjection Symfony, więc można go rozszerzyć przez wstrzyknięcie nowych serwisów do jego kontenera. W rzeczy samej, plik konfiguracyjny monorepo-builder.php jest już konfiguratorem serwisów.

Rozszerzyłem więc Monorepo builder o nowe polecenie o nazwie package-entries-json, które obsługuje wejście 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;
  }
}

Jest wstrzykiwane do kontenera serwisów w taki sposób:

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

Teraz nowe polecenie o nazwie package-entries-json będzie dostępne w workflow GitHub Action.

Pobieranie listy zmodyfikowanych plików w GitHub Action

Zobaczmy teraz, jak zaktualizować workflow.

Wygodnie korzystam z akcji technote-space/get-diff-action, która dostarcza git diff wszystkich zmodyfikowanych plików w PR-ze:

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

Na podstawie tych wyników (przechowywanych w ${{ env.GIT_DIFF }}) generuję wywołanie niestandardowego polecenia package-entries-json i ustawiam je jako 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))"

Wynikowe pakiety są następnie używane do utworzenia matrixa:

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

Działa świetnie! W tym przypadku zmodyfikowano tylko dwa pakiety, więc w matrixie uruchomiono tylko 2 instancje:

Pobieranie listy zmodyfikowanych pakietów

Teraz merge'owanie PR-a może zająć zaledwie kilka minut (zamiast 1 godziny), więc znowu jestem szczęśliwym programistą.

Dalsze optymalizacje/wyzwania

Istnieje jeszcze jeden przypadek, w którym można skrócić czas GitHub Action: przy uruchamianiu testów PHPUnit.

Obecnie za każdym razem, gdy nowy fragment kodu jest przesyłany, uruchamiana jest cała bateria testów dla wszystkich pakietów. Ale i to można zoptymalizować.

Powiedzmy, że monorepo zawiera 3 pakiety: A, B i C, gdzie B zależy od A, a C zależy od B.

Jeśli zmodyfikujemy kod tylko jednego pakietu, testy wymagające uruchomienia będą się różnić:

  • Modyfikacja kodu A: należy przetestować A, B i C
  • Modyfikacja kodu B: należy przetestować B i C
  • Modyfikacja kodu C: należy przetestować C

Optymalizacja będzie więc zależeć od uzyskania listy zmodyfikowanych pakietów (jak w poprzedniej optymalizacji) i uruchomienia testów dla nich oraz dla wszystkich pakietów, które od nich zależą.

Niestety, nie posiadam obecnie informacji o tym, jak każdy pakiet w monorepo zależy od innych.

Chociaż główny composer.json zawiera wszystkie lokalne pakiety, nie mogę uzyskać ich zależności przez Composer, wykonując composer info ${ package_name }, ponieważ zostały zdefiniowane w sekcji replace, zamiast require.

Alternatywnie mógłbym wejść do podfolderu każdego pakietu, uruchomić composer install, a następnie wykonać composer info. Ale uruchamianie composer install ponad 200 razy byłoby czystym szaleństwem.

Dlatego nie zoptymalizowałem jeszcze tego scenariusza. Na razie stworzyłem issue i mam nadzieję, że w końcu uda mi się znaleźć rozwiązanie.

Podsumowanie

Muszę powiedzieć, że jestem niezmiernie zadowolony z odkrycia Monorepo Builder. Nie sądzę, żebym był w stanie w inny sposób zarządzać kodem źródłowym Gato GraphQL.

Nie twierdzę, że każdy projekt powinien go używać. Ale gdy ma się ponad 200 pakietów, jak w moim przypadku, lub nawet ponad 20, to absolutnie upraszcza życie.

Zarządzanie monorepo wymaga trochę czasu i wysiłku przy konfiguracji i utrzymaniu, ale oszczędzam ten czas i wysiłek wielokrotnie każdego dnia, tylko dzięki bieżącemu rozwijaniu projektu.


Zapisz się do naszego newslettera

Bądź na bieżąco ze wszystkimi aktualizacjami Gato GraphQL.