💁🏻♀️ Tại sao Gato GraphQL cần một Monorepo, và cách tối ưu hóa nó
Vài ngày trước, tôi đã đăng bài viết Lưu trữ tất cả các package PHP của bạn trong một monorepo, giải thích tại sao chúng ta có thể muốn sử dụng monorepo để quản lý codebase PHP, và cách thực hiện thông qua Monorepo Builder.
Ở đây, tôi muốn bổ sung cho bài viết đó, giải thích chi tiết hơn một chút về lý do tại sao codebase GatoGraphQL/GatoGraphQL (chứa Gato GraphQL, engine GraphQL bên dưới, và kiến trúc component-model mà nó dựa trên) cần được lưu trữ trên một monorepo, cũng như các tối ưu hóa tôi đã thực hiện cho nó.
Tại sao Gato GraphQL cần một monorepo
Để hỗ trợ tính độc lập với CMS, codebase của Gato GraphQL và các dự án liên quan đã được chia thành rất nhiều package, được quản lý qua Composer. Tổng cộng, hơn 100 package đã được tạo ra! (Hiện tại, con số đã vượt quá 200.)
Số lượng package lớn không thêm độ phức tạp nào trong việc lắp ráp tất cả chúng lại với nhau qua Composer: chúng ta chỉ cần chạy composer install, và mọi thứ hoạt động. Tuy nhiên, nó trở nên rắc rối cho việc phát triển khi mỗi package riêng lẻ tồn tại trên repository của riêng nó, vì vấn đề versioning.
Mỗi package phải được đánh version, và mỗi version của một package sẽ phụ thuộc vào một version nào đó của package khác. Với quá nhiều package như vậy, việc cấu hình cách tất cả các version phụ thuộc lẫn nhau khi tạo PR sẽ trở thành một cơn ác mộng, giống như một đĩa spaghetti code, nơi bạn thấy đầu của một sợi mì nhưng không biết nó kết thúc ở đâu.

Thực tế là, việc liên kết tất cả các version của nhiều branch từ tất cả các repository liên quan đã trở nên quá khó khăn đến mức tôi đã bỏ qua hoàn toàn quy trình này, đẩy code thẳng lên branch master trên mỗi repo, rồi phụ thuộc vào version dev-master trên từng cái.
Điều đó không đúng đắn. Chuyển sang mô hình monorepo, lưu trữ tất cả code trong GatoGraphQL/GatoGraphQL, đã giải quyết được vấn đề một cách hiệu quả.
Tác dụng phụ đáng mừng: Rào cản thấp hơn cho đóng góp
Như tôi đã đề cập trong bài viết, hồi đó khi dự án sử dụng một repo cho mỗi package, có một người đóng góp đã từ bỏ dự án trước khi tham gia, do không thể thiết lập môi trường làm việc.
Trước khi chuyển sang monorepo, việc thiết lập môi trường phát triển rất khó khăn. Vì tôi là tác giả, tôi có thể clone tất cả các repo và thêm chúng vào một workspace VSCode duy nhất, nên nó hoạt động được với tôi theo một cách nào đó.
Tôi đã cố gắng giúp cho những người đóng góp tiềm năng dễ dàng hơn trong việc thiết lập cùng một môi trường, thông qua bash script này. Nhưng thực ra, điều đó không bao giờ có thể hoạt động được, đó là một trận chiến thua từ đầu, và không ai có thể bắt đầu đóng góp cho dự án.
Với monorepo, tôi có thể ngủ ngon vào ban đêm, biết rằng tôi sẽ không từ chối những người đóng góp với thủ tục hành chính không hợp lý, nếu họ muốn tham gia.
Tối ưu hóa monorepo
Như tôi đã đề cập trong bài viết, ưu điểm của việc sử dụng thư viện Monorepo Builder so với các lựa chọn thay thế là nó được xây dựng bằng PHP, và chúng ta có thể mở rộng nó.
Ví dụ, khi thực hiện push lên master và chia tách monorepo, ma trận trong GitHub Action thường sẽ tạo một runner instance cho mỗi package, để đồng bộ hóa code của nó với repository riêng của nó (để phân phối qua Packagist).
Vì GatoGraphQL/GatoGraphQL chứa hơn 200 package, điều đó có nghĩa là hơn 200 runner instance đã được khởi chạy.

Vấn đề ở đây là GitHub giới hạn bạn 20 job chạy song song. Vì tất cả các action được đặt trong hàng đợi, tôi cần phải đợi chúng hoàn thành, mới có thể tiếp tục thực hiện các action khác.
Ngoài ra, thỉnh thoảng GitHub sẽ không cung cấp runner ngay lập tức, và bắt bạn chờ đến một thời điểm muộn hơn:

Tất cả điều này chuyển thành thời gian chờ đợi. Với hơn 200 package, việc merge một PR có thể mất đến 1 tiếng! Đây là vấn đề cần được giải quyết.
Mở rộng monorepo với các lệnh tùy chỉnh có thể giải quyết vấn đề này.
Mở rộng Monorepo builder
Thông thường, khi thực thi lệnh sau, chúng ta sẽ nhận được danh sách tất cả các package trong repo:
vendor/bin/monorepo-builder packages-json
Nhưng rồi tôi nghĩ: không cần phải đồng bộ hóa tất cả các package, mà chỉ những package chứa code đã được sửa đổi trong PR.
Nếu chúng ta có thể tìm ra danh sách các file đã sửa đổi, chúng ta có thể tính toán những package nào đã bị sửa đổi chứa chúng. Nói cách khác: thực thi git diff, và đưa kết quả vào lệnh packages-json, thông qua đầu vào filter, như sau:
vendor/bin/monorepo-builder packages-json --filter=modified_file_1 --filter=modified_file_2 --filter=...Hiện tại, lệnh packages-json được cung cấp với Monorepo Builder không chấp nhận đầu vào filter. Vì vậy, đây là nơi chúng ta phải mở rộng nó với các lệnh tùy chỉnh của mình.
Monorepo builder sử dụng DependencyInjection của Symfony, vì vậy nó có thể được mở rộng bằng cách inject các service mới vào container của nó. Thật vậy, file cấu hình monorepo-builder.php đã là một service configurator.
Vì vậy, tôi đã mở rộng Monorepo builder với một lệnh mới có tên package-entries-json, hỗ trợ đầu vào 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;
}
}Nó được inject vào service container như thế này:
return static function (ContainerConfigurator $containerConfigurator): void {
$services = $containerConfigurator->services();
$services->defaults()->autowire()->autoconfigure();
$services->set(PackageEntriesJsonCommand::class);
}Bây giờ, lệnh mới có tên package-entries-json sẽ sẵn sàng cho workflow GitHub Action.
Lấy danh sách các file đã sửa đổi trong GitHub Action
Bây giờ hãy xem cách cập nhật workflow.
Tôi sử dụng action technote-space/get-diff-action một cách tiện lợi, cung cấp git diff của tất cả các file đã sửa đổi trong PR:
# git diff to generate matrix with modified packages only
- uses: technote-space/get-diff-action@v4
with:
PATTERNS: layers/*/*/*/**Từ những kết quả này (được lưu trữ dưới ${{ env.GIT_DIFF }}), tôi tạo lời gọi đến lệnh tùy chỉnh package-entries-json, và đặt nó làm 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))"Các package kết quả sau đó được sử dụng để tạo ma trận:
outputs:
matrix: ${{ steps.output_data.outputs.matrix }}Hoạt động rất tốt! Trong trường hợp này, chỉ có hai package được sửa đổi, vì vậy chỉ có 2 instance được khởi chạy trong ma trận:

Bây giờ, việc merge PR có thể chỉ mất vài phút (giảm từ 1 tiếng), vì vậy tôi lại là một lập trình viên hạnh phúc.
Các tối ưu hóa/thách thức tiếp theo
Có một trường hợp khác mà tôi có thể giảm thời gian từ GitHub Action: khi thực thi các bài test PHPUnit.
Hiện tại, bất cứ khi nào một đoạn code mới được tải lên, toàn bộ bộ test cho tất cả các package đều được thực thi. Nhưng một lần nữa, điều này có thể được tối ưu hóa.
Giả sử monorepo chứa 3 package: A, B và C, trong đó B phụ thuộc vào A, và C phụ thuộc vào B.
Sau đó, nếu chúng ta sửa đổi code từ một package duy nhất, các bài test cần thực thi sẽ khác nhau:
- Sửa đổi code từ A: phải test A, B và C
- Sửa đổi code từ B: phải test B và C
- Sửa đổi code từ C: phải test C
Việc tối ưu hóa sau đó sẽ phụ thuộc vào việc lấy danh sách các package đã sửa đổi (như trong tối ưu hóa trước), và thực thi các bài test cho chúng và cho tất cả các package phụ thuộc vào chúng.
Tuy nhiên, hiện tại tôi không có thông tin về cách mỗi package trong monorepo phụ thuộc vào nhau.
Mặc dù file composer.json gốc chứa tất cả các package cục bộ, tôi không thể lấy các dependency của chúng qua Composer bằng cách thực thi composer info ${ package_name }, vì chúng đã được định nghĩa trong phần replace, thay vì require.
Thay vào đó, tôi có thể vào từng thư mục con của mỗi package, thực thi composer install, và sau đó composer info. Nhưng thực thi composer install hơn 200 lần sẽ là sự điên rồ thuần túy.
Do đó, tôi chưa tối ưu hóa kịch bản này. Đến nay tôi đã tạo issue, và hy vọng cuối cùng sẽ tìm ra giải pháp.
Tổng kết
Tôi phải nói rằng tôi vô cùng vui mừng khi khám phá ra Monorepo Builder. Tôi không nghĩ mình có thể quản lý codebase cho Gato GraphQL theo cách nào khác.
Tôi không nói rằng mọi dự án đều nên sử dụng nó. Nhưng khi bạn có hơn 200 package, như trong trường hợp của tôi, hoặc có thể thậm chí hơn 20, thì nó thực sự đơn giản hóa cuộc sống của bạn.
Quản lý monorepo đòi hỏi một chút thời gian và nỗ lực để thiết lập và duy trì, nhưng tôi tiết kiệm được thời gian và nỗ lực đó nhiều lần mỗi ngày, chỉ từ quá trình phát triển liên tục.