什么是真正的Monorepo?
在软件公司中,经常会有关于是否应该采用"Monorepo"的讨论,即"公司所有代码都存放在一个版本控制仓库中"。通常,人们基于Google就是这样存储代码的事实来做决定。
我曾在拥有非常先进Monorepo的公司(Google)和拥有非常先进多仓库系统的公司(LinkedIn)的开发者生产力组织工作过,我必须告诉你们:人们与Monorepo关联的大多数有价值属性与你有多少个源代码控制仓库无关。
实际上,人们(和Google)认为的Monorepo实际上是多个不同的概念:
- 跨项目的原子提交(因此有一个原子的"头"提交,为所有代码原子性地向前移动)
- 通用目录层次结构和所有源代码的单一视图
- 检出或提交代码的单一位置(包括所有读取或写入内容的工具)
- (有时)检出、提交和依赖的最小单位是文件
- (通常)没有项目的概念,只有目录和文件的概念
- (有时)单一版本规则:任何时候仓库中任何依赖只能有一个版本
- 要求库维护者解决他们引起的问题的能力
跨项目的原子提交
假设我们有两个独立的项目A和B。我们想要进行影响这两个项目的更改。“Monorepo"的一部分是保证你可以同时原子性地提交到这两个项目。不存在项目A处于提交#1而项目B处于提交#2的仓库视图。
当你想要进行更改,而如果项目A或B没有同时更改就会被破坏时,这一点尤其重要。例如,假设我们有一个名为App的项目,它依赖于一个名为Library的项目。我们想要更改Library中函数的签名,并同时更新App。如果我们只更新Library或只更新App,那么App就会被破坏。
这个特性最依赖于将内容放在单个源代码仓库中,因为"仓库"的实际定义是"你可以原子性地提交多个文件的位置,它跟踪这些原子提交,并且你可以从该原子提交历史中的任何点检出”。
这个特性还意味着整个仓库有一个单一的"头"(最新提交)定义。这一点很重要,因为当开发者从仓库检出时,他们通常会在"头"处检出。这意味着当开发者检出时,无论他们同时检出多少项目,都能保证整个源代码树的一致视图。他们永远不必考虑是否检出了两个相互不兼容的App和Library版本。在很大程度上(只要你有一个良好的测试系统来验证所有提交实际上都能工作,这本身就是一个复杂的问题),在任何给定提交处检出的代码都应该一起工作。
标准化的跨项目目录结构
Monorepo中的所有代码都被认为是在单个目录结构中。这在开发时和浏览代码时都有优势。
开发期间:检出是标准化的
在开发期间,如果项目A存储在仓库中的/path/to/project/A,项目B存储在仓库中的/path/to/project/B,那么当我同时检出它们时,它们将位于彼此相邻的目录中。我可以保证这将是目录结构。如果我需要在开发期间让它们一起工作,我永远不必考虑应该将项目A放在磁盘上的什么位置与项目B相关。
对于那些习惯使用Monorepo的人来说,这可能看起来是一个小细节。然而,在大多数多仓库系统中,这可能非常令人困惑。如果我正在开发一个依赖于Library的App,并且我想在磁盘上同时修改它们以测试这两个修改如何协同工作,那么弄清楚如何让App使用我修改过的Library可能会非常令人困惑。
尽管如此,这个原则实际上并不需要单一的源代码仓库。即使你有多个仓库,也可以通过工具提供标准化的方式,使项目始终以相同方式检出。
统一的代码浏览方式
由于你有单一的目录结构,在代码搜索工具中浏览目录相对简单,并且可以使用单一的代码搜索工具来搜索该仓库。
然而,没有什么能阻止你通过某些UI工具或某些虚拟文件系统来拥有多仓库系统的单一通用视图。这更复杂,因为多仓库系统没有原子的"头"——所有仓库在不同时间处于不同版本。但是,你可以(a)在代码审查工具的UI中考虑这一点(例如,通过使版本号成为人们浏览时看到的"路径"的一部分,或者让人们以某种方式选择版本),或者(b)决定在浏览或搜索时,你总是看到每个仓库的"头"提交(这也是当今大多数代码搜索工具的工作方式)。
单一的检出和提交位置
这可能看起来不重要,但Monorepo的价值之一是不必思考"我应该从哪个仓库检出?“相反,开发者只需要考虑他们需要检出什么代码。类似地,所有提交都进入同一个仓库。
这也意味着你对整个历史的所有提交有一个单一的视图,这有时会很有帮助(例如,当你为了调试目的试图找出时间A和时间B之间可能改变的所有内容时)。
最后,所有工具只需要担心访问单个仓库——它们只需要关心目录和文件名。
再次强调,这并不真正需要只有一个仓库。你可以在多仓库系统前面设置一个外观,提供此功能的重要部分,例如统一的历史视图、单一的检出位置和单一的提交位置,如果这真的很重要的话。
文件是检出、提交和依赖的最小单位
在大多数Monorepo中,你可以提交的、由版本控制系统跟踪的最小东西是一个文件。系统知道"一个文件"是改变的内容。它可能似乎知道文件中的行,但这只是因为它可以通过比较先前版本和当前版本将文件的更改再现为"差异”。当你提交时,新提交实际上包含了你修改的文件的完整新副本。
在一些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。你可以允许依赖只有一个版本存在于所有仓库中。然后你只需要强制规定公司所有仓库始终在头部构建,并且只在头部消费彼此的代码,你基本上会有相同的效果。我不是推荐你这样做,只是指出你可以。是否这样做取决于你。
让库维护者解决他们引起的问题
在Monorepo世界中,如果你拥有一个库,你可以通过签入与这些项目不兼容的内容来破坏每个依赖你的项目的构建。在单一版本世界中尤其如此,库所有者必须签入每个人都依赖的库的单一版本。这意味着库维护者不能只是强制他们的消费者做所有升级到库新版本的工作。库维护者必须深入并自己做这项工作。如果他们认为进行破坏性更改是值得的,他们必须为企业承担成本。否则,库维护者可能在不与消费者交谈的情况下为他们的消费者创造大量计划外工作。(有时这些消费者代表甚至不再有开发者的项目,但对业务仍然很重要,所以甚至没有人在那里做升级工作。)
这主要是公司政策的问题,但在你可以实际执行它的世界中,以及存在某些系统在库开发者给他人造成痛苦时给他们带来痛苦的世界中,这要容易得多。例如,有很多团队抱怨他们的构建被破坏可能是那种痛苦。在一些Monorepo中,你实际上可以阻止库维护者签入他们的更改,因为测试系统运行所有消费者的测试并阻止破坏性更改进入。
这种强制执行并不完全需要单一的源代码仓库。在多仓库系统中有各种方法来实现这一点或部分实现这一点。
总结
所以你可以看到,“Monorepo"实际上远不止是只有一个你放所有东西的源代码仓库。有些人已经将所有这些事物组合在一起,因为上面基本上是对Google Monorepo的描述,大多数人在谈论"Monorepo"时似乎都在考虑那个系统。但分离这些概念很重要,因为其中许多可以在你今天拥有的系统中实现。此外,也许并非所有这些事情实际上都是好的,也许你应该有意识地选择要在业务中采用哪些。
-Max