博客

💁🏻‍♀️ Gato GraphQL 为何需要 Monorepo,以及如何优化它

Leonardo Losoviz
作者:Leonardo Losoviz ·

几天前,我发表了文章 Hosting all your PHP packages together in a monorepo,解释了为什么我们可能希望使用 monorepo 来管理 PHP 代码库,以及如何通过 Monorepo Builder 来实现这一点。

在这里,我想对那篇文章进行补充,更详细地解释为什么 GatoGraphQL/GatoGraphQL 代码库(托管了 Gato GraphQL、其底层 GraphQL 引擎,以及基于此构建的组件模型架构)需要托管在 monorepo 上,以及我为此进行的优化。

Gato GraphQL 为何需要 Monorepo

为了支持 CMS 无关性,Gato GraphQL 及相关项目的代码库被拆分成大量通过 Composer 管理的包。总共创建了超过 100 个包!(目前已超过 200 个。)

包数量庞大,并不会给通过 Composer 将它们全部组合在一起带来额外的复杂性:只需运行 composer install,一切都能正常工作。然而,当每个包都独立存在于自己的仓库时,由于版本管理问题,开发工作就会变得困难重重。

每个包都必须进行版本控制,而某个包的每个版本又会依赖另一个包的某个版本。包数量如此之多,在创建 PR 时配置所有版本之间的依赖关系将会是一场噩梦,就像一盘意大利面代码,你能看到面条的一端,却不知道它在哪里结束。

寻找另一端

事实上,跨所有相关仓库的多个分支关联所有版本变得如此困难,以至于我干脆完全跳过这个过程,将代码直接推送到每个仓库的 master 分支,然后在各处依赖 dev-master 版本。

这样做并不恰当。迁移到 monorepo 模式,将所有代码托管在 GatoGraphQL/GatoGraphQL 中,有效地解决了这个问题。

意外收获:降低了贡献门槛

正如我在文章中提到的,当项目还是每个包一个仓库的时候,有一位贡献者因为无法搭建开发环境,在加入之前就放弃了项目。

在切换到 monorepo 之前,搭建开发环境非常困难。由于我是作者,我可以克隆所有仓库,然后将它们全部添加到一个 VSCode 工作区中,勉强让它运转起来。

我尝试通过这个 bash 脚本让潜在贡献者更容易地搭建相同的环境。但说实话,那从一开始就注定行不通,是一场输定了的战斗,没有人能够开始为这个项目做贡献。

有了 monorepo,我终于可以安心入睡了,因为我知道如果有人想参与进来,我不会用不合理的繁文缛节来拒绝贡献者。

优化 Monorepo

正如我在文章中提到的,与其他替代方案相比,使用 Monorepo Builder 库的优势在于它是用 PHP 构建的,并且我们可以对其进行扩展。

例如,在推送到 master 并拆分 monorepo 时,GitHub Action 中的矩阵通常会为每个包启动一个运行器实例,将其代码与自己的仓库同步(用于通过 Packagist 分发)。

由于 GatoGraphQL/GatoGraphQL 包含 200 多个包,这意味着会启动超过 200 个运行器实例。

处理 200 多个包

这里的问题是 GitHub 限制并行运行的 job 数量为 20 个。由于所有 action 都被放入队列,我需要等待它们完成才能继续执行其他 action。

此外,GitHub 有时不会立即分配运行器,而是让你等到稍后:

等待运行器变为可用

所有这些都转化为等待时间。有 200 多个包时,合并一个 PR 可能需要长达 1 小时!这是一个需要解决的问题。

通过自定义命令扩展 monorepo 可以解决这个问题。

扩展 Monorepo Builder

通常,执行以下命令时,我们会获得仓库中所有包的列表:

vendor/bin/monorepo-builder packages-json

获取仓库中所有包的列表

但随后我想到:没有必要同步所有包,只需同步那些包含 PR 中修改代码的包即可。

如果我们能找到已修改文件的列表,就可以计算出包含这些文件的已修改包。换句话说:执行 git diff,并通过 filter 输入将结果传递给 packages-json 命令,像这样:

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

然而,Monorepo Builder 自带的 packages-json 命令不接受 filter 输入。这就是我们必须用自定义命令对其进行扩展的地方。

Monorepo Builder 使用 Symfony 的 DependencyInjection,因此可以通过向其容器注入新服务来进行扩展。实际上,配置文件 monorepo-builder.php 已经是一个服务配置器

因此,我用一个名为 package-entries-json 的新命令扩展了 Monorepo Builder,该命令支持 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;
  }
}

它被如此注入到服务容器中:

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

现在,名为 package-entries-json 的新命令将可在 GitHub Action 工作流中使用。

在 GitHub Action 中获取已修改文件的列表

现在让我们看看如何更新工作流。

我方便地使用了 action technote-space/get-diff-action,它提供了 PR 中所有已修改文件git diff

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

根据这些结果(存储在 ${{ env.GIT_DIFF }} 中),我生成对自定义命令 package-entries-json 的调用,并将其设置为输出

- 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))"

得到的包随后被用于创建矩阵

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

效果非常好!在这个例子中,只有两个包被修改,因此矩阵中只启动了 2 个实例:

获取已修改包的列表

现在,合并 PR 可能只需几分钟(从 1 小时缩减),我又成为了一个快乐的开发者。

进一步的优化与挑战

还有另一个可以减少 GitHub Action 时间的场景:执行 PHPUnit 测试时。

目前,每当上传新代码时,都会执行所有包的全部测试套件。但同样,这也可以被优化。

假设 monorepo 包含 3 个包:A、B 和 C,其中 B 依赖 A,C 依赖 B。

那么,如果我们只修改单个包中的代码,需要执行的测试将有所不同:

  • 修改 A 的代码:必须测试 A、B 和 C
  • 修改 B 的代码:必须测试 B 和 C
  • 修改 C 的代码:必须测试 C

这种优化将依赖于获取已修改包的列表(与前一项优化相同),并对它们以及所有依赖它们的包执行测试。

然而,我目前没有掌握 monorepo 中每个包相互依赖关系的信息。

尽管根 composer.json 包含所有本地包,但我无法通过执行 composer info ${ package_name } 来获取它们的依赖关系,因为它们是在 replace 部分中定义的,而不是 require

另一个方案是进入每个包的子文件夹,执行 composer install,然后再执行 composer info。但执行 200 次以上的 composer install 简直是疯狂之举。

因此,我目前尚未对这种场景进行优化。我已经创建了相关 issue,希望最终能找到解决方案。

总结

我必须说,发现 Monorepo Builder 让我感到非常高兴。否则我认为自己根本无法管理 Gato GraphQL 的代码库。

我并不是说每个项目都应该使用它。但当你像我一样拥有 200 多个包,甚至可能超过 20 个包时,它绝对能让你的生活更轻松。

管理 monorepo 确实需要花一些时间和精力来搭建和维护,但仅仅从日常持续开发中,我就能将这些时间和精力收回好几倍。


订阅我们的新闻通讯

及时了解 Gato GraphQL 的所有更新。