从单个仓库到多个仓库,再到单一仓库,最后到多个单一仓库

Avatar of Leonardo Losoviz
Leonardo Losoviz on

DigitalOcean 为您的旅程每个阶段提供云产品。从 200 美元的免费信用额度 开始!

我一直在同一个项目上工作了好几年。它的初始版本是一个包含数千个文件的庞大单体应用。它的架构设计糟糕,不可重用,但托管在一个单一仓库中,使其易于使用。后来,我通过将代码库拆分成自主的包,将每个包都托管在自己的仓库中,并使用 Composer 来管理它们,从而“修复”了项目中的混乱。代码库变得架构良好且可重用,但由于分散在多个仓库中,因此使用起来变得更加困难。

随着代码的不断重新格式化,它在仓库中的托管方式也必须随之改变,从最初的单个仓库,到多个仓库,再到单一仓库,最后到我们可能称之为“多个单一仓库”的东西。

让我带您了解这一过程的旅程,解释为什么以及何时我觉得我必须切换到新的方法。旅程到目前为止分为四个阶段,所以让我们按此进行细分。

阶段 1:单个仓库

该项目是 leoloso/PoP,它已经经历了多种托管方案,随着代码在不同时间段的重新架构,托管方案也在不断变化。

它诞生于 这个 WordPress 网站,包含一个主题和几个插件。所有代码都托管在同一个仓库中。

一段时间后,我需要另一个具有类似功能的网站,所以我选择了一种快速简便的方法:我复制了主题,并添加了自己的自定义插件,所有这些都在同一个仓库中。我很快就让 新网站 运行起来。

我对另一个网站做了同样的事情,然后是另一个,再是另一个。最终,这个仓库托管了大约 10 个网站,包含数千个文件。

单个仓库托管所有代码。

单个仓库的问题

虽然这种设置使创建新的网站变得容易,但它根本无法扩展。最主要的是,单个更改涉及在所有 10 个网站中搜索相同的字符串。这完全无法管理。只能说,复制/粘贴/搜索/替换对我来说已经成为例行公事了。

因此,是时候开始编写 PHP 正确的方式 了。

阶段 2:多个仓库

快进几年。我将应用程序完全拆分成 PHP 包,通过 Composer 和依赖注入进行管理。

Composer 使用 Packagist 作为其主要的 PHP 包存储库。为了发布包,Packagist 需要在包的仓库根目录中放置一个 composer.json 文件。这意味着我们无法在同一个仓库中拥有多个 PHP 包,每个包都有自己的 composer.json

因此,我必须从将所有代码都托管在单个 leoloso/PoP 仓库中,切换到使用多个仓库,每个 PHP 包都对应一个仓库。为了便于管理,我在 GitHub 上创建了 组织“PoP” ,并将所有仓库都托管在那里,包括 getpop/rootgetpop/component-modelgetpop/engine 以及许多其他仓库。

在多个仓库中,每个包都托管在自己的仓库中。

多个仓库的问题

当您只有少量 PHP 包时,处理多个仓库可能很容易。但在我的情况下,代码库包含 超过 200 个 PHP 包。管理它们一点也不轻松。

该项目被拆分成如此多的包的原因是,我还 将代码与 WordPress 解耦(以便它们也可以与其他 CMS 一起使用),因此每个包都必须非常细粒度,处理单一目标。

现在,200 个包并不常见。但即使一个项目只包含 10 个包,跨 10 个仓库进行管理也会很困难。这是因为每个包都必须进行版本控制,并且每个包的版本都依赖于另一个包的某个版本。在创建拉取请求时,我们需要在每个包的 composer.json 文件中配置相应的依赖项开发分支。这很繁琐且官僚。

我最终根本没有使用功能分支,至少在我的情况下是这样,而是简单地将每个包指向其依赖项的 dev-master 版本(即我没有对包进行版本控制)。我不会感到惊讶,这种做法很常见,而且比我们想象的要普遍。

有一些工具可以帮助管理多个仓库,例如 meta。它创建了一个由多个仓库组成的项目,并在项目上执行 git commit -m "some message" 会在每个仓库上执行 git commit -m "some message" 命令,允许它们彼此同步。

然而,meta 无法帮助管理每个依赖项的 composer.json 文件中的版本控制。虽然它有助于减轻痛苦,但它并非最终解决方案。

因此,是时候将所有包都放到同一个仓库中了。

阶段 3:单一仓库

单一仓库是一个托管多个项目代码的单个仓库。由于它将不同的包托管在一起,我们也可以一起对其进行版本控制。这样,所有包都可以使用相同的版本发布,并在依赖项之间建立链接。这使得拉取请求变得非常简单。

单一仓库托管多个包。

如前所述,如果 PHP 包托管在同一个仓库中,我们无法将其发布到 Packagist。但我们可以通过将代码的开发和分发解耦来克服这一限制:我们使用单一仓库来托管和编辑源代码,并使用多个仓库(每个包一个仓库)将其发布到 Packagist 以进行分发和使用。

单一仓库托管源代码,多个仓库进行分发。

切换到单一仓库

切换到单一仓库方法涉及以下步骤

首先,我在 leoloso/PoP 中创建了文件夹结构来托管多个项目。我决定使用两级层次结构,首先在 layers/ 下指示更广泛的项目,然后在 packages/plugins/clients/ 等等下指示类别。

Showing the HitHub repo for a project called PoP. The screen in is dark mode, so the background is near black and the text is off-white, except for blue links.
单一仓库的层指示更广泛的项目。

然后,我将所有仓库(getpop/enginegetpop/component-model 等等)中的所有源代码复制到单一仓库中该包的对应位置(即 layers/Engine/packages/enginelayers/Engine/packages/component-model 等等)。

我不需要保留包的 Git 历史记录,所以只需使用 Finder 复制文件即可。否则,我们可以使用 hraban/tomonoshopsys/monorepo-tools 将仓库移植到单一仓库中,同时保留其 Git 历史记录和提交哈希值。

接下来,我更新了所有下游仓库的描述,以 [READ ONLY] 开头,例如 这个

Showing the GitHub repo for the component-model project. The screen is in dark mode, so the background is near black and the text is off-white, except for blue links. There is a sidebar to the right of the screen that is next to the list of files in the repo. The sidebar has an About heading with a description that reads: Read only, component model for Pop, over which the component-based architecture is based." This is highlighted in red.
下游仓库的“只读”位于仓库描述中。

我通过 GitHub 的 GraphQL API 批量执行了此任务。我首先使用以下查询获取所有仓库的所有描述

{
  repositoryOwner(login: "getpop") {
    repositories(first: 100) {
      nodes {
        id
        name
        description
      }
    }
  }
}

…它返回了如下所示的列表

{
  "data": {
    "repositoryOwner": {
      "repositories": {
        "nodes": [
          {
            "id": "MDEwOlJlcG9zaXRvcnkxODQ2OTYyODc=",
            "name": "hooks",
            "description": "Contracts to implement hooks (filters and actions) for PoP"
          },
          {
            "id": "MDEwOlJlcG9zaXRvcnkxODU1NTQ4MDE=",
            "name": "root",
            "description": "Declaration of dependencies shared by all PoP components"
          },
          {
            "id": "MDEwOlJlcG9zaXRvcnkxODYyMjczNTk=",
            "name": "engine",
            "description": "Engine for PoP"
          }
        ]
      }
    }
  }
}

从那里,我复制了所有描述,在它们前面添加了 [READ ONLY],并为每个仓库生成了一个新的查询,执行 updateRepository GraphQL 变异

mutation {
  updateRepository(
    input: {
      repositoryId: "MDEwOlJlcG9zaXRvcnkxODYyMjczNTk="
      description: "[READ ONLY] Engine for PoP"
    }
  ) {
    repository {
      description
    }
  }
}

最后,我引入了工具来帮助“拆分单一仓库”。使用单一仓库依赖于在每次拉取请求合并时,同步上游单一仓库和下游仓库之间的代码。此操作称为“拆分单一仓库”。拆分单一仓库可以使用 git subtree split 命令来实现,但由于我很懒,我更愿意使用工具。

我选择了用 PHP 编写的 Monorepo Builder。我喜欢这个工具,因为它可以让我 自定义自己的功能。其他流行的工具包括用 Go 编写的 Git Subtree Splitter 和用 bash 脚本编写的 Git Subsplit

我喜欢 Monorepo 的地方

我感觉在 Monorepo 中很自在。开发速度提高了,因为处理 200 个包感觉就像处理一个包一样。当重构代码库时,这种提升最为明显,例如在多个包中执行更新时。

Monorepo 还允许我一次发布多个 WordPress 插件。我只需要通过 PHP 代码(使用 Monorepo Builder 时)为 GitHub Actions 提供配置,而不是在 YAML 中硬编码。

为了 生成可用于分发的 WordPress 插件,我创建了一个在创建版本时触发的 generate_plugins.yml 工作流。使用 Monorepo,我已经调整了它,可以生成多个插件,而不是一个,通过 plugin-config-entries-json 中的自定义命令 在 PHP 中进行配置,并像 GitHub Actions 中这样调用

- id: output_data
  run: |
    echo "quot;::set-output name=plugin_config_entries::$(vendor/bin/monorepo-builder plugin-config-entries-json)"

通过这种方式,我可以同时生成我的 GraphQL API 插件 和托管在 Monorepo 中的其他插件。通过 PHP 定义的配置是 这个

class PluginDataSource
{
  public function getPluginConfigEntries(): array
  {
    return [
      // GraphQL API for WordPress
      [
        'path' => 'layers/GraphQLAPIForWP/plugins/graphql-api-for-wp',
        'zip_file' => 'graphql-api.zip',
        'main_file' => 'graphql-api.php',
        'dist_repo_organization' => 'GraphQLAPI',
        'dist_repo_name' => 'graphql-api-for-wp-dist',
      ],
      // GraphQL API - Extension Demo
      [
        'path' => 'layers/GraphQLAPIForWP/plugins/extension-demo',
        'zip_file' => 'graphql-api-extension-demo.zip',
        'main_file' =>; 'graphql-api-extension-demo.php',
        'dist_repo_organization' => 'GraphQLAPI',
        'dist_repo_name' => 'extension-demo-dist',
      ],
    ];
  }
}

在创建版本时, 插件是通过 GitHub Actions 生成的

Dark mode screen in GitHub showing the actions for the project.
此图显示了创建版本时生成的插件。

如果将来我在存储库中添加了另一个插件的代码,它也会毫无问题地生成。现在投入一些时间和精力来构建这个设置,将来一定会节省大量的时间和精力。

Monorepo 的问题

我认为,当所有包都用相同的编程语言编写,紧密耦合并且依赖于相同的工具时,Monorepo 特别有用。相反,如果我们有多个基于不同编程语言(如 JavaScript 和 PHP)的项目,由不相关的部分(如主网站代码和处理新闻稿订阅的子域)或工具(如 PHPUnit 和 Jest)组成,那么我认为 Monorepo 没有什么优势。

也就是说,Monorepo 有一些缺点。

  • 我们必须对托管在 Monorepo 中的所有代码使用相同的许可证;否则,我们就无法在 Monorepo 的根目录添加 LICENSE.md 文件并让 GitHub 自动获取它。事实上,leoloso/PoP 最初提供了几个使用 MIT 许可证的库和使用 GPLv2 许可证的插件。因此,我决定使用它们之间的最小公分母(即 GPLv2)来简化它。
  • 有很多代码,很多文档,还有很多来自不同项目的问题。因此,被特定项目吸引的潜在贡献者很容易感到困惑。
  • 在标记代码时,所有包都使用该标记独立地进行版本控制,无论它们的特定代码是否已更新。这是 Monorepo Builder 的问题,而不是 Monorepo 方法本身的问题(Symfony 已经 为其 Monorepo 解决这个问题)。
  • 问题板需要适当的管理。特别是,它需要标签来将问题分配给相应的项目,否则它会变得混乱。
Showing the list of reported issues for the project in GitHub in dark mode. The image shows just how crowded and messy the screen looks when there are a bunch of issues from different projects in the same list without a way to differentiate them.
没有与项目相关的标签,问题板可能会变得混乱。

所有这些问题都不是障碍。我可以应付它们。但是,有一个问题是 Monorepo 无法解决的:将公共代码和私有代码一起托管。

我计划创建一个我打算在私有存储库中托管的“PRO”版本的插件。但是,存储库中的代码要么是公共的,要么是私有的,因此我无法在我的公共 leoloso/PoP 存储库中托管我的私有代码。同时,我也想继续使用我的私有存储库的设置,特别是 generate_plugins.yml 工作流(它已经 对插件进行了范围限定,并且 将代码从 PHP 8.0 降级到 7.1)及其通过 PHP 配置的可能性。我想保持 DRY,避免复制粘贴。

是时候切换到多 Monorepo 了。

阶段 4:多 Monorepo

多 Monorepo 方法由不同的 Monorepo 通过 Git 子模块 互相共享文件组成。最基本的多 Monorepo 包含两个 Monorepo:一个自治的上游 Monorepo 和一个下游 Monorepo,它将上游存储库嵌入为一个 Git 子模块,该子模块能够访问其文件。

A giant red folder illustration is labeled as the downstream monorepo and it contains a smaller green folder showing the upstream monorepo.
上游 Monorepo 位于下游 Monorepo 中。

这种方法通过以下方式满足了我的需求:

  • 让公共存储库 leoloso/PoP 成为上游 Monorepo,并且
  • 创建一个私有存储库 leoloso/GraphQLAPI-PRO 作为下游 Monorepo。
The same illustration as before, but now the large folder is a bright pink and is labeled as with the project name, and the smaller folder is a purplish-blue and labeled with the name of the public downstream module,.
私有 Monorepo 可以访问公共 Monorepo 中的文件。

leoloso/GraphQLAPI-PRO 在子文件夹 submodules/PoP 中嵌入 leoloso/PoP(请注意 GitHub 如何链接到嵌入存储库的特定提交)。

此图显示了公共 Monorepo 如何在 GitHub 项目中嵌入到私有 Monorepo 中。

现在,leoloso/GraphQLAPI-PRO 可以访问 leoloso/PoP 中的所有文件。例如,来自 leoloso/PoP 的脚本 ci/downgrade/downgrade_code.sh(它将代码从 PHP 8.0 降级到 7.1)可以在 submodules/PoP/ci/downgrade/downgrade_code.sh 中访问。

此外,下游存储库可以加载来自上游存储库的 PHP 代码,甚至可以扩展它。通过这种方式,可以覆盖生成公共 WordPress 插件的配置,以生成 PRO 版本的插件。

class PluginDataSource extends UpstreamPluginDataSource
{
  public function getPluginConfigEntries(): array
  {
    return [
      // GraphQL API PRO
      [
        'path' => 'layers/GraphQLAPIForWP/plugins/graphql-api-pro',
        'zip_file' => 'graphql-api-pro.zip',
        'main_file' => 'graphql-api-pro.php',
        'dist_repo_organization' => 'GraphQLAPI-PRO',
        'dist_repo_name' => 'graphql-api-pro-dist',
      ],
      // GraphQL API Extensions
      // Google Translate
      [
        'path' => 'layers/GraphQLAPIForWP/plugins/google-translate',
        'zip_file' => 'graphql-api-google-translate.zip',
        'main_file' => 'graphql-api-google-translate.php',
        'dist_repo_organization' => 'GraphQLAPI-PRO',
        'dist_repo_name' => 'graphql-api-google-translate-dist',
      ],
      // Events Manager
      [
        'path' => 'layers/GraphQLAPIForWP/plugins/events-manager',
        'zip_file' => 'graphql-api-events-manager.zip',
        'main_file' => 'graphql-api-events-manager.php',
        'dist_repo_organization' => 'GraphQLAPI-PRO',
        'dist_repo_name' => 'graphql-api-events-manager-dist',
      ],
    ];
  }
}

GitHub Actions 仅加载来自 .github/workflows 下面的工作流,而上游工作流位于 submodules/PoP/.github/workflows 下面;因此,我们需要复制它们。这并不理想,但我们可以避免编辑复制的工作流并将上游文件视为单一的事实来源。

为了复制工作流,一个简单的 Composer 脚本 可以做到:

{
  "scripts": {
    "copy-workflows": [
      "php -r \"copy('submodules/PoP/.github/workflows/generate_plugins.yml', '.github/workflows/generate_plugins.yml');\"",
      "php -r \"copy('submodules/PoP/.github/workflows/split_monorepo.yaml', '.github/workflows/split_monorepo.yaml');\""
    ]
  }
}

然后,每次我在上游 Monorepo 中编辑工作流时,我也会将它们复制到下游 Monorepo 中,方法是执行以下命令:

composer copy-workflows

此设置到位后,私有存储库通过重用公共存储库中的工作流来生成自己的插件。

此图显示了在 GitHub Actions 中生成的 PRO 插件。

我对这种方法非常满意。我觉得它消除了我关于项目管理方式的所有负担。我读到一个 WordPress 插件作者抱怨管理他 10 多个插件的版本花费了大量时间。在这里不会发生这种情况——在我合并我的拉取请求后,公共和私有插件都会像变魔术一样自动生成。

多 Monorepo 的问题

首先,它泄漏了。理想情况下,leoloso/PoP 应该完全自治,并且不知道它在更大的方案中用作上游 Monorepo——但事实并非如此。

在执行 git checkout 时,下游 Monorepo 必须传递 --recurse-submodules 选项才能检出子模块。在下游存储库的 GitHub Actions 工作流中,检出必须像这样完成:

- uses: actions/checkout@v2
  with:
    submodules: recursive

因此,我们必须将 submodules: recursive 输入到下游工作流中,但不能输入到上游工作流中,即使它们都使用相同的文件源。

为了解决这个问题,同时保持公共存储库作为单一的事实来源,leoloso/PoP 中的工作流通过环境变量 CHECKOUT_SUBMODULES 注入 submodules 的值, 就像这样

env:
  CHECKOUT_SUBMODULES: "";

jobs:
  provide_data:
    steps:
      - uses: actions/checkout@v2
        with:
          submodules: ${{ env.CHECKOUT_SUBMODULES }}

对于上游 Monorepo,环境值为空,因此执行 submodules: "" 很好用。然后,当将工作流从上游复制到下游时,我将环境变量的值替换为 "recursive",以便它变成:

env:
  CHECKOUT_SUBMODULES: "recursive"

(我有一个 PHP 命令来进行替换,但我们也可以在 copy-workflows Composer 脚本中使用管道 sed。)

这种泄漏暴露了此设置的另一个问题:我必须在将贡献合并到公共存储库之前审查所有贡献,否则它们可能会破坏下游的东西。贡献者也将完全不知道这些泄漏(并且他们不能被指责)。这种情况是公共/私有 Monorepo 设置特有的,我是在其中唯一了解完整设置的人。虽然我共享了对公共存储库的访问权限,但我只是唯一访问私有存储库的人。

举个例子,leoloso/PoP 的贡献者可能会删除 CHECKOUT_SUBMODULES: "",因为它多余。但贡献者不知道的是,虽然这一行不需要,但删除它会导致私有仓库无法运行。

我想我需要添加一个警告!

env:
  ### ☠️ Do not delete this line! Or bad things will happen! ☠️
  CHECKOUT_SUBMODULES: ""

总结

我的仓库经历了一段相当长的旅程,在不同阶段适应了代码和应用程序的新需求。

  • 它最初是一个单一仓库,托管一个整体应用程序。
  • 它在将应用程序拆分为包时变成了多仓库。
  • 它被切换到单一仓库以更好地管理所有包。
  • 它被升级到多单一仓库以与私有单一仓库共享文件。

上下文决定一切,因此这里没有“最佳”方法——只有或多或少适合不同场景的解决方案。

我的仓库是否已经走到了旅程的终点?谁知道呢?多单一仓库满足了我目前的需要,但它将所有私有插件一起托管。如果我需要向承包商授予对特定私有插件的访问权限,同时阻止他们访问其他代码,那么单一仓库可能不再是我的理想解决方案,我需要再次迭代。

我希望你喜欢这段旅程。如果你有任何想法或来自你自身经历的例子,我很乐意在评论区听到它们。