我一直在同一个项目上工作了好几年。它的初始版本是一个包含数千个文件的庞大单体应用。它的架构设计糟糕,不可重用,但托管在一个单一仓库中,使其易于使用。后来,我通过将代码库拆分成自主的包,将每个包都托管在自己的仓库中,并使用 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/root
、getpop/component-model
、getpop/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/
等等下指示类别。

然后,我将所有仓库(getpop/engine
、getpop/component-model
等等)中的所有源代码复制到单一仓库中该包的对应位置(即 layers/Engine/packages/engine
、layers/Engine/packages/component-model
等等)。
我不需要保留包的 Git 历史记录,所以只需使用 Finder 复制文件即可。否则,我们可以使用 hraban/tomono
或 shopsys/monorepo-tools
将仓库移植到单一仓库中,同时保留其 Git 历史记录和提交哈希值。
接下来,我更新了所有下游仓库的描述,以 [READ ONLY]
开头,例如 这个。

我通过 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 生成的。

如果将来我在存储库中添加了另一个插件的代码,它也会毫无问题地生成。现在投入一些时间和精力来构建这个设置,将来一定会节省大量的时间和精力。
Monorepo 的问题
我认为,当所有包都用相同的编程语言编写,紧密耦合并且依赖于相同的工具时,Monorepo 特别有用。相反,如果我们有多个基于不同编程语言(如 JavaScript 和 PHP)的项目,由不相关的部分(如主网站代码和处理新闻稿订阅的子域)或工具(如 PHPUnit 和 Jest)组成,那么我认为 Monorepo 没有什么优势。
也就是说,Monorepo 有一些缺点。
- 我们必须对托管在 Monorepo 中的所有代码使用相同的许可证;否则,我们就无法在 Monorepo 的根目录添加
LICENSE.md
文件并让 GitHub 自动获取它。事实上,leoloso/PoP
最初提供了几个使用 MIT 许可证的库和使用 GPLv2 许可证的插件。因此,我决定使用它们之间的最小公分母(即 GPLv2)来简化它。 - 有很多代码,很多文档,还有很多来自不同项目的问题。因此,被特定项目吸引的潜在贡献者很容易感到困惑。
- 在标记代码时,所有包都使用该标记独立地进行版本控制,无论它们的特定代码是否已更新。这是 Monorepo Builder 的问题,而不是 Monorepo 方法本身的问题(Symfony 已经 为其 Monorepo 解决这个问题)。
- 问题板需要适当的管理。特别是,它需要标签来将问题分配给相应的项目,否则它会变得混乱。

所有这些问题都不是障碍。我可以应付它们。但是,有一个问题是 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 子模块,该子模块能够访问其文件。

这种方法通过以下方式满足了我的需求:
- 让公共存储库
leoloso/PoP
成为上游 Monorepo,并且 - 创建一个私有存储库
leoloso/GraphQLAPI-PRO
作为下游 Monorepo。

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

现在,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
此设置到位后,私有存储库通过重用公共存储库中的工作流来生成自己的插件。

我对这种方法非常满意。我觉得它消除了我关于项目管理方式的所有负担。我读到一个 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: ""
总结
我的仓库经历了一段相当长的旅程,在不同阶段适应了代码和应用程序的新需求。
- 它最初是一个单一仓库,托管一个整体应用程序。
- 它在将应用程序拆分为包时变成了多仓库。
- 它被切换到单一仓库以更好地管理所有包。
- 它被升级到多单一仓库以与私有单一仓库共享文件。
上下文决定一切,因此这里没有“最佳”方法——只有或多或少适合不同场景的解决方案。
我的仓库是否已经走到了旅程的终点?谁知道呢?多单一仓库满足了我目前的需要,但它将所有私有插件一起托管。如果我需要向承包商授予对特定私有插件的访问权限,同时阻止他们访问其他代码,那么单一仓库可能不再是我的理想解决方案,我需要再次迭代。
我希望你喜欢这段旅程。如果你有任何想法或来自你自身经历的例子,我很乐意在评论区听到它们。
我建议为此使用 GitHub 项目。
我将它们用于一般管理(尤其是状态跟踪),但这是它们设计用途之一(另一个主要用途是组织相关问题组,例如单个功能的多个问题)。
哇!真是一段旅程!
看到你如何在这些年中解决这些问题,非常有趣。
感谢分享
在共享依赖方面,要小心使用单一仓库。一旦任何东西需要同一个包(但特定版本),更新就会变成头痛的事情。我犯了一个错误,将 Create React App 实例用作组件库的兄弟包。你必须避免提升共享资源(如 react、babel、eslint、jest 等),或者处理手动匹配所有包中的 CRA 版本(CRA 更新其依赖项很慢)。
我不确定我对多单一仓库的看法。有趣,但似乎管理起来又多了一层复杂性。尤其是在你对诸如常规提交、提交 linting 等仓库管理之类的事情感兴趣的情况下。
我必须重新阅读一遍,也许是第三遍。感谢分享。
我最初的印象是,我想问——这是一个我们应该更频繁地依赖的常见问题——“如果你被车撞了怎么办?”
我的感觉是,对于其他人来说,接手并掌控可能让人难以承受,如果不是危险的话。那么产品会怎样?使用它的客户怎么办?
可维护的代码和流程很重要,但经常被忽视。我不是说这里就是这种情况,但如果 80% 的内容都依赖于你并且是独一无二的,那么你的旅程还没有结束 :)
对每种方法的优缺点进行了很好的分析!然而,似乎缺少最后一步:发现 Git X-Modules 并使用相同的多单一仓库方法,而不会出现子模块带来的问题。实际上,在任何人都可以破坏他人工作的团队中,子模块相当严重。
我正在我的一个 Vue 项目中使用 turborepo。目前,我在我的组织中遇到了一种情况。我们为客户端/服务器 Web 应用程序设置了一个单一仓库(turborepo),并且想要雇用承包商来设计客户端的某些部分。我们打算只向承包商提供相关代码的访问权限。
你的解决方案是否解决了这个问题?谢谢
这种方法可以用于具有可写入的子项目吗?
一个用于内部使用的私有单一仓库,但面向公众的子项目是接收来自公众的 PR 的项目。
原因:不同的许可证。(否则紧密耦合,这是单一仓库需求的原因)