什么是真正的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中,你可以提交的、由版本控制系统跟踪的最小单位是文件。系统知道"一个文件"发生了变化。它可能似乎知道文件中的行,但这只是因为它可以通过比较先前版本和当前版本将文件的更改再现为"diff”。当你提交时,新提交实际上包含了你修改的文件的全新副本。
在一些monorepo中,你还可以在不检出整个仓库的情况下检出单个文件。实际上,如果仓库变得非常大,这就成为一个非常重要的生产力特性。否则,你可能被迫检出与你正在处理的内容无关的千兆字节的代码。
此外,在一些monorepo中(特别是Google的),依赖的最小单位是文件。这意味着构建系统可以知道一个文件依赖于另一个文件。它不能知道一个函数依赖于另一个函数,或者一个类依赖于另一个类。这意味着当你构建时,你只需要构建你需要的特定文件,跨所有依赖项传递。(应该注意的是,在Google的monorepo中,有时你只能依赖一组文件或整个目录,有时这更有意义。)
所有这些都不需要拥有单一仓库。
没有项目的概念
由于所有内容都在同一个仓库中,没有固有的概念认为不同目录的集合都可以代表一个单一的"项目"。构建系统可能知道某些目录被编译在一起以产生特定的工件,但没有通用的方法可以通过查看目录结构或类似的东西来轻松看到这一点。目录层次结构的任何级别都可能具有任何意义。仓库中可能有一个顶级目录是一个完整的项目。可能有一个向下三级的目录是一个项目,比如/code/team/project
。没有固有的规则(除了通常顶级目录被强制规定为包含许多项目的潜在项目的非常广泛的类别)。
相比之下,多仓库系统可以说每个仓库都是一个项目,这将为你提供一个更具体的工件来表示一个项目。然而,在多仓库系统中也没有真正强制执行这一点。一个仓库中可能有四个项目,另一个仓库中可能有两个项目。
实际上,大部分内容最终都是由你的构建系统的配置文件定义的,而不是由你的源代码仓库定义的。
单一版本规则
通常,monorepo会强制规定任何给定软件在仓库中同时只能存在一个版本。如果你检入一个库,你可能只能在