1.1 代码仓库发展历程
从零开始开发软件时,首先需要确定的是代码仓库的架构。对于JavaScript/TypeScript技术栈的项目,根据应用中项目代码组织的关系,一般来说有三种常用架构:Single-repo、Multirepo、Monorepo。
1.1.1 常用的代码组织架构
Single-repo、Multirepo、Monorepo是三种常见的代码库组织方式,它们分别有不同的优缺点和适用场景。下面将分别介绍这三种代码库组织方式的优劣。
1.Single-repo
Single-repo结构如图1-1所示,即一个应用的所有项目代码全部在一个代码仓库中管理,模块之间仅仅通过简单的划分或不划分来进行组织。
•图1-1 Single-repo结构
整个应用就像是一个独立的npm包,只在根目录有一个package.json文件,用于管理所有项目的版本和依赖关系。这种方法通常适用于Monolith(巨石)应用,即整个应用作为一个整体进行开发和管理。例如Vue 2项目就是一个由Yarn管理的Single-repo项目,其目录结构如下所示。
从目录结构上可以看出,Vue 2核心应用所有相关模块都在src目录下,各模块做了一定的隔离,但是全局只有一个package.json文件。
2.Multirepo
Multirepo结构如图1-2所示,即多代码仓库,是一种把应用内的各模块彻底隔离和封装的方法,每个模块都存放在一个独立的代码仓库中进行管理。
•图1-2 Multirepo结构
通常,每一个代码仓库有独立的版本号管理、依赖管理、CI/CD(Continuous Integration/Continuous Delivery,持续集成/持续部署)流程。例如Node.js后端框架Fastify就是一个由npm管理的Multirepo项目,其4.13.0版本的package.json文件如下所示。
从上可以看到,每一个Fastify的子模块都有独立的版本号。实际上,每一个子模块都有一个独立的GitHub仓库链接地址,维护相对独立,也有独立的package.json文件管理。每一个子模块的目录结构如下。
3.Monorepo
Monorepo结构如图1-3所示。Monorepo的核心并没有特别神秘的地方,简单来说就是在一个代码仓库中组织多个相对隔离的模块代码,可以同时有一个或者多个应用。早期,这种类型的代码仓库更多被称为共享仓库(Shared Codebase),但现在Monorepo已经成为专门描述这种类型代码仓库的词汇。
•图1-3 Monorepo结构
虽然Monorepo和Single-repo都是把一个应用的所有代码放在一个仓库,但是两者有一个本质区别,即Monorepo项目中的各个子项目与Multirepo一样,要进行彻底的隔离和封装。对于一个特定的子项目来说,这个项目是一个完备的npm包,有独立且完整的package.json文件,可以像Multirepo一样进行独立的版本号管理、依赖管理和CI/CD流程。但是因为所有代码在一个仓库内,物理上在一个文件夹之下,可以很容易进行优化。例如Vue 3就是一个由pnpm管理的Monorepo项目,其目录结构如下所示。
packages目录下的每一个目录都是一个完备的npm包,并且统一进行版本管理。通过比对Vue 3和Vue 2的目录结构,可以看出Vue 3在复杂度和解耦度方面比Vue 2有了很大提升。
当然一个项目的代码存储架构不是一成不变的,可以根据成长阶段,选用合适的代码存储架构,甚至可以多种架构组合使用。代码架构对比见表1-1。
表1-1 代码架构对比表
近年来,各个编程语言都可以非常好地支持Monorepo架构。Go语言在1.18版本增加了多模块工作空间;Java/Kotlin可以使用Maven multimodule或者Gradle的composite build;C#可以使用Nuget,Nuget在6.2版本加强了中央包管理功能;Rust可以使用Cargo workspace。越来越多的开源项目也开始采用Monorepo架构,相关的支持工具也日渐成熟。
1.1.2 Single-repo与Monolith
一般来说,Single-repo是单个应用最朴素的开发方式,是初学者经常使用的仓库架构模式,构建步骤如下。
1)使用npm init命令初始化项目。
2)到GitHub或npmjs.com上寻找开发需要的依赖包。
3)通过npm install命令安装依赖。
4)在src目录里根据项目需要创建service、controller、view、utils文件夹,一步步迭代开发的项目。
随着软件的规模变大,软件的复杂度也开始增加。如果一个项目只需要几千行就可以完成,那么Monorepo这样的技术可能永远不会流行。然而事实是,只要稍微复杂的逻辑,仅几个月,代码就可能膨胀到几万行的量级。以一个3万行的代码为例,按一个文件有300行代码来计算,这个拥有3万行代码的项目共包含100个文件且一般会嵌套在3~5级目录下。文件数达百,代码行数达万时,一个初级项目开始慢慢演进为一个中级项目,此时一般会开始进行拆分。也许开发者会在网上学习到Monolith架构的短板,进行有意识的拆分和分层。这个阶段也许会持续几个月,代码行数和文件数并不会变化,开发者对自己开发的软件结构的调整会持续进行一些变化。
但因人的记忆是有限的,在重构的过程中,特别容易出现开发者做了新的工作规划,最终却只是修改了命名,逻辑和之前区别不大的问题。随着重构的进行,文件夹的命名和分层结构越来越清晰,开发者自认为的“模块”之间的关系也越来越清晰,但是此时项目仍然是在一个Single-repo架构里,在进行更细致的代码评审和讨论时,很可能出现除了命名和文件夹清晰,代码之间的关系仍混乱的问题。
当然,这样的情况并不一定会出现。开发者能力之间的差异是很大的。确实有记忆超群,脑力惊人的开发者可以在一个Single-repo中开发一个几十万行,甚至上千万行规模的项目。但是更多的情况是,因为无法做出更优秀的架构,在不断的遗忘和重复中,耗费了很多宝贵的时间。Single-repo一般是一个Monolith应用,虽然开发者会自认为通过精心的解耦,已经解决了Monolith的问题,但是因为代码量大了以后,人的记忆是不可靠的,人为隔离通常都是失败的。根据2021年的State of JS调查,管理JavaScript依赖是开发者最为困扰的问题,其次是代码架构问题,如图1-4所示。
•图1-4 令JavaScript开发者痛苦的列表
package.json文件可以是一个非常好的项目管理工具。开发JavaScript项目都会接触各种各样的npm包,有的非常大(可能是个数据库),有的很小(可能只是一个类型转换工具),不管是哪一种,package.json文件都记录了非常完善的项目元信息。如果一个Monorepo项目中每个子项目使用单独的package.json文件进行元数据管理,同时禁止不同子项目直接跨过package.json文件引入代码,就可以很好地防止只是形式上的模块化。
近年来,GitHub和npmjs.com上流行库的发展趋势证明了,在非常细分的领域可能出现高速演进。这种细分需要项目开发者清楚地思考业务模块与技术模块之间的关系。而使用Monorepo这种以package.json文件物理隔离的方式可以更容易地将这种思考落地。
总之,相较于Monorepo/Multirepo使用独立的package.json文件管理子项目的方式,使用Single-repo管理中高级难度的项目,可能会面临更大的挑战。
1.1.3 Monorepo≠Monolith
很多对Monorepo不熟悉的人会觉得Monorepo和Monolith是一个意思,但是恰恰相反,一个采用Monorepo架构的项目一般都不会是一个Monolith项目。Monolith应用通常是指没有模块化的应用,即一个应用内包含了所有需要的功能,且不可分割,Monolith应用结构如图1-5所示。与Monolith相对的是微服务架构。
•图1-5 Monolith应用结构
将所有代码放在一个仓库中并不一定会使项目变成Monolith,但Monolith项目一定会同时部署。实际上,代码仓库和开发架构是两件事。开发代码的地点和部署代码的时间可以是独立的。谷歌的Monorepo项目支撑了数千个应用,但显然这些应用的部署是独立的。在执行CI过程时,要打包和存储制品,在CD阶段,将最终制品部署于生产环境。也就是Monorepo更关注于CI过程之前的事情,而部署应用时更多的是关注制品仓库。
如果把很多项目的代码单纯地放到一个仓库里面,只是实现了代码的集中管理,此时,项目其实是一个Single-repo或Monolith项目,模块和模块之间的关系并没有定义清楚。而流行的Monorepo解决方案大都基于npm包进行隔离,实际上把一个项目各个模块通过npm包进行了一定程度的解耦。例如当在开发A应用时抽象出一个模块A的npm包,在之后开发和A应用类似的B应用时,可以直接使用模块A进行开发,如图1-6所示。就像通过GitHub下载其他开源作者编写的项目一样。
•图1-6 应用A和应用B共用模块A
正如上面提到的Monolith的缺点,很多时候是由于在开发时缺乏一个想象。在编写代码时,如果没有“开发一个供他人使用的库”的想象,很容易写出“临时能运行”的代码。一般来说,在Monolith项目中,由于缺乏这样的想象,很容易写出高度耦合的代码,需要查看这段代码的上下游逻辑才能进行调试。但是在Monorepo项目中,由于使用package.json文件对每个子项目包进行物理隔离,基于这种想象写出的代码如果发布到GitHub上,就是一个可供其他开发者使用的开源项目,在这种结构下,一个项目自然而然就不会变成一个Monolith项目。
1.1.4 Monorepo的优点
通常,我们将Monorepo与Multirepo进行对比,如图1-7所示。在Multirepo中,每个仓库都是一个具备完整功能的应用程序或库,例如一个GitHub用户下的所有仓库合起来就是一个典型的Multirepo。相比之下,Monorepo的隔离性要差一些,但是其优点和缺点都源于这种隔离性的差异。Monorepo的优点如下。
•图1-7 Monorepo与Multirepo
1.有助于更好、更高效的工作流程
使用Monorepo架构管理的项目对整个团队来说都是可见的,这种可见性使开发者更容易地进行跨系统、应用程序、库的程序搜索、共享和重用代码。通常随着代码重用和可见性的提高,代码一致性也会提高。
2.更容易管理应用内部之间的依赖关系
Monorepo由多个应用程序和库组成,这种组织方式可以更好地检查包之间的关系,更容易地进行全局重构。例如将库A中的函数移动到库B中,或者将库A拆分成若干小库。这些操作只需要在IDE中移动目录,修改错误并调试即可完成。虽然这听起来很美好,但是缺点也很明显,即如何确保本次重构是在预期范围内进行的?这需要实施操作的开发者对重构项目涉及的所有方面有足够的了解,否则可能产生灾难性的后果。
3.提供统一的Git提交视图
Monorepo项目中,由于所有提交在同一个仓库中,因此更容易分析当前应用整体Git提交情况。但是要加强对git commit填写要求的管理,单次提交代码要注明影响包的范围,不同包的影响应该分开提交。
4.便于统一CI/CD、打包等自动化构建和测试流水线
Monorepo的优势在于将所有代码放在一起,这样有利于进行测试自动化。随着项目复杂性增加,涉及的语言和框架也会增加,相应的自动化脚本也会变得更多。这并不是一个缺点,因为无论使用哪种代码仓库组织形式,都有相同的构建流水线需求。但是Monorepo更希望统一解决这些问题,复用构建测试流水线相关工作。Monorepo给予开发者全局层面的视角,可以审视所有代码,并获得统一的规范。在构建良好的Monorepo项目中,基础设施是非常健壮的,可以很好地支持自有应用,并提高开发者对于维护构建基础设施的动力。由于代码、工具和配置都在一起,可以更容易地统一管理所有配置信息,比散落在多个仓库中的配置文件更容易维护。
5.可以极大简化依赖的管理
当项目变复杂时,非常清晰的单一模块依赖已经变得不那么重要,更重要的是在全局上统一管理依赖关系。然而,这会带来面向依赖编写工具的需要。这种方式带来的问题是,几乎每个Monorepo都有自己的依赖关系规则和工具链体系,这需要相当多的时间来适应和学习,对于新人来说学习曲线会变高。
6.降低多技术融合的成本
Monorepo吸引人的一个重要原因是支持多种技术融合。技术界有句俗语:“没有银弹”。在项目较小时,使用单一技术栈开发是正常的,随着业务变得复杂,引入其他的技术栈可能要简单很多。Monorepo支持多种流行技术,并且可以很容易地进行融合,例如一个pnpm的项目中是可以集成Rust workspace或者Go workspace项目。使用Rust编写CLI,再通过pnpm发布,其他项目使用起来会变得非常简单。
1.1.5 Monorepo的缺点
有优点就会有缺点,Monorepo的缺点如下。
1.相关开发工具不成熟
Monorepo是近年来流行起来的一种开发方式,相关工具链和IDE仍在适应和调整阶段。尽管VS Code、JetBrains IDE等主流开发工具对于Monorepo的支持已经有所提升,但仍存在一些问题,如JetBrains在大型Monorepo项目中重建索引需要耗费大量时间。同样,Git在处理大型Monorepo项目时的性能也不尽如人意。配置生产环境所需的成本也相当高。但是,Monorepo的发展势头不可阻挡,越来越多的项目正在解决相关问题,促进了整体公共工具的发展趋势。
2.CI/CD流水线较为复杂
维护Monorepo项目是一项长期任务,这个过程可能会导致构建速度变慢。由于有太多相互依赖的包和模块,构建需要太多的步骤,可能导致迭代停滞不前。任何改动都需要重新构建整个Monorepo项目,而不是只构建应用程序的一部分。需要深入了解代码库之间的关系,并编写自动化流程。更重要的是,还需要长期维护这些流程。最终,开发者需要额外花费时间和精力创建和维护构建系统,随着代码库的复杂度增加,构建系统也会变得更加复杂。由于时间有限,流水线的技术债务很容易积累。
3.测试复杂
测试是限制发布速度的一个重要因素,尤其在Monorepo项目中。测试和通常的构建/编译任务必须按照正确的顺序完成,才能确保所有依赖项从正确的来源构建并在正确的时间可用。与构建一样,测试整个代码库也需要时间。最理想的方案是小规模更改进行测试并重新部署。然而,随着代码复杂性的增加,测试整个库可能更加现实。要根本地解决这些问题,需要进行高维度的编排和依赖分析,但这超出了本书的范围。当项目发展到一定阶段时,就必须考虑这个问题。
4.固有复杂性
管理Monorepo项目非常复杂,需要解决很多问题,而且容易出错。因为将适用于单个包或应用程序的内容映射到多个包和应用程序是一项非常复杂的任务,学习曲线非常陡峭,即使有很好的文档也不能解决这个问题。定制协调和自动化脚本也需要相当长的时间去学习使用。
5.隔离性差
维护文件的所有权具有更大的挑战性,因为如Git或Mercurial等系统缺少内置目录权限。通常需要在一个目录下面维护一个Markdown文档来指明负责人。而Multirepo的隔离性带来的一个好处是可以责任到人,不同的开发者负责不同的repo。但是带来的坏处,自然也是责任到人,库和责任的隔离带来交流的不便,潜在地增加了重复造轮子的可能性。通常,一个复杂的Monorepo项目也会为子项目设置几个负责人,但是因为代码仍然在一起,其变化和沟通成本要低于Multirepo。
1.1.6 Monorepo在现实中的应用
许多公司,如谷歌、微软和Airbnb等,已经长期实践了Monorepo架构。许多开源项目也已经成功地使用了Monorepo,如Babel、谷歌的Angular、Facebook的React、Vue等前端框架。如今,越来越多的小型团队也开始使用Monorepo。
1.谷歌
在谷歌,几乎所有的代码都存在于一个大型的Monorepo中,几乎所有的工程师都能在其中查看几乎所有的代码。超过2万名工程师使用该Monorepo,包含超过20亿行代码。整个Monorepo中使用共享的构建系统、通用测试基础设施、代码浏览工具、代码审查工具,以及自定义源代码控制系统。尽管工程师可以查看和编辑几乎整个代码库,但所有的代码只有在得到代码所有者的批准后才能提交。代码所有权是基于路径的,目录所有者隐式地拥有所有子目录的所有权。
2.微软
微软的Windows代码库存储在一个Monorepo中,拥有350多万个文件,大小超过270GB,这导致了使用Git来管理这样的项目变得困难。在这样的代码库上运行git checkout需要3h,git status需要10min,git clone需要12h。为了解决这个问题,微软开发了GVFS(Git Virtual File System)。通过这个系统,开发者在使用时会觉得所有文件都在本地,但实际上只有在文件第一次打开时才会进行下载。GVFS还积极管理Git在checkout和状态操作中需要考虑多少repo,因为任何未变化的文件都可以安全地被忽略。GVFS是在文件系统级别进行这些操作的,因此IDE和构建工具并不需要更改。使用GVFS管理庞大的代码库后,除了第一次构建比较慢之外,之后的git clone只需要几min,checkout只需要几十秒,status只需要几秒。
3.Airbnb
Airbnb最初的版本被称为“the monorail”,是一个完整的Ruby on Rails应用程序。当Airbnb的业务开始指数级增长时,代码库也随之增长。为了维护代码库的可维护性,Airbnb实施了一项名为“民主发布”的新颖发布政策。但随着业务的继续扩大,这项政策受到了挑战。最终,Airbnb的工程师们决定将应用程序拆分为微服务,并创建了两个Monorepo:一个用于前端,一个用于后端。这样做的优点是可以通过一次提交,在两个微服务之间进行更改,并且可以围绕一个存储库构建所有的工具。Airbnb的基础设施工程师Jens Vanderhaeghe表示:“我们不想处理所有这些微服务之间的版本依赖关系。使用Monorepo,您可以通过一次提交,在两个微服务之间进行更改。可以围绕一个存储库构建所有的工具。最大的卖点是,您可以同时对多个微服务进行更改。我们运行一个脚本,检测Monorepo中的哪些应用程序受到影响,然后部署这些应用程序。我们的主要好处是源代码控制。”
4.Uber
Uber使用Go语言编写大部分后端服务,并于2018年引入了Go Monorepo,发现构建效率立即提高。随着Go Monorepo的成熟,Uber将越来越多的项目转移到该仓库中,使用量迅速扩大。截止到2020年,Go Monorepo上有70000个文件,月提交数量达10000,活跃开发者900人。Uber的Go Monorepo可能是在Bazel上运行的最大的Go存储库之一。
Uber的前端项目经历了从Monorepo到Multirepo再到Monorepo的过程。项目最初采用Monorepo存储架构,但随着业务的发展,Monorepo出现了问题,如IDE阻塞、Git速度变慢、master崩溃等。当业务达到中等规模时,团队决定采用Multirepo架构,这解决了许多问题。然而,随着Uber业务的进一步发展,Multirepo架构开始显示出它的弱点,这不仅是关于技术问题,而且是更多关于开发者如何合作。管理数千个存储库的开销消耗了大量宝贵的时间,每个小组都有自己的编码风格、框架和测试实践,管理依赖关系也变得更加困难,最终很难将所有内容整合到一个产品中。最终,Uber的工程师们重新集结起来,决定再给Monorepo一次机会。