深入解析 Monorepo:超越单一代码仓库的技术架构与权衡

本文深入探讨了Monorepo(单一代码仓库)的真正内涵,它远不止是将所有代码放入一个仓库。文章详细解析了其核心特性,包括跨项目原子提交、标准化目录结构、单一版本规则等,并对比了多仓库系统的差异,为技术决策提供全面视角。

什么是真正的 Monorepo?

在软件公司中,经常有人讨论是否应该采用“Monorepo”,即“存放公司所有代码的单一版本控制仓库”。人们做这个决定时,常常是基于谷歌就是这样存储代码的事实。

我曾在拥有非常先进 Monorepo 系统(谷歌)的公司和拥有非常先进多仓库系统(领英)公司的开发者生产力团队工作过。我必须告诉你:人们所认为的 Monorepo 的大部分宝贵特性,实际上与你拥有多少个源代码控制仓库毫无关系。事实上,人们(以及谷歌)所认为的 Monorepo 实际上是多个不同概念的集合:

  1. 跨项目的原子提交。(因此,整个代码库会有一个原子性的“头”提交向前移动。)
  2. 统一的目录层次结构和所有源代码的单一视图
  3. 检出或提交代码的单一场所。(包括所有读写内容的工具。)
  4. (有时)检出、提交和依赖的最小单位是文件
  5. (通常)没有“项目”的概念,只有目录和文件的概念。
  6. (有时)单一版本规则:仓库中任何依赖项在任何时候只能有一个版本。
  7. 要求库的维护者解决他们引起的问题的能力

我将更详细地讨论这些内容,包括它们的一些优点和缺点。

跨项目的原子提交

假设我们有两个独立的项目 A 和 B。我们希望进行一个同时影响这两个项目的更改。“Monorepo”的一部分特性是保证你可以同时对这两个项目进行原子提交。不存在项目 A 在提交 #1 而项目 B 在提交 #2 的仓库视图。

当你希望进行一项更改,而如果项目 A 和 B 没有在同一时间更改,其中一个就会损坏时,这一点尤其重要。例如,假设我们有一个名为 App 的项目,它依赖于一个名为 Library 的项目。我们希望更改 Library 中某个函数的签名,同时更新 App。如果我们只更新 Library 或只更新 App,那么 App 就会损坏。

这个特性在很大程度上依赖于将内容存放在单一源代码仓库中,因为“一个仓库”的实际定义就是“一个你可以原子性地提交多个文件的位置,它跟踪这些原子提交,并且你可以从该原子提交历史中的任意点进行检出”。

这个特性也意味着整个仓库对“头”(最近一次提交)有单一的定义。这一点值得思考,因为开发者从仓库检出时,通常检出的是“头”。这意味着当开发者检出时,无论他们同时检出多少项目,都能保证获得整个源代码树的一致视图。他们永远不必考虑是否将 AppLibrary 检出到了两个彼此不兼容的版本上。在大多数情况下(只要你有一个良好的测试系统来验证所有提交确实能工作,这本身就是一个复杂的问题),在任何给定提交下检出的代码都应该可以协同工作。

标准化的跨项目目录结构

Monorepo 中的所有代码都被认为存在于单一的目录结构中。这在开发时和在浏览代码时都有优势。

在开发期间:检出是标准化的 在开发过程中,如果项目 A 存储在仓库的 /path/to/project/A,项目 B 存储在 /path/to/project/B,那么当我同时检出它们时,它们将位于彼此相邻的目录中。我可以保证那就是目录结构。在开发过程中如果需要让它们协同工作,我永远不必思考应该将项目 A 放在磁盘上的什么位置,以及它与项目 B 的关系如何。

对于那些习惯了 Monorepo 的人来说,这可能看起来是个小细节。然而,在大多数多仓库系统中,这可能非常令人困惑。如果我正在处理一个依赖于 LibraryApp,并且我想在磁盘上同时修改它们以测试这两个修改如何协同工作,那么弄清楚如何让 App 使用我修改后的 Library 可能会非常混乱。

话虽如此,这个原则实际上并不要求单一的源代码仓库。可能存在一种由工具提供的标准化方式,即使你拥有多个仓库,也能确保项目总是以某种方式检出。

浏览代码的统一方式 由于你拥有单一的目录结构,通过代码搜索工具浏览目录相对简单,并且可以拥有一个搜索该单一仓库的代码搜索工具。

然而,这并不妨碍你通过某个 UI 工具或虚拟文件系统来获得多仓库系统的单一、统一视图。这更复杂,因为多仓库系统没有原子的“头”——所有仓库在不同的时间都处于不同的版本。但是,你可以(a)在你的代码审查工具的 UI 中解决这个问题(例如,使版本号成为人们浏览时看到的“路径”的一部分,或者让人们以某种方式选择版本),或者(b)决定在浏览或搜索时,总是看到每个仓库的“头”提交(这无论如何也是当今大多数代码搜索工具的工作方式)。

检出和提交的单一场所

这看起来可能不重要,但 Monorepo 的价值之一在于不必思考“我应该从哪个仓库检出?”相反,开发者只需要思考他们需要检出什么代码。同样,所有的提交都进入同一个仓库。

这也意味着你对整个历史中的所有提交有单一的视图,这有时会很有帮助(例如,当你为了调试目的,试图找出时间 A 和时间 B 之间所有可能发生的变化时)。

最后,所有工具只需考虑访问一个仓库——它们只需关心目录和文件名。

再一次,这并不真正要求只有一个仓库。你可以为多仓库系统设置一个外观(facade),以提供此功能的重要部分,例如历史的统一视图、单一的检出场所和单一的提交场所,如果这真的很重要的话。

文件是检出、提交和依赖的最小单位

在大多数 Monorepo 中,你可以提交的、被版本控制系统跟踪的最小单位是文件。系统知道“一个文件”发生了变化。它可能看起来能感知文件中的行,但那只是因为它可以通过比较先前版本与当前版本来生成文件的“差异”(diff)。当你提交时,新提交实际上包含了你修改文件的一个全新副本。

在某些 Monorepo 中,你还可以在不检出整个仓库的情况下检出单个文件。事实上,如果仓库变得非常大,这将成为非常重要的生产力特性。否则,你可能被迫检出与你的工作无关的千兆字节的代码。

此外,在一些 Monorepo(特别是谷歌的)中,依赖的最小单位是文件。这意味着构建系统可以知道一个文件依赖于另一个文件。它不能知道一个函数依赖于另一个函数,或者一个类依赖于另一个类。这意味着在构建时,你只需要构建你需要的特定文件,并在所有依赖项中传递性地进行。(应该指出的是,在谷歌的 Monorepo 中,有时你只能依赖于一组文件或整个目录,有时这更有意义。)

这些都不需要拥有单一的仓库,完全不需要。

没有“项目”的概念

由于所有东西都在同一个仓库中,没有固有的概念表明一组不同的目录可以共同代表一个单一的“项目”。构建系统可能知道一些目录被编译在一起以产生特定的制品,但是没有一个统一的方法可以仅通过查看目录结构之类的东西就能轻松地看到这一点。目录层次结构的任何级别都可能具有任何意义。仓库中可能存在一个代表整个项目的顶级目录。也可能存在一个三层之下的目录是一个项目,比如 /code/team/project。没有固有的规则(通常只有顶级目录被强制规定为非常宽泛的潜在项目类别,这些类别在其树中包含许多项目)。

相比之下,多仓库系统可以说每个仓库就是一个项目,这会为你提供一个更具体的制品来代表一个项目。然而,在多仓库系统中也并没有真正强制执行这一点。一个仓库里可能有四个项目,另一个仓库里有两个项目。

实际上,大部分最终是由你的构建系统的配置文件定义的,而不是由你的源代码仓库定义的。

单一版本规则

通常,Monorepo 会强制规定任何给定的软件在仓库中同一时间只能有一个版本。如果你签入一个库,在整个仓库中你可能只能签入该库的一个版本。由于你拥有 Monorepo,这最终意味着该公司在任何给定时间只能存在该库的一个版本。这(基本上)是谷歌 Monorepo 的工作方式。

这样做有多种原因。

首先,它让你更容易推理系统的行为。你总是知道你将得到哪个版本的依赖项。你不需要在每次检出一段代码时都检查你的传递依赖树来理解你实际得到的是什么,因为当你检出时,你得到的是存在于仓库中的该依赖项的版本。

但也许这样做最重要的原因是,大多数编程语言都强制规定在最终程序中任何特定依赖项只能有一个版本。否则,当你包含同一事物的多个版本时,它们在运行时会出现奇怪的行为。例如,在 Java 中,如果你在二进制文件中同时包含两个版本,那么将使用哪个版本的依赖项从程序员的角度来看本质上是随机的。在程序中包含多个版本可能导致一些非常复杂且难以调试的运行时错误。

这个问题是可以解决的,现代语言或框架中的许多依赖项解析系统也确实解决了这个问题。一些系统允许存在一个依赖项的多个版本,并且调用代码实际上“知道”它们期望调用哪个版本。其他系统会“强制升级”所有版本的依赖项为最新版本,或“强制降级”所有版本为最旧版本。

然而,所有这些只有在你的系统有项目和这些项目的版本概念时才存在,而大多数 Monorepo 没有这个概念。

这条规则有一些相当显著的缺点。如果你拥有一段许多人依赖的代码,那么升级这段代码可能会非常困难,因为你所做的任何更改都会破坏某些人。你不能分叉你的代码库,将依赖你的每个人逐步迁移到新版本,然后删除旧版本。相反,当你进行破坏性更改时,你必须要么:

  1. 一次性提交给每个依赖你的项目
  2. 玩一个“舞蹈”,即创建一个没有调用者的新函数,提交它,然后在多次提交中将你的调用者转移到使用新函数,最后删除旧函数。
  3. 决定永不进行破坏性更改,即使你是一个内部库。

老实说,上面的选项(b)并不那么糟糕。它实际上是一种良好的软件实践,但对于库维护者来说,这可能工作量很大,有时工作量如此之大,以至于维护者默认选择(c),并让他们的系统随着时间的推移越来越停滞。

真正成为问题的是第三方库。如果所有代码都必须存在于你的仓库中,那就意味着你必须将第三方库签入你的仓库。并且对于公司里的每个人来说,只能有一个版本。但你不是这些库的维护者,你实际上无法进行上述选项(b)的函数舞蹈。

此外,外部世界并不是一个 Monorepo。那里的库依赖于其他库的特定版本。假设你签入了库 A,导致你必须签入库 B、C 和 D 作为依赖项。但后来有人想签入库 X,它需要 C 的一个新版本。但这要求他们现在必须升级库 A。但是对库 A 的升级会破坏所有依赖库 A 的人,所以现在那个只想签入一个库以便使用它的人,却必须升级所有依赖库 A 的人。

当你在仓库内拥有一个被广泛使用的第三方库时,情况会变得更糟。通常,它们会“卡”在某个特定版本上,永远不会被升级,因为升级它们实在太难了。相反,人们开始为该库引入他们知道不会破坏它的选择性补丁。或者他们开始自己修复它,并与上游分道扬镳,使得以后很难或不可能升级到外部版本。

关于单一版本规则的另一点是,在复杂的多服务环境中,生产中的系统都是基于不同版本构建的,所以现实情况是,在生产中你实际上总是同时经历多个版本的事物。单一版本规则提供了一个礼貌的虚构,使得在开发时大多数情况下的生活更轻松,但它也可能让你忘记,当你有多个程序相互作用时,这实际上并不真实。

值得注意的是,这条规则并不真正需要一个 Monorepo。你可以允许在你的所有仓库中只存在一个依赖项的版本。然后你只需要强制规定你公司所有的仓库总是在“头”上构建,并且只在“头”上消费彼此的代码,你就能获得基本相同的效果。我不是推荐你这么做,只是指出你可以这么做。是否这样做取决于你。

让库维护者解决他们引起的问题

在 Monorepo 的世界里,如果你拥有一个库,你可以通过签入一些与依赖你的项目不兼容的东西来破坏每个依赖你的项目的构建。这在单一版本的世界里尤其如此,库所有者必须签入每个人都依赖的那个单一版本的库。这意味着库维护者不能只是强迫他们的消费者做所有升级到新版本库的工作。库维护者必须深入研究并自己完成工作。如果他们认为进行破坏性更改是值得的,他们必须为企业承担成本。否则,库维护者可能会在不与消费者沟通的情况下,为他们的消费者创造大量计划外的工作。(有时,这些消费者代表的项目甚至已经没有开发人员了,但对业务仍然很重要,因此甚至没有人来做升级工作。)

这主要是公司政策的问题,但在一个你实际上可以强制执行它,并且存在某种系统在库开发者给他人带来痛苦时也让他们自己感到痛苦的世界里,这要容易得多。例如,让很多团队抱怨他们的构建被破坏就可以成为那种痛苦。在一些 Monorepo 中,你实际上可以完全阻止库维护者签入他们的更改,因为测试系统会运行所有他们的消费者的测试,并阻止破坏性更改进入。

这种强制执行并不完全需要单一的源代码仓库。有多种方法可以在多仓库系统中实现这一点或其部分内容。

总结

所以你可以看到,“Monorepo”实际上远不止是把所有东西都放在一个源代码仓库里。有些人将所有这些事情归为一类,因为上面基本是对谷歌 Monorepo 的描述,而且大多数人在谈论“一个 Monorepo”时,似乎都想到了那个系统。但是,将这些概念分开是很重要的,因为它们中的很多都可以在你今天拥有的系统中实现。此外,也许并非所有这些事情都是真正好的,也许你应该有意识地选择你想要在你的业务中采纳其中的哪些。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计