深入解析:什么是真正的Monorepo?

本文深入探讨了Monorepo(单一代码仓库)的真正内涵,指出其不仅是将所有代码放在一个仓库,更涉及到跨项目的原子提交、标准化目录结构、单一版本规则等一系列关键技术概念和利弊权衡。

什么是真正的Monorepo?

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

我曾在拥有非常先进的Monorepo(Google)和拥有非常先进的多仓库系统(LinkedIn)公司的开发效率组织工作过,我必须告诉你们:人们认为Monorepo所具有的大部分有价值的属性,与你有多少个源码控制仓库几乎没有关系。事实上,人们(和Google)所认为的Monorepo实际上是多个不同的概念:

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

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

跨项目的原子提交

假设我们有两个独立的项目A和B。我们想要做一个同时影响两者的更改。Monorepo的一部分特性是保证你可以同时原子性地提交到这两个项目。不存在仓库的某个视图中,项目A处于提交#1而项目B处于提交#2的情况。

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

这个特性最依赖于将东西放在单一的源代码仓库中,因为“一个仓库”的实际定义就是“一个你可以原子性地提交多个文件的位置,它跟踪这些原子提交,并且你可以检出该原子提交历史中的任何时间点”。

这个特性也意味着整个仓库有一个单一的“head”(最新提交)定义。这一点很重要,因为当开发者从仓库检出版本时,他们通常检出的是“head”。这意味着当开发者检出版本时,他们保证能看到整个源代码树的一致视图,无论他们同时检出了多少项目。他们永远不必考虑自己是否检出了两个相互不兼容版本的App和Library。在很大程度上(只要有一个良好的测试系统来验证所有提交确实能工作,这本身就是一个复杂的问题),在任何给定提交下检出的代码都应该能协同工作。

标准化的跨项目目录结构

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

开发时:检出操作是标准化的

在开发过程中,如果项目A存储在仓库的 /path/to/project/A,项目B存储在仓库的 /path/to/project/B,那么当我将它们都检出时,它们就会位于彼此相邻的目录中。我可以保证那就是目录结构。如果我需要在开发时让它们协同工作,我永远不必考虑在磁盘上,相对于项目B,我应该把项目A放在哪里。

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

话虽如此,这个原则实际上并不要求单一的源代码仓库。即使你有多个仓库,也可以通过工具提供一种标准化的方式,规定项目始终以特定方式检出。

统一的代码浏览方式

因为你有一个单一的目录结构,所以在你的代码搜索工具中浏览目录相对直接,并且可以拥有一个搜索这个单一仓库的单一代码搜索工具。

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

一个用于检出和提交的单一地方

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

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

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

再次强调,这实际上并不要求只有一个仓库。如果你的多仓库系统前面有一个门面(facade),提供此功能的重要部分,例如统一的历史视图、一个单一的检出位置和一个单一的提交位置,如果这真的很重要的话。

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

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

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

此外,在一些Monorepo中(特别是Google的),依赖的最小单位是文件。这意味着构建系统可以知道一个文件依赖于另一个文件。它无法知道一个函数依赖于另一个函数,或者一个类依赖于另一个类。这意味着当你构建时,你只需要构建你需要的特定文件,并递归地跨越你所有的依赖项。(需要注意的是,在Google的Monorepo中,有时你只能依赖于一组文件或整个目录,有时这更有意义。)

这完全不要求拥有单一仓库。

没有项目的概念

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

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

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

单一版本规则

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

这样做有多个原因。

首先,它让你更容易推理系统的行为。你总是能理解你将获得依赖的哪个版本。你不必每次检出一段代码时都检查你的传递依赖树,以理解你实际获得了什么,因为你获得的是当你检出时仓库中存在的那个依赖的版本。

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

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

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

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

(a) 一次性提交到所有依赖你的项目 (b) 进行一种操作:创建一个没有调用者的新函数,提交它,然后通过大量提交将调用者迁移到使用新函数,最后删除旧函数。 (c) 决定从不进行破坏性更改,即使你是一个内部库

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

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

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

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

关于单一版本规则的另一点是,在复杂的多服务环境中,生产中的系统都是在不同版本下构建的,所以现实情况是,你在生产中实际上总是经历着多个版本的事物。单一版本规则提供了一个美好的假设,在大多数情况下使开发时更轻松,但当你拥有多个程序相互交互时,它也会让你忘记这实际上并非事实。

值得注意的是,这个规则并不真正要求Monorepo。你可以规定所有仓库中只能存在一个依赖版本。然后你只需要强制规定公司内所有仓库始终在head构建,并且只在head上相互消费彼此的代码,你就能获得基本相同的效果。我并不是建议你这样做,只是指出你可以。是否这样做取决于你。

让库维护者解决他们造成的问题

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

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

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

总结

所以你可以看到,“Monorepo”实际上远不止是将你所有的东西放在一个单一的源代码仓库里。一些人将这些所有东西归为一类,因为上面基本上是对Google Monorepo的描述,而且大多数人在谈论“一个Monorepo”时似乎都在考虑那个系统。但是将这些概念分开很重要,因为它们中的很多都可以在你现有的系统中实现。另外,也许并不是所有这些概念都是好的,也许你应该有意识地决定在你的业务中采用其中哪些。

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