写点什么

什么是真正的 Monorepo?深入解析单一代码仓库的利与弊

作者:qife122
  • 2025-08-11
    福建
  • 本文字数:4619 字

    阅读完需:约 15 分钟

什么是真正的 Monorepo?

在软件公司中经常存在是否应该采用"Monorepo"(即"公司所有代码的单一版本控制仓库")的讨论。很多人做这个决定是基于 Google 的代码存储方式。我曾在拥有高度成熟 Monorepo 的公司(Google)和拥有先进多仓库系统的公司(LinkedIn)的开发者生产力团队工作过,必须指出:大多数人认为 Monorepo 具备的多数价值特性,其实与源代码控制仓库的数量无关。


实际上,人们(和 Google)所说的 Monorepo 包含多个不同概念:


  1. 跨项目的原子提交(因此所有代码都有一个原子性的"head"提交)

  2. 统一的目录层次结构和所有源代码的单一视图

  3. 代码检出和提交的单一入口点(包括所有读写工具)

  4. (有时)检出、提交和依赖的最小单位是文件

  5. (通常)没有项目的概念,只有目录和文件的概念

  6. (有时)单版本规则:任何时候仓库中任何依赖只能有一个版本

  7. 要求库维护者解决他们造成的问题的能力


下面我将详细讨论这些特性及其优缺点。

跨项目的原子提交

假设有两个独立项目 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"时似乎都在考虑该系统。但重要的是要分离这些概念,因为其中很多可以在你今天拥有的系统中实现。此外,也许并非所有这些实际上都是好的,也许你应该有意识地决定在你的业务中采用哪些。


-Max 更多精彩内容 请关注我的个人公众号 公众号(办公 AI 智能小助手)公众号二维码


办公AI智能小助手


用户头像

qife122

关注

还未添加个人签名 2021-05-19 加入

还未添加个人简介

评论

发布
暂无评论
什么是真正的Monorepo?深入解析单一代码仓库的利与弊_版本控制_qife122_InfoQ写作社区