什么是真正的Monorepo?
作者:Max Kanat-Alexander | 2022年3月11日
在软件公司中经常出现是否应该采用"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可能会非常混乱。
统一的代码浏览方式
由于你有单一的目录结构,通过代码搜索工具浏览目录相对简单,并且可以使用搜索该单一仓库的单一代码搜索工具。
唯一的检出和提交位置
这看起来可能不重要,但monorepo的一个价值是不必思考"我应该从哪个仓库检出?“开发者只需要考虑他们需要检出什么代码。类似地,所有提交都去往同一个仓库。
这也意味着你对整个历史有单一的视图,有时这会很有帮助(例如当出于调试目的试图找出时间A和时间B之间可能改变的所有内容时)。
最后,所有工具只需要担心访问单个仓库——它们只需要关心目录和文件名。
文件是最小的检出、提交和依赖单位
在大多数monorepo中,你可以提交的最小单位(由版本控制系统跟踪的)是文件。系统知道"一个文件"是改变的内容。它可能看起来知道文件中的行,但那只是因为它可以通过比较前一个版本和当前版本来重现对文件的更改作为一个"diff”。当你提交时,新提交实际上包含你修改文件的完整新副本。
在一些monorepo中,你也可以在不检出整个仓库的情况下检出单个文件。事实上,如果仓库变得非常大,这就成为一个非常重要的生产力特性。否则你可能被迫检出与你工作无关的千兆字节代码。
此外,在一些monorepo中(特别是Google的),最小的依赖单位是文件。这意味着构建系统可以知道一个文件依赖于另一个文件。它不能知道一个函数依赖于另一个函数,或者一个类依赖于另一个类。这意味着当你构建时,你只需要构建你需要的特定文件,跨所有依赖传递地。
没有项目的概念
由于所有内容都在同一个仓库中,没有固有的概念认为不同目录的集合可以代表单个"项目"。构建系统可能知道一些目录被一起编译以产生特定的产物,但没有通过查看目录结构等就能轻松看到的通用方式。目录层次结构的任何级别都可能具有任何意义。
相比之下,多仓库系统可以说每个仓库都是一个项目,这会给你一个更具体的产物来代表一个项目。然而,在多仓库系统中也没有真正强制执行这一点。一个仓库中可能有四个项目,另一个仓库中可能有两个项目。
单一版本规则
通常,monorepo会强制规定任何给定软件在仓库中同时只能存在一个版本。如果你检入一个库,在整个仓库中你可能只能检入该库的一个版本。由于你有一个monorepo,这最终意味着在公司中任何给定时间该库只能存在一个版本。这基本上是Google monorepo的工作方式。
这样做有多个原因。
首先,它使推理系统行为变得容易得多。你总是理解你将获得依赖的哪个版本。你不必每次检出代码时都检查传递依赖树来理解你实际获得什么,因为你获得的是当你检出时仓库中存在的依赖版本。
但这样做最重要的原因可能是大多数编程语言强制规定在最终程序中只能存在一个特定依赖的版本。否则,当你包含同一事物的多个版本时,它们在运行时会有奇怪的行为。例如,在Java中,如果你在二进制文件中包含两个版本,使用哪个依赖版本基本上是随机的(从程序员的角度看)。在程序中包含多个版本可能导致一些非常复杂和难以调试的运行时错误。
让库维护者解决他们造成的问题
在monorepo的世界中,如果你拥有一个库,你可以通过检入与依赖你的项目不兼容的内容来破坏每个项目的构建。在单一版本的世界中尤其如此,库所有者必须检入库的单一版本,每个人都依赖该版本。这意味着库维护者不能只是强迫他们的消费者完成升级到库新版本的所有工作。库维护者必须亲自完成这项工作。如果他们认为进行破坏性更改是值得的,他们必须为企业承担成本。
这主要是公司政策的问题,但在你可以实际执行它的世界中,以及有某些系统在库开发者给他人造成痛苦时给他们带来痛苦的世界中,这要容易得多。例如,有很多团队抱怨他们的构建被破坏可能就是那种痛苦。在一些monorepo中,你实际上可以阻止库维护者检入他们的更改,因为测试系统运行他们所有消费者的测试并阻止破坏性更改进入。
总结
所以你可以看到"monorepo"实际上远不止是只有一个你放所有东西的源代码仓库。有些人把这些东西都组合在一起,因为上面基本上是对Google monorepo的描述,大多数人在谈论"monorepo"时似乎都在想那个系统。但重要的是分离这些概念,因为它们中的很多可以在你今天拥有的系统中实现。另外,也许不是所有这些实际上都是好的,也许你应该有意识地选择你想在企业中采用哪些。