不久之前,在一个很好的平台上(maintainerati)和一些十分优秀的维护者聊天时,谈到关于如何扩展真正的大型开源项目,以及 GitHub 如何对项目进行扩展。Linux 内核有一个完全不同的模式,不过把项目托管在 GitHub 上的开源项目维护者似乎不明白这个。但我认为这是值得解释的,以及它是如何运作和不同的地方在哪里。
写下这些文字的另一个动机是我的“Maintainers Don’t Scale”这个观点在 HN discussion 上引起的讨论,其中最热的评论归结起来可以理解为“为什么这些大项目不会使用现代开发工具”。事实上,一些顶级的内核维护者仍在大力捍卫邮件列表,他们会通过这种方式提交补丁,像 github 的 pull requests 那样,但有些使用图形系统的用户会更喜欢现代化的工具,因为这更容易脚本化。问题是 GitHub 不能支持 Linux 内核扩展到如此大规模贡献者的方式,所以我们不能简单地迁移,即使仅仅是一些子系统。而且这不仅仅涉及到托管 git 数据,这部分当然不会有什么问题,问题主要是如何在 github 上进行 pull requests, forks 以及提 issue 这些操作。
Git 是非常优秀的系统,因为每个人都可以很容易地 fork 和创建分支,并且对代码进行修改,最后你会获得一些好的东西。你可以为主仓库创建 pull request,它会得到审查、测试和合并。GitHub 也是十分优秀的产品,因为它打造了一个 UI,使得 git 这个复杂的工具更友好,并更容易探索和学习,因此使得新人能更容易为项目贡献。
但最终,一个项目取得巨大的成功时,再多的 tag, 标签, 排序, bot-herding 和自动化都会排在仓库中的 pull request 和 issue 之前,现在是时候将事情分解成更可控的部分了。更重要的是,具有一定规模和历史的项目的不同部分需要不同的规则和处理:耀眼的新实验性库具有与主代码不同的稳定性和 CI 标准,也许你还有一些不再被支持的被放弃的插件的垃圾代码块,但你不能删除它们:你需要将你的大规模项目分解成子项目,每个项目都有自己的流程和合并条件,以及备份与 pull 请求和问题跟踪。一般来说,在项目达到必须大规模重组之前,需要几十或几百个全职贡献者。
几乎所有托管在 GitHub 的大型项目都是通过将它们的 monorepo 源码树分解成许多不同的项目实现的,每个项目都具有不同的功能。通常这会导致一堆被认为是核心的东西,加上一堆插件、库和扩展。所有这些都与某种插件或包管理器捆绑在一起,在某些情况下,它们直接从 github 仓库中拉取数据。
由于几乎每个大型项目都是这样做的,所以我不认为有必要深挖这其中的好处。但我想强调一些这样做引起的问题:
你社区碎片将过多。大多数贡献者只需要那些他们直接贡献的代码和仓库,同时会忽略所有其他内容。这对他们来说非常棒,但是使不同插件和库之间重复的工作和并行的解决方案能够得到注意并且共享的可能性将会更小。而想要管理整个社区的人需要处理一些脚本、git 子模块或者其他更糟糕的东西所带来的麻烦,同时他们通过订阅所有内容而被淹没在 pull 请求和各种问题上。任何与你的仓库不相关的担忧(也许你已经共享的构建工具、文档或其他任何东西)都会分割你整个工程,这将给后期的维护工作带来困扰。
只要你注意到了这方面的需求,你就会发现重构和代码共享上更多的制度性障碍:首先你必须发布一个新版本的核心库,然后遍历所有的插件并更新它们,然后你可能就可以删除共享库中的旧代码。但是,由于代码是大规模传播的,你可能会忘记最后一步。
当然,这并不需要太多的工作量,许多项目都可以简单的处理这些事情。但这要比对单个 monorepo 的简单 pull 请求多花心思。非常简单的重构通常会被忽视(如,共享一个新函数),但随着时间的累积,代码中的小问题就积累成了大问题。除非你使用像 node.js 方法那样将单个函数放到仓库中的作法,但以 npm 替代 git 作为源代码控制系统,这方法从本质上来说也有些傻。
理论上支持的版本混合带来的新组合增长量将失控。作为一个用户,这意味着你最终不得不进行集成测试。作为一个项目,你最终会得到满意的组合,因为开发人员会用“请先升级到最新版”来关闭错误报告。事实上,这意味着你有一个 monorepo,除了有一次的它不是使用 git 管理的。那么,除非你使用 submodules,不然我无法确定这是否被认为是 git ...
将整个项目重构并分解为子项目是一件很痛苦的事情,因为这意味着你需要重新组织 git 存储库以及它们的拆分方式。在一个 monorepo 的维护中迁移只需更新 OWNER 或 MAINTAINERS 文件,同时如果您的 bot 都是设计得比较完善,系统将自动完成 auto-tag。但是,如果你的可伸缩方案是将 git 仓库拆分为不相交的集合的话,那么比起从 monorepo 拆分为一系列仓库的简单步骤,这里的任何重构都一样痛苦。这意味着你的项目将被困在一个糟糕的组织架构中很久。
Linux 内核是为数不多的不像这样运作的项目之一。在我们研究它是如何工作之前,我们应当了解:内核是一个巨大的项目,没有一些子项目结构根本无法运行。了解为什么 git pull requests 很有意思:在 github 上,pull requests 是一个将不同人的贡献合并的有效方式。但在内核,即使在 git 被广泛采用之后,更改依然被作为补丁提交发送到邮件列表。
然而 git 的最初版本支持 pull requests。内核维护者见证了Git 第一次相当粗暴的发布,它是为了解决 Linus Torvalds 的维护问题而编写的。显然,它发挥了它的作用,但不是用于处理来自个人贡献者的贡献:在今天,pull requests 比当时更多地用于促进整个子系统、同步代码重构或跨项目代码交叉重构。举个例子,Linus 提出的的 4.12 网络是在 Dave S. Miller 的基础上 pull requests 而成的:它包含来自 600 个贡献者的两千多的意见和建议和来自下属维护者 pull requests 的大量合并。几乎所有的补丁都是由维护者从邮件列表中获取,而不是由作者自己打上的。这个内核进程的特点是作者一般不提交到共享存储库,这也是为什么 git 将提交者和作者分开追踪。
Github 的创新和改进都是为了有朝一日所有事情都能使用 pull requests,接纳下至个人的微小贡献。但这不是他们创造的最初目的。
乍一看,内核像一个把一切杂糅到 Linus’ main repo 里的 monorepo 模型,但实际上相差甚远:
极少人仅基于 Linus Torvalds 的 main repo 运行 Linux 。如果有人能在 Linux 上层运行程序,那么它可能是稳定的内核(stable kernels)之一。他们极可能是从通常带有其他的补丁和回报的发行版中运行一个内核,甚至有可能没有在 kernel.org 上托管,那就将是一个完全不同的组织。也有可能他们是从他们的硬件供应商( SoC 和几乎任何 Android 的硬件都有自己的内核)处取得内核。与主仓库中的任何一个东西相比,它通常具有更大的数据吞吐量。
除了 Linus 本人外没有人在 Linus 的仓库开发内容。每个子系统、甚至是大的驱动程序,都有自己的 git 存储库。它们用自己的邮件列表跟踪提交的内容,并完全与其他人分开讨论问题。
跨子系统的工作是在 linux-next integration tree 的顶部完成的,该树约包含来自上百个不同的 git 仓库的几百个 git 分支。
所有这些疯狂的东西是通过 MAINTAINERS 文件和 get_maintainers.pl 脚本进行管理的,知道任何特定的代码段,就可以知道谁是维护者、谁应该查看这个、正确的 git 报告在哪里、哪个邮件列表要使用以及在哪里如何报错。它不仅仅基于文件位置,还捕获代码模式以确保跨子系统主题(诸如 device-tree 处理或 kobject 层次结构)由合适的专家处理。
起初,这看起来像以一种复杂的方式使用大家根本不关心的内容来填满其磁盘空间,但这样一堆微小改进的组合还是有好处的:
重新组织如何拆分子项目是异常容易的,只需更新下 MAINTAINERS 文件即可完成。这比真正完成这个任务所需要的工作少多了,因为你可能需要创建新的 repo 、新的邮件列表和新的 bugzilla 。这只是一个 UI 问题,github 使用简洁的 fork 按钮解决了这个问题。
在子项目之间重新分配 pull 请求和问题的讨论真的很容易,你只需调整下你的 Cc 列表:罗列你的回复。同样的,跨子系统的工作将更容易协同,因为同样的 pull 请求可以提交到多个子项目中,而且只需要一次整体的讨论(因为 Msg-Ids :用于邮件列表议程的标签,对每个人而言是一样),尽管邮件被存档在一堆不同的邮件存档列表中,通过不同的邮件列表,并发送到几千个不同的收件箱中。在子项目中更容易地讨论主题和代码,能够避免碎片化,从而更容易地发现代码共享和重构的优势在哪里。
跨子系统工作不需要任何类型的发布规则。你只需更改代码,这些代码都在你的单个存储库中。请注意,严格来说这比拆分式 repo 设置功能强大的多:对于真正的入侵式重构,你依然可以通过多个发布版本来隔离工作,例如当有许多用户时,你可以立即改动它们,并且不会造成太大的协同问题。
(使重构和代码共享更容易的一个巨大好处是,你无需背负过多的历史负担。这在内核的“非稳定 API 是荒唐的”一文中有详细的解释。)
它不会阻止你创建自己的验证性修改,这是 multi-repo 设置的主要优点之一。将你的代码添加到你自己的分支中,并将其放到哪里 - 没有人会强迫你将代码重新推回,或者将其推入到一个单 repo、甚至到主分支中,因为根本就没有中央存储库。这样做效果非常好,也许太好了,其中一个最好的证明是在 Android 各种硬件供应商存储库中的数百万个代码行采用这种策略。
简而言之,我认为严格来说这是一个更强大的模型,因为你总是可以回头在多个不相交的仓库上做一些完全一样的事情。甚至还有一些内核驱动程序保存在他们自己的仓库中,与主内核树已经脱离,就像私有的 Nvidia 驱动程序。那只是一个对 blob 进行的源代码修正,但是由于法律原因它不能包含任何内核的内容,这绝对是一个完美的例子。
这看起来像 monorepo 的一场表演秀!
乍看起来,linux 内核看起来像 monorepo ,因为它包含所有内容。许多人知道,使用 monrepo 真的很痛苦,因为超过一定的大小,他们会停止自适应。
但是进一步分析,这和单 git 仓库有很大的差距。只需看看上游的子系统和驱动程序库就可以有几百个。如果你浏览下整个生态系统,包括硬件供应商、发行版、其他基于 Linux 的操作系统和单独的产品,你会轻松地拥有数千个主代码仓库,并且总共会有许多主仓库。这里不包括计任何仅供个人贡献者使用的私有 git repo 。
关键的区别是,linux 具有一个单一的文件层次结构,它作为跨越所有内容的共享命名空间,但是拥有对于所有不同的代码段和问题的诸多不同的 repo 。它是一个拥有多存储库的单一树,而不是 monorepo 。
例子,谢谢!
在我解释为什么 github 目前无法支持此工作流程之前,反正如果您希望保留 github UI 和集成的优势,我们需要一些实际操作的例子。简单来说,就是在维护者之间完成 git 拉取请求。
简单的情况是传递改变了维护者的层次结构,直到它最终落在树中并移植完毕。这很容易,因为拉取请求只能从一个仓库到下一个仓库,因此可以使用当前的 github UI 完成。
更有趣的是跨子系统的变化,因为拉取请求流将停止变为一个无环图,形成一个网络。第一步是让所有涉及的子系统及其维护者进行审查和测试。在 github 流中,这将是同时提交给多个仓库的拉取请求,其中一个单独的讨论流在它们之间共享。因为这是内核,所以这个步骤是通过补丁提交的,以及一堆不同的邮件列表和维护者作为收件人完成的。
它的评审方式通常不是合并的方式,而是其中一个子系统被选为主导,并应用这些pull请求,只要所有其他维护者赞成该合并路径就可以了。通常情况下,这个子系统是被一系列改动影响最大的,但有时也可能是一个已经有一些其他的工作正在进行与 pull 请求有冲突的子系统。有时也会创建一个全新的存储库和维护人员的队伍,这通常发生在跨越整个版本树的功能上,并且不是整齐地包含在一个地方的几个文件和目录中。最近的一个例子是 DMA 映射树,它试图整合到目前为止已经广泛引用在驱动程序、平台维护者和架构支持组中的工作。
但有时候会有多个子系统会与一组改动相冲突,而且所有子系统都需要解决一些不常见的合并冲突。在这种情况下,补丁并不是直接应用的(即在 github 上执行 rebasing pull 请求),而是基于通用于所有子系统的提交补丁的 pull 请求被合并到所有子系统树中。共同的基线对于避免子系统树被不相关改动影响具有重要作用。由于这个 pull 仅针对特定问题,这些分支通常称为问题分支。
我曾参与的一个例子是添加支持 audio-over-HDMI 的代码,跨越了图形和声音驱动两个子系统。同样的提交请求同时被合并到 Intel 图形驱动程序和声音子系统中。
另一个完全不同的例子是,世界上唯一的其他相关的通用大型操作系统(其实就是Windows,译者注)也决定使用 monotree,一个类似于 linux 上发生的提交流程。我和工作在这个系统的人交流过,这样一个巨大的树使得他们不得不重写一个全新的 GVFS 虚拟文件系统驱动来支持它...
不幸的是,github 不支持这种工作流,至少在 github UI 中不是原生的。当然可以通过使用简单的 git 工具来完成,但这样就不得不回到在邮件列表中 patch 程序,并通过电子邮件发起 pull 请求、手动应用。在我看来,这是内核社区无法从 github 迁移中获益的原因之一。还有一些小问题:几个顶级维护者对 github 持反对意见,但这并不是一个真正的技术问题。这里不仅仅是 Linux 内核,所有 GitHub 上的大规模项目在扩展性方面都具有挑战性,因为 github 并没有真正给他们提供扩展到多个存储库的选择,而是坚持使用一个 monotree。
总之,我对 github 有一个简单的功能请求:
请支持跨 monotree 的不同仓库的 pull requests 和 issue 追踪
简单的想法,巨大的意义。
存储和组织
首先,它需要在一个组织内有多个相同的 repo 分支的可能。就以 git.kernel.org 为例,这上面的大多数存储库不是个人的。甚至你可能有不同的组织,例如:不同的子系统,这样每个组织都需要一个 repo,这样愚蠢的行为会导致大量重复,以至于让访问以及用户管理起来感到痛苦。在图例中,我们在每一个用户空间的测试套件上都有一个 repo,用来共享用户空间的库,并且还有一个通用的工具集和脚本被用来运维和开发,这些都工作在 github 上。但是当我们拥有整个子系统的 repo 时,为了核心子系统的工作和为每个大的驱动添加一个附加的仓库的时候就需要再加一个仓库。这时候,那些分支就都要被分叉,而 github 做不了这件事。每一个这样的 repo 都有一个分支的分支,为了至少一个特性能工作,就必须解除循环去修复另一个分支中的 bug 。
把所有的分支都组合到一个仓库中是做不到的,因为 pull 请求和问题(issue)在 repo 中是被分割的。
与此相关的是,这可能需要建立分叉关系。至于新的工程,在 github 上倒不是一个大问题。但是 Linux 将可能会在某次将子系统进行迁移,并且 github 上已经有大量的 Linux 仓库,这样就不能恰当地在 github 上给每一个分支创建分支了。
Pull 请求
pull 请求需要同时附加到多个repo,同时保持一个统一的讨论流。 您可以将 pull 请求重新分配到不同的 repo 分支,但不能同时在多个存储库中重新分配。重新分配 pull 请求是非常重要的,因为新的贡献者将只是根据他们认为是主要的 repo 来创建 pull 请求。 然后,Bot 可以将周围的人随机指派到所有列出 MAINTAINERS 文件的 repo。比如,这个 repo 是一个给定一组文件的 MAINTAINERS 文件和要包含 pull 请求更改的 repo。 当我和 githubbers 聊天时,我要求他们直接实现。 但是我认为,因为此处并没有真正的标准,既然这些项目都是脚本化的,不如留给单个的项目。
这里有一个非常简单的 UI 挑战,因为补丁列表可能根据与 pull 请求所针对的分支的情况不同而不同。但这不一定是用户错误导致,一个 repo 可能已经合并了几个补丁。
此外,每个 pull 请求状态需要的不同的 repo。如果维护者同意将另一个子系统 pull 进来,可能会在没有合并的时候将其关闭,而另一个维护者将合并和关闭 pull。另一个数据结构甚至可能将 pull 请求关闭为无效,因为它不适用于较旧版本或供应商分支。 更有趣的是,在每个具有不同合并提交的子系统中,pull 请求可能会多次合并。
Issue(问题)
类似 pull 请求,issue 会与多个 repo 相关,可能还需要迁移。一个示例可能是一个首先针对发行版的内核存储库报告的错误。经过分类,很明显这是一个仍然存在于最新开发部门的驱动程序错误,因此它可能与此 repo 有关,可能还与主要的上游分支相关,或许还有其他的分支。
状态应该被反复分开,从一个 bug 修复到被推送到 repo,所有这些都不是即时可用的。这甚至可能需要额外的工作来获得对旧的内核或发行版的反馈,有些甚至可能不值得修复,还可能将其关闭为 WONTFIX,甚至认为它已被标记为在相关子系统存储库中成功解决。
Linux 内核不会迁移到 github。但是将 Linux 的扩展方式转变为单一的树,而不是多个库, 这样 github 作为一个概念将真正有益于已经在那里的所有大型项目:在我看来,它将提供一个更新更给力的方式来帮他们应对独特的挑战。