什么是真正的单代码库(Monorepo)?
在软件公司中,经常会有关于是否应该采用“单代码库”(即“公司所有代码都存放在一个版本控制的仓库中”)的讨论。通常,人们会基于谷歌如何存储代码来做出这一决定。作者曾在拥有非常先进的单代码库的公司(谷歌)和拥有非常先进的多代码库系统的公司(LinkedIn)的开发者生产力组织工作过,并指出:大多数与单代码库相关的有价值特性实际上与拥有多少个源代码仓库无关。
事实上,人们(以及谷歌)所认为的单代码库实际上是多个不同的概念:
- 跨项目的原子提交(因此有一个原子的“头”提交,所有代码都原子地向前移动)。
- 统一的目录层次结构和所有源代码的单一视图。
- 检出或提交代码的单一位置(包括所有读取或写入内容的工具)。
- (有时)检出、提交和依赖的最小单位是文件。
- (通常)没有项目的概念,只有目录和文件的概念。
- (有时)单一版本规则:任何依赖在仓库中同一时间只能存在一个版本。
- 要求库维护者解决他们引起的问题的能力。
本文将更详细地讨论这些概念,包括它们的一些优点和缺点。
跨项目的原子提交
假设我们有两个独立的项目A和B。我们想要做一个同时影响这两个项目的更改。单代码库的一部分是保证您可以同时原子地提交到这两个项目。不存在仓库的视图显示项目A处于提交#1而项目B处于提交#2的情况。
这在您想要进行更改时尤其重要,如果项目A或B没有同时更改,它们就会损坏。例如,假设我们有一个名为App的项目,它依赖于一个名为Library的项目。我们想要更改Library中一个函数的签名,并同时更新App。如果我们只更新Library或只更新App,那么App就会损坏。
这个特性最依赖于事物位于单个源代码仓库中,因为“仓库”的实际定义是“一个可以原子地提交多个文件、跟踪这些原子提交、并且可以从该原子提交历史中的任何点检出的位置”。
这个特性还意味着整个仓库有一个单一的“头”(最新提交)定义。这一点很重要,因为当开发者从仓库中检出时,他们通常是在“头”处检出。这意味着当开发者检出时,无论他们同时检出多少项目,都能保证整个源代码树的一致性视图。他们永远不必考虑是否检出了两个不兼容版本的App和Library。在大多数情况下(只要您有一个良好的测试系统来验证所有提交实际上都能工作,这本身就是一个复杂的问题),在任何给定提交处检出的代码都应该能一起工作。
标准化的跨项目目录结构
单代码库中的所有代码都被认为位于一个单一的目录结构中。这在开发时和浏览代码时都有优势。
开发时:检出是标准化的
在开发过程中,如果项目A存储在仓库中的/path/to/project/A
,项目B存储在仓库中的/path/to/project/B
,那么当我同时检出它们时,它们将位于彼此相邻的目录中。我可以保证这就是目录结构。如果我需要在开发时让它们一起工作,我永远不必考虑应该将项目A放在磁盘上的什么位置相对于项目B。
对于那些习惯单代码库的人来说,这可能看起来是一个小细节。然而,在大多数多代码库系统中,这可能非常令人困惑。如果我在开发一个依赖于Library的App,并且我想在磁盘上修改它们以测试这两个修改如何一起工作,那么弄清楚如何让App使用我修改后的Library可能会非常令人困惑。
尽管如此,这个原则实际上并不需要单一的源代码仓库。工具可以提供一种标准化的方式,即使您有多个仓库,项目也总是以这种方式检出。
统一的代码浏览方式
由于您有一个单一的目录结构,在代码搜索工具中浏览目录相对简单,并且可以有一个单一的代码搜索工具来搜索该仓库。
然而,没有什么能阻止您通过某些UI工具或某些虚拟文件系统拥有多代码库系统的单一通用视图。这更复杂,因为多代码库系统没有原子的“头”——所有仓库在不同时间处于不同的版本。但是,您可以(a)在代码审查工具的UI中考虑这一点(例如,通过使版本号成为人们浏览时看到的“路径”的一部分,或者让人们以某种方式选择版本),或者(b)决定在浏览或搜索时,您总是看到每个仓库的“头”提交(这也是大多数代码搜索工具今天的工作方式)。
检出和提交的单一位置
这可能看起来不重要,但单代码库的价值之一是不必思考“我应该从哪个仓库检出?”相反,开发者只需要考虑他们需要检出什么代码。类似地,所有提交都进入同一个仓库。
这也意味着您拥有整个历史中所有提交的单一视图,这有时会很有帮助(例如,当您试图找出时间A和时间B之间可能发生的一切变化以进行调试时)。
最后,所有工具只需要担心访问单个仓库——它们只需要关心目录和文件名。
再次强调,这并不真正需要只有一个仓库。您可以在多代码库系统前面设置一个外观,提供此功能的重要部分,例如统一的历史视图、单一的检出位置和单一的提交位置,如果这真的很重要的话。
文件是检出、提交和依赖的最小单位
在大多数单代码库中,您可以提交的、由版本控制系统跟踪的最小单位是文件。系统知道“一个文件”是发生了什么变化。它可能似乎知道文件中的行,但这只是因为它可以通过比较先前版本和当前版本来重现对文件的更改作为“差异”。当您提交时,新提交实际上包含您修改的文件的完整新副本。
在一些单代码库中,您还可以在不检出整个仓库的情况下检出单个文件。事实上,如果仓库变得非常大,这就成为一个非常重要的生产力特性。否则,您可能被迫检出与您工作无关的千兆字节的代码。
此外,在一些单代码库中(特别是谷歌的),依赖的最小单位是文件。这意味着构建系统可以知道一个文件依赖于另一个文件。它不能知道一个函数依赖于另一个函数,或者一个类依赖于另一个类。这意味着当您构建时,您只需要构建您需要的特定文件,跨所有依赖项传递。(应该注意的是,在谷歌的单代码库中,有时您只能依赖于一组文件或整个目录,有时这更有意义。)
所有这些都不需要拥有单个仓库。
没有项目的概念
由于所有内容都在同一个仓库中,没有固有的概念认为不同目录的集合可以代表一个单一的“项目”。构建系统可能知道某些目录被编译在一起以产生特定的工件,但没有通用的方式可以通过查看目录结构或类似的东西来轻松看到这一点。目录层次结构的任何级别都可能具有任何意义。仓库中可能有一个顶级目录是一个完整的项目。可能有一个向下三级的目录是一个项目,比如/code/team/project
。没有固有的规则(除了通常顶级目录被强制规定为包含许多项目的潜在项目的非常广泛的类别)。
相比之下,多代码库系统可以说每个仓库都是一个项目,这将为您提供一个更具体的工件来表示一个项目。然而,在多代码库系统中也没有真正强制执行这一点。一个仓库中可能有四个项目,另一个仓库中可能有两个项目。
实际上,大多数这些最终都是由您的构建系统的配置文件定义的,而不是由您的源代码仓库定义的。
单一版本规则
通常,单代码库会强制规定任何给定软件在仓库中同一时间只能存在一个版本。如果您签入一个库,您可能只能在整个仓库中签入该库的一个版本。由于您有一个单代码库,这最终意味着该库在公司中任何给定时间只能存在一个版本。这(主要)是谷歌单代码库的工作方式。
这样做有多个原因。
首先,它使推理系统行为变得容易得多。您总是理解您将获得哪个版本的依赖项。您不必每次检出一段代码时都检查您的传递依赖树以理解您实际获得的是什么,因为您获得的是您检出时仓库中存在的该依赖项的版本。
但也许这样做的最重要原因是大多数编程语言强制规定在最终程序中只能存在任何特定依赖项的一个版本。否则,当您包含同一事物的多个版本时,它们在运行时最终会出现奇怪的行为。例如,在Java中,如果您在二进制文件中包含两个版本,那么使用哪个版本的依赖项本质上是随机的(从程序员的角度来看)。在程序中包含多个版本可能导致一些非常复杂且难以调试的运行时错误。
这个问题可以解决,现代语言或框架中的许多依赖项解析系统确实解决了这个问题。一些系统允许多个版本的依赖项存在,并且调用代码实际上“知道”它们期望调用哪个版本。其他系统将“强制升级”所有版本的依赖项到最新版本,或“强制降级”所有版本到最旧版本。
然而,所有这些只有在您的系统具有项目和这些项目的版本的概念时才存在,而大多数单代码库没有。
这个规则有一些相当显著的缺点。如果您拥有一段很多人依赖的代码,升级这段代码可能非常困难,因为您所做的任何更改都会破坏某些人。您不能分叉代码库,逐步将所有依赖您的人移动到新版本,然后删除旧版本。相反,当您进行破坏性更改时,您必须要么:
(a) 一次性提交到所有依赖您的项目
(b) 进行一种舞蹈,创建一个没有调用者的新函数,提交它,然后在多次提交中将您的调用者移动到使用新函数,然后删除旧函数。
(c) 决定从不进行破坏性更改,即使您是一个内部库
老实说,上面的选项(b)并不那么糟糕。它实际上是一种良好的软件实践,但对于库维护者来说可能工作量很大,有时工作量如此之大,以至于维护者默认选择(c),并让他们的系统随着时间的推移越来越停滞。
这真正成为问题的是第三方库。如果所有代码都必须存在于您的仓库中,那意味着您必须将第三方库签入您的仓库。并且对于公司中的每个人来说,只能存在一个版本。但您不是这些库的维护者,您无法实际进行选项(b)的函数舞蹈。
此外,外部世界不是单代码库。那里的库依赖于其他库的特定版本。假设您签入了库A,这导致您必须签入库B、C和D作为依赖项。但然后有人想要签入库X,它需要更新版本的C。但这要求他们现在必须升级库A。但升级库A会破坏所有依赖库A的人,所以现在只想签入单个库以便使用它的人必须升级所有依赖库A的人。
当您在仓库内部有一个非常广泛使用的第三方库时,情况会变得更糟。通常,它们会“卡”在特定版本上,永远不会升级,因为升级它们太难了。相反,人们开始引入他们知道不会破坏它的库的选择性补丁。或者他们开始自己修复它并与上游分叉,使得以后升级到外部版本变得困难或不可能。
关于单一版本规则的另一点是,在复杂的多服务环境中,生产中的系统都是在不同版本构建的,所以现实是您实际上总是在生产中体验事物的多个版本。单一版本规则提供了一个礼貌的虚构,使大多数情况下的开发生活更轻松,但当您有多个程序相互交互时,它也可能让您忘记这实际上并不真实。
值得注意的是,这个规则并不真正需要单代码库。您可以允许所有仓库中只存在一个版本的依赖项。然后您只需要强制规定公司中的所有仓库始终在头部构建,并且只在头部消费彼此的代码,您就会产生基本相同的效果。我不是建议您这样做,只是指出您可以。是否这样做取决于您。
让库维护者解决他们引起的问题
在单代码库的世界中,如果您拥有一个库,您可以通过签入与那些项目不兼容的内容来破坏每个依赖您的项目的构建。在单一版本的世界中尤其如此,库所有者必须签入每个人都依赖的库的单一版本。这意味着库维护者不能只是强制他们的消费者完成升级到库新版本的所有工作。库维护者必须深入并自己完成工作。如果他们认为进行破坏性更改是值得的,他们必须为企业承担成本。否则,库维护者可能会在没有与消费者沟通的情况下为他们的消费者创造大量计划外的工作。(有时那些消费者代表甚至不再有开发者的项目,但对业务仍然很重要,所以甚至没有人来做升级工作。)
这主要是公司政策的问题,但在您可以实际执行它并且有某种系统在库开发者给他人带来痛苦时让他们感到痛苦的世界中,这样做要容易得多。例如,有很多团队抱怨他们的构建被破坏可能就是那种痛苦。在一些单代码库中,您实际上可以阻止库维护者签入他们的更改,因为测试系统运行所有消费者的测试并阻止破坏性更改进入。
这种强制执行并不完全需要单一的源代码仓库。在多仓库系统中有各种方法可以实现这一点或部分实现。
总结
所以您可以看到,“单代码库”实际上远不止是将所有东西放在一个源代码仓库中。有些人将所有这些事物分组在一起,因为上面基本上是对谷歌单代码库的描述,大多数人在谈论“单代码库”时似乎都在考虑那个系统。但分离这些概念很重要,因为其中许多可以在您今天拥有的系统中实现。此外,也许并非所有这些事物实际上都是好的,也许您应该有意识地选择您试图在企业中采用哪些。
-Max