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