Blog

๐Ÿ’๐Ÿปโ€โ™€๏ธ Mengapa Gato GraphQL Membutuhkan Monorepo, dan Bagaimana Cara Mengoptimalkannya

Leonardo Losoviz
Oleh Leonardo Losoviz ยท

Beberapa hari lalu saya menerbitkan artikel Menampung semua paket PHP Anda bersama-sama dalam sebuah monorepo, yang menjelaskan mengapa kita mungkin ingin menggunakan monorepo untuk mengelola basis kode PHP kita, dan bagaimana melakukannya melalui Monorepo Builder.

Di sini saya ingin melengkapi artikel tersebut, menjelaskan sedikit lebih detail mengapa basis kode GatoGraphQL/GatoGraphQL (yang menampung Gato GraphQL, mesin GraphQL yang mendasarinya, dan arsitektur component-model yang menjadi landasannya) perlu di-host dalam sebuah monorepo, serta optimasi yang telah saya lakukan untuk itu.

Mengapa Gato GraphQL membutuhkan monorepo

Untuk mendukung CMS-agnosticism, basis kode Gato GraphQL dan proyek-proyek terkait dipecah menjadi sejumlah besar paket, yang dikelola melalui Composer. Secara keseluruhan, lebih dari 100 paket telah dibuat! (Saat ini, jumlahnya sudah lebih dari 200.)

Jumlah paket yang besar tidak menambah kompleksitas ekstra dalam merakit semuanya bersama-sama melalui Composer: kita cukup menjalankan composer install, dan semuanya berjalan. Namun, hal ini memang menjadi masalah dalam pengembangan ketika setiap paket tinggal di repositorinya sendiri, karena masalah versioning.

Setiap paket harus diberi versi, dan setiap versi dari sebuah paket akan bergantung pada versi tertentu dari paket lain. Dengan begitu banyak paket, mengonfigurasi bagaimana semua versi saling bergantung ketika membuat PR akan menjadi mimpi buruk, menyerupai sepiring spaghetti code, di mana Anda bisa melihat ujung satu mie, tetapi tidak tahu di mana ia berakhir.

Mencari ujung yang lain

Faktanya, menautkan semua versi dari berbagai cabang di semua repositori yang terlibat menjadi sangat sulit, sehingga saya langsung melewatkan proses ini sama sekali, mendorong kode langsung ke cabang master di setiap repo, lalu bergantung pada versi dev-master di masing-masing.

Itu tidak benar. Beralih ke model monorepo, menampung semua kode di GatoGraphQL/GatoGraphQL, telah secara efektif menyelesaikan masalah.

Efek samping yang disambut: Hambatan kontribusi yang lebih rendah

Seperti yang saya sebutkan dalam artikel, dulu ketika proyek menggunakan satu repo per paket, seorang kontributor meninggalkan proyek bahkan sebelum bergabung, karena ketidakmampuannya menyiapkan lingkungan kerja.

Sebelum beralih ke monorepo, menyiapkan lingkungan pengembangan sangatlah sulit. Karena saya adalah penulisnya, saya bisa mengelola untuk mengkloning semua repo, dan menambahkannya bersama-sama di bawah satu workspace VSCode, sehingga kurang lebih berhasil untuk saya.

Saya mencoba memudahkan kontributor potensial untuk menyiapkan lingkungan yang sama, melalui script bash ini. Tapi jujur saja, itu tidak akan pernah berhasil, itu adalah pertempuran yang kalah sejak awal, dan tidak ada yang bisa mulai berkontribusi pada proyek.

Dengan monorepo, saya bisa tidur nyenyak di malam hari, mengetahui bahwa saya tidak akan menolak kontributor dengan birokrasi yang tidak masuk akal, jika mereka pernah ingin terlibat.

Mengoptimalkan monorepo

Seperti yang saya sebutkan dalam artikel, keunggulan menggunakan library Monorepo Builder dibandingkan alternatif lainnya adalah bahwa ia dibangun dengan PHP, dan kita dapat memperluasnya.

Misalnya, ketika melakukan push ke master dan memisah monorepo, matrix dalam GitHub Action biasanya akan menjalankan satu runner instance per paket, untuk menyinkronkan kodenya dengan repositorinya sendiri (untuk distribusi melalui Packagist).

Karena GatoGraphQL/GatoGraphQL berisi lebih dari 200 paket, itu berarti lebih dari 200 runner instance diluncurkan.

Memproses lebih dari 200 paket

Masalahnya di sini adalah GitHub memberi Anda batas 20 job yang berjalan secara paralel. Karena semua action ditempatkan dalam antrian, saya perlu menunggu mereka selesai untuk melanjutkan menjalankan action lainnya.

Selain itu, sesekali GitHub tidak menyediakan runner secara langsung, dan membuat Anda menunggu hingga beberapa waktu kemudian:

Menunggu runner tersedia

Semua ini menghasilkan waktu tunggu. Dengan lebih dari 200 paket, melakukan merge satu PR saja bisa memakan waktu hingga 1 jam! Ini adalah masalah yang perlu diselesaikan.

Memperluas monorepo dengan perintah kustom dapat menyelesaikan masalah ini.

Memperluas Monorepo builder

Biasanya, ketika menjalankan perintah berikut, kita akan mendapatkan daftar semua paket dalam repo:

vendor/bin/monorepo-builder packages-json

Mengambil daftar semua paket dalam repo

Tapi kemudian saya berpikir: tidak perlu menyinkronkan semua paket, hanya yang berisi kode yang dimodifikasi dalam PR.

Jika kita bisa mengetahui daftar file yang dimodifikasi, kita dapat menghitung paket mana yang dimodifikasi yang berisi file-file tersebut. Dengan kata lain: jalankan git diff, dan masukkan hasilnya ke perintah packages-json, melalui input filter, seperti ini:

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

Sekarang, perintah packages-json yang disertakan dengan Monorepo Builder tidak menerima input filter. Jadi inilah saatnya kita harus memperluasnya dengan perintah kustom.

Monorepo builder menggunakan DependencyInjection milik Symfony, sehingga dapat diperluas dengan menyuntikkan layanan baru ke dalam containernya. Memang, file konfigurasi monorepo-builder.php sudah merupakan sebuah service configurator.

Jadi saya memperluas Monorepo builder dengan sebuah perintah baru bernama package-entries-json, yang mendukung 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;
  }
}

Ini disuntikkan ke dalam service container seperti ini:

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

Sekarang, perintah baru bernama package-entries-json akan tersedia untuk workflow GitHub Action.

Mendapatkan daftar file yang dimodifikasi dalam GitHub Action

Mari kita lihat sekarang cara memperbarui workflow.

Saya secara praktis menggunakan action technote-space/get-diff-action, yang menyediakan git diff dari semua file yang dimodifikasi dalam PR:

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

Dari hasil-hasil ini (disimpan di bawah ${{ env.GIT_DIFF }}) saya kemudian menghasilkan panggilan ke perintah kustom package-entries-json, dan menetapkannya sebagai 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))"

Paket-paket yang dihasilkan kemudian digunakan untuk membuat matrix:

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

Ini bekerja dengan sangat baik! Dalam contoh ini, hanya dua paket yang dimodifikasi, sehingga hanya 2 instance yang diluncurkan dalam matrix:

Mendapatkan daftar paket yang dimodifikasi

Sekarang, melakukan merge PR mungkin hanya membutuhkan beberapa menit (turun dari 1 jam), sehingga saya kembali menjadi developer yang bahagia.

Optimasi/Tantangan Lebih Lanjut

Ada satu lagi kasus di mana saya dapat mengurangi waktu dari GitHub Action: ketika menjalankan tes PHPUnit.

Saat ini, setiap kali sepotong kode baru diunggah, seluruh rangkaian tes untuk semua paket dijalankan. Tapi sekali lagi, ini bisa dioptimalkan.

Katakanlah monorepo berisi 3 paket: A, B, dan C, di mana B bergantung pada A, dan C bergantung pada B.

Kemudian, jika kita memodifikasi kode dari satu paket saja, tes yang perlu dijalankan akan bervariasi:

  • Memodifikasi kode dari A: harus menguji A, B, dan C
  • Memodifikasi kode dari B: harus menguji B dan C
  • Memodifikasi kode dari C: harus menguji C

Optimasi ini akan bergantung pada mendapatkan daftar paket yang dimodifikasi (seperti pada optimasi sebelumnya), dan menjalankan tes untuk paket-paket tersebut serta semua paket yang bergantung padanya.

Namun, saat ini saya belum memiliki informasi tentang bagaimana setiap paket dalam monorepo bergantung satu sama lain.

Meskipun composer.json root berisi semua paket lokal, saya tidak dapat memperoleh dependensinya melalui Composer dengan menjalankan composer info ${ package_name }, karena mereka telah didefinisikan dalam bagian replace, bukan require.

Sebagai alternatif, saya bisa masuk ke subfolder setiap paket, menjalankan composer install, lalu melakukan composer info. Tetapi menjalankan composer install lebih dari 200 kali akan menjadi kegilaan mutlak.

Oleh karena itu, saya belum mengoptimalkan skenario ini. Sejauh ini saya telah membuat issue, dan berharap pada akhirnya menemukan solusi.

Kesimpulan

Saya harus mengakui bahwa saya sangat senang menemukan Monorepo Builder. Saya tidak berpikir saya bisa mengelola basis kode Gato GraphQL dengan cara lain.

Saya tidak mengatakan bahwa setiap proyek harus menggunakannya. Tetapi ketika Anda memiliki lebih dari 200 paket, seperti dalam kasus saya, atau bahkan mungkin lebih dari 20, maka itu benar-benar menyederhanakan hidup Anda.

Mengelola monorepo memang membutuhkan sedikit waktu dan usaha untuk menyiapkan dan memelihara, tetapi saya menghemat waktu dan usaha itu berkali-kali setiap hari, hanya dari pengembangan yang sedang berjalan.


Berlangganan newsletter kami

Tetap update dengan semua pembaruan Gato GraphQL.