💁🏻♀️ Por que o Gato GraphQL precisa de um Monorepo, e como ele é otimizado
Há alguns dias publiquei o artigo Hospedando todos os seus packages PHP juntos em um monorepo, explicando por que podemos querer usar um monorepo para gerenciar nosso código-fonte PHP, e como fazer isso por meio do Monorepo Builder.
Aqui gostaria de complementar aquele artigo, explicando com um pouco mais de detalhe por que o código-fonte do GatoGraphQL/GatoGraphQL (que hospeda o Gato GraphQL, seu motor GraphQL subjacente e a arquitetura de modelo de componentes sobre a qual ele é baseado) precisa ser hospedado em um monorepo, e as otimizações que realizei nele.
Por que o Gato GraphQL precisa de um monorepo
Para suportar o agnosticismo de CMS, o código-fonte do Gato GraphQL e dos projetos associados foi dividido em uma multidão de packages, gerenciados via Composer. No total, foram criados mais de 100 packages! (Atualmente, o número supera 200.)
O grande número de packages não adiciona complexidade extra para montá-los todos juntos via Composer: basta executar composer install, e tudo funciona. No entanto, torna-se problemático para o desenvolvimento quando cada package individual vive em seu próprio repositório, por causa do versionamento.
Cada package deve ser versionado, e cada versão de um package dependerá de alguma versão de outro package. Com tantos packages, configurar como todas as versões dependem umas das outras ao criar PRs se tornaria um pesadelo, semelhante a um prato de código espaguete, onde você vê a ponta de um macarrão, mas não sabe onde ele termina.

A verdade é que ficou tão difícil vincular todas as versões dos múltiplos branches de todos os repositórios envolvidos, que eu simplesmente pulava esse processo por completo, fazendo push do código direto para o branch master de cada repo, e depois dependendo da versão dev-master em cada um.
Não era adequado. Mudar para o modelo monorepo, hospedando todo o código em GatoGraphQL/GatoGraphQL, resolveu o problema de forma efetiva.
Efeito colateral bem-vindo: barreira mais baixa para contribuições
Como mencionei no artigo, na época em que o projeto usava um repo por package, um contribuidor abandonou o projeto antes mesmo de ingressar, por não conseguir configurar o ambiente de trabalho.
Antes de mudar para o monorepo, configurar o ambiente de desenvolvimento era muito difícil. Como eu era o autor, conseguia clonar todos os repos e adicioná-los todos juntos em um único workspace do VSCode, então funcionava de certa forma para mim.
Tentei facilitar a configuração do mesmo ambiente para potenciais contribuidores, por meio deste script bash. Mas, sério, isso jamais poderia funcionar, era uma batalha perdida desde o início, e ninguém conseguia começar a contribuir com o projeto.
Com o monorepo, consigo dormir tranquilo à noite, sabendo que não vou rejeitar contribuidores com burocracia irrazoável, caso eles queiram se envolver.
Otimizando o monorepo
Como mencionei no artigo, a vantagem de usar a biblioteca Monorepo Builder em relação às alternativas é que ela é construída com PHP, e que podemos estendê-la.
Por exemplo, ao fazer um push para master e dividir o monorepo, a matriz na GitHub Action normalmente lança uma instância de runner por package, para sincronizar seu código com seu próprio repositório (para distribuição via Packagist).
Como GatoGraphQL/GatoGraphQL contém mais de 200 packages, isso significava que mais de 200 instâncias de runner eram iniciadas.

O problema aqui é que o GitHub impõe um limite de 20 jobs em execução em paralelo. Como todas as actions são colocadas em uma fila, eu precisava esperar que terminassem para continuar executando outras actions.
Além disso, de vez em quando o GitHub não provisiona um runner imediatamente, fazendo você esperar até um momento posterior:

Tudo isso se traduz em tempo de espera. Com mais de 200 packages, fazer o merge de uma única PR podia levar até 1 hora! Era um problema que precisava ser resolvido.
Estender o monorepo com comandos personalizados pode resolver o problema.
Estendendo o Monorepo builder
Normalmente, ao executar o seguinte comando, obtemos a lista de todos os packages no repo:
vendor/bin/monorepo-builder packages-json
Mas então pensei: não há necessidade de sincronizar todos os packages, apenas aqueles que contêm código que foi modificado na PR.
Se pudermos descobrir a lista de arquivos modificados, podemos calcular quais são os packages modificados que os contêm. Em outras palavras: executar git diff, e alimentar os resultados para o comando packages-json, por meio de um input filter, assim:
vendor/bin/monorepo-builder packages-json --filter=modified_file_1 --filter=modified_file_2 --filter=...Agora, o comando packages-json fornecido com o Monorepo Builder não aceita um input filter. Então é aqui que precisamos estendê-lo com nossos comandos personalizados.
O Monorepo builder usa o DependencyInjection do Symfony, portanto pode ser estendido injetando novos serviços em seu container. De fato, o arquivo de configuração monorepo-builder.php já é um configurador de serviços.
Então estendi o Monorepo builder com um novo comando chamado package-entries-json, que suporta o 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;
}
}Ele é injetado no container de serviços desta forma:
return static function (ContainerConfigurator $containerConfigurator): void {
$services = $containerConfigurator->services();
$services->defaults()->autowire()->autoconfigure();
$services->set(PackageEntriesJsonCommand::class);
}Agora, o novo comando chamado package-entries-json estará disponível para o workflow da GitHub Action.
Obtendo a lista de arquivos modificados na GitHub Action
Vejamos agora como atualizar o workflow.
Uso convenientemente a action technote-space/get-diff-action, que fornece o git diff de todos os arquivos modificados na PR:
# git diff to generate matrix with modified packages only
- uses: technote-space/get-diff-action@v4
with:
PATTERNS: layers/*/*/*/**A partir desses resultados (armazenados em ${{ env.GIT_DIFF }}) gero então a chamada para o comando personalizado package-entries-json, e o defino como 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))"Os packages resultantes são então usados para criar a matriz:
outputs:
matrix: ${{ steps.output_data.outputs.matrix }}Funciona muito bem! Neste caso, apenas dois packages foram modificados, e portanto apenas 2 instâncias foram iniciadas na matriz:

Agora, fazer o merge da PR pode levar apenas alguns minutos (contra 1 hora), então sou um desenvolvedor feliz novamente.
Mais otimizações/desafios
Há outra situação em que posso reduzir o tempo da GitHub Action: ao executar os testes PHPUnit.
Atualmente, sempre que um novo trecho de código é enviado, toda a bateria de testes de todos os packages é executada. Mas, mais uma vez, isso pode ser otimizado.
Digamos que o monorepo contém 3 packages: A, B e C, onde B depende de A, e C depende de B.
Então, se modificarmos o código de um único package, os testes que precisam ser executados variarão:
- Modificar o código de A: é preciso testar A, B e C
- Modificar o código de B: é preciso testar B e C
- Modificar o código de C: é preciso testar C
A otimização dependerá então de obter a lista de packages modificados (como na otimização anterior), e executar os testes para eles e para todos os packages que dependem deles.
No entanto, atualmente não tenho a informação de como cada package no monorepo depende dos outros.
Embora o composer.json raiz contenha todos os packages locais, não consigo obter suas dependências via Composer executando composer info ${ package_name }, porque foram definidos na seção replace, em vez de require.
Alternativamente, poderia entrar na subpasta de cada package, executar composer install, e depois fazer composer info. Mas executar composer install mais de 200 vezes seria pura loucura.
Por isso, ainda não otimizei esse cenário. Até agora criei a issue, e espero eventualmente encontrar uma solução.
Conclusão
Devo dizer que estou extremamente feliz por ter descoberto o Monorepo Builder. Não acho que seria capaz de gerenciar o código-fonte do Gato GraphQL de outra forma.
Não estou dizendo que todo projeto deveria usá-lo. Mas quando você tem mais de 200 packages, como no meu caso, ou possivelmente até mais de 20, então ele simplifica absolutamente sua vida.
Gerenciar o monorepo demanda um pouco de tempo e esforço para configurar e manter, mas economizo esse tempo e esforço várias vezes todos os dias, apenas com o desenvolvimento contínuo.