为什么我不使用 MultiRepo
Jun 9 · 20min
我已经多次开始写这篇文章,但一直无法完成并发表它。我找不到合适的方式来表达我对 MultiRepo 的看法。但这一次,我想我应该尽力去表达清楚,以供将来作参考。
首先,我其实并不是非常讨厌 MultiRepo。我对于它保持了十足的尊重。
我尊重 MultiRepo
「 MultiRepo / one-repository-per-module 」 是一个非常广泛的包管理方式,提倡将项目按照模块分为多个代码仓库,通过科学的分解,可是实现每个repo独立开发和测试从而提升开发效率。
但是需要在开发过程中,需要避免过度的细分。一定要有一个机制来把握全局,时刻监控整个开发环境是否正常。
那么为什么不使用
如果你曾经看过我的个人项目,你会发现我大多数项目都没有使用 MultiRepo。在这篇文章中,我会尝试给出为什么我要这么做的理由:
它是分散的
由于在 MultiRepo 当中,各个项目的工作流是割裂的,因此每个项目需要单独配置开发环境、配置 CI 流程、配置部署发布流程等等,甚至每个项目都有自己单独的一套脚手架工具。
其实,很容易发现这些项目里的很多基建的逻辑都是重复的,如果是 10 个项目,就需要维护 10 份基建的流程,逻辑重复不说,各个项目间存在构建、部署和发布的规范不能统一的情况,这样维护起来就更加麻烦了。
它难以管理
在 MultiRepo 的开发方式下,依赖包的版本管理有时候是一个特别玄学的问题。比如说刚开始一个工具包版本是 v0.0.1,有诸多项目都依赖于这个工具包,但在某个时刻,这个工具包发了一个 break change
版本,和原来版本的 API 完全不兼容。而事实上有些项目并没有升级这个依赖,导致一些莫名的报错。
它难以复用
在维护代码的时候我们可能会有一部分的逻辑是重合的。但是如果我们没有及时的进行代码重构,我们就需要使用 CV 大法
让我们的开发越来越省事,但是,由于这些代码是分散在不同的项目中,那么我们就需要在不同的项目中进行重复的修改,这样就会导致我们的代码越来越难以维护。
为什么我钟情于 Monorepo
让仓库更简单
在使用多个代码仓库时,通常情况下你要么为每个代码仓库定义一个项目,要么将相关的项目放在同一个代码仓库中。但这会迫使你为团队或公司定义“项目”的概念,并且有时候不得不因为纯粹的开销原因而拆分和合并代码仓库。例如,由于版本控制系统过大或历史记录太多而必须拆分一个项目是不理想的。
使用「单一存储库(monorepo)」,可以按照最符合逻辑的方式组织和分组各种项目,而不只是因为版本控制系统强制你以特定方式组织事物。同时,使用单一存储库还减少了管理依赖项所需的开销。
简化后的组织结构带来了额外好处:更容易浏览各种项目。我用过的 monorepo 让你基本上可以像访问网络文件系统那样导航,在其中重复使用用于在项目内部导航的习语。相比之下,多个代码仓库通常需要两个独立级别进行导航——用于在项目内部导航文件系统习语和用于在各个项目之间进行导航元级别。
这种副作用也意味着,在 monorepo 中往往很容易设置开发环境以运行构建和测试。如果你希望能够使用 cd
等效果在项目之间导航,那么你也希望能够执行 cd; make
。由于这种方式看起来很奇怪,因此通常会生效,并且必要的工具化工作得到了完成。虽然在多个代码仓库中实现这种便利是技术上可行的,但不如单一存储库自然,这意味着必要的工作不会经常进行。
简化依赖关系
这可能是不言而喻的,但对于多个仓库,你需要有一些指定和版本化它们之间依赖关系的方式。听起来应该很简单,但实际上大多数解决方案都很繁琐,并涉及大量开销。
使用单体仓库,可以轻松地为所有项目设置一个通用版本号。由于原子跨项目提交是可能的(尽管在大型公司出于实际原因会分成许多部分),因此存储库始终可以处于一致状态 - 在提交 #X
时,所有项目构建应该正常工作。虽然构建系统中仍需要指定依赖项,但无论是 make Makefiles
还是 bazel BUILD
文件等内容都可以像其他所有内容一样检入版本控制中。并且由于只有一个版本号,所以 Makefiles
或 BUILD
文件或你选择的任何其他文件都不需要指定版本号。
工具与制造
导航和依赖关系的简化使得编写工具变得更加容易。现在,工具只需要能够读取文件(包括指定存储库内单元之间依赖关系的某种文件格式),而不是必须理解存储库之间的关系以及存储库中文件的性质。
这听起来可能很琐碎,但是Christopher Van Arsdale提供了一个例子,说明构建过程可以变得多么简单:
Google内部的构建系统使用大型模块化代码块非常容易地构建软件。你想要一个爬虫?在这里添加几行代码即可。你需要 RSS
解析器?再添加几行代码就好了。大规模分布式、容错数据存储?当然,再添加几行代码就可以了。这些都是许多项目共享并且易于集成的基础组件和服务…… 这种类似乐高积木一样的开发流程在开源世界中并没有那么干净利落…… 由于这种情况(更多推测),开源领域存在着复杂性障碍,在过去几年中没有显著改变。这造成了谷歌等公司与开源项目之间存在差距。
Arsdale所指称的系统非常方便,在其公开发布前,Facebook 和 Twitter 的前谷歌工程师编写了自己的 bazel 版本以获得相同的好处。
理论上可以创建一个构建系统,使任何依赖关系都变得简单,而不需要使用 monorepo
,但这需要更多努力。我从未见过能够无缝处理此问题的系统。 Maven
和 sbt
非常好用,但通常会花费很多时间来跟踪和修复版本依赖性问题。像 rbenv
和 virtualenv
这样的系统试图规避该问题,但它们会导致开发环境大量增加。在使用 monorepo
时,HEAD
始终指向一致且有效的版本,完全消除了跟踪多个存储库版本的问题。
构建系统并不是唯一受益于运行在 monorepo
上的东西。例如静态分析可以在项目边界之间运行而无需额外工作。许多其他事情(如跨项目集成测试和代码搜索)也大为简化了。
跨项目更改
有很多仓库时,跨仓库的更改是痛苦的。通常需要在每个仓库之间进行繁琐的手动协调或使用 hack-y
脚本。即使脚本可以工作,也存在正确更新跨仓库版本依赖关系的开销。重构一个在数十个活动内部项目中使用的 API
可能需要一整天时间。重构一个在数千个活动内部项目中使用的 API
是没有希望的。
使用单一代码库,你只需在一个提交中重构 API
及其所有调用者。这并不总是容易的,但比使用许多小型代码库要容易得多。我曾经看到过跨数百个项目具有数千个用途的 API
被重构,并且在单一代码库设置下,它非常容易,以至于没有人会再考虑它。
现在大多数人认为使用像 CVS
、 RCS
或 ClearCase
这样的版本控制系统是荒谬的,因为它们无法跨多个文件进行单个原子提交,迫使人们要么手动查看时间戳和提交消息,要么保留元信息以确定某些特定的跨文件更改是否“真正”是原子性的。 SVN
、hg
、 git
等解决了原子交叉文件更改问题; monorepos
则解决了项目间相同问题。
这不仅对大规模 API
重构有用。曾参与过 Twitter
从多个代码库迁移到单一代码库的David Turner举了一个小型交叉更改的例子,以及必须为其发布版本所需的开销:
我需要更新[项目A],但要做到这一点,我需要我的同事修复其中一个依赖项[项目B]。而我的同事则需要修复[项目C]。如果我必须等待C先发布版本,然后是B,在我能够修复和部署A之前,我可能还在等待中。但由于所有内容都在一个代码库中,我的同事可以进行更改并提交,然后我就可以立即进行更改。
如果每个东西都通过
git
版本链接起来那倒也行, 但是我的同事仍然必须做两次提交. 而且总会有诱惑去选择一个版本并“稳定”(意思是停滞不前)。 如果你只有一个项目那没问题, 但当你拥有相互依赖的众多项目时,则情况就不太好了。向下强制更新依赖方实际上是单一代码库的另一个好处。
不仅跨项目更改变得更容易,跟踪它们也更容易。要在多个存储库上执行类似于 git bisect
的操作,你必须严格使用另一个工具来跟踪元信息,而大多数项目根本不这样做。即使他们这样做了,现在你有两个非常不同的工具,在只需要一个工具的情况下显得有些讽刺。
具有讽刺意味的是,在公司规模越大时,该优势会减少。在 Twitter
这样的大公司中,David Turner 能够从能够发布跨项目更改中获得很多价值。但是对于像 Google
这样规模庞大的公司来说,大型提交可能足够大以至于出于各种原因将其拆分为许多较小的提交是有意义的,并且需要可以有效地将概念性原子更改拆分为许多非原子提交的工具。
Mercurial 和 git 都很棒
我得到的最常见的回应是,从 CVS
或 SVN
切换到 git
或 hg
会带来巨大的生产力提升。这是真的。但很多原因在于 git
和 hg
在多个方面(例如更好的合并)都比较优秀,并不仅仅是因为拥有小型存储库就更好。
事实上,Twitter
一直在修补 git
,Facebook
一直在修补 Mercurial
以支持巨大的单体存储库。
结语
当然,使用单一代码库也有缺点。我不会讨论它们,因为这些缺点已经被广泛讨论过了。单一代码库并不绝对优于多个代码库。它们也不是绝对劣于多个代码库。我的观点并不是你应该一定要转向使用单一代码库;而仅仅是想表达:使用单一代码库并非完全没有道理,像 Google
、 Facebook
、 Twitter
、Digital Ocean
和 Etsy
这样的公司可能有很好的理由选择单一代码库而非成百上千甚至数万个小型的分散式仓储系统。
当然希望这能够清楚地表达我的想法,也许希望能给你一些启发。干杯!