与Bazel一起打造Uber的Go Monorepo

2020-05-14 23:14:41

在汽车或航空航天等传统工业中,工程师首先设计产品,制造设施根据设计生产汽车或飞机。在软件开发中,构建系统类似于将源代码转化为服务、工具和应用程序的制造设施。除了促进软件编译和链接之外,构建系统通常还需要生成代码、下载外部包或构建不同的安装包。一些构建系统还可以管理工具,如编译器、链接器和代码生成器,从而减少构建构件对其本地环境的依赖。当优步开始利用Go开发我们的后端服务时,我们将流行的开源构建系统Make与Go的默认构建系统Go Build结合使用。

在将我们在优步的Android和iOS项目转移到更高效的monorepo模式后,Go开发者体验团队对Go项目采取了类似的举措,发现Make and Go Build不再满足我们的需求。我们最终决定使用Bazel,它为Go语言提供了很好的支持,并因其活跃的开源社区的持续贡献而得到加强。

由于我们的大部分技术都是在Go中开发的,Uber的Go Monorepo很可能是在Bazel上运行的最大的Go存储库之一。我们注意到了我们可以改进Bazel生态系统并为其做出贡献的领域,增强了Bazel规则的生成,并将Bazel与Uber的SubmitQueue系统集成在一起,以确保Monorepo的主分支总是可以成功构建和测试。

我们希望我们使用Bazel构建大型存储库的经验以及我们对开源Bazel生态系统的贡献能帮助其他工程团队使用Bazel构建他们的源代码存储库。

优步的大部分后端服务和库都是用Go编写的。在我们决定构建Go Monorepo之前,Uber的工程师在许多小型的孤立存储库中开发了这些Go项目(其中一些已经开源)。我们在2018年初推出了Go Monorepo,并看到早期采用者项目的构建效率立即上升。随着Go Monorepo的成熟,我们将越来越多的项目转移到它上面,并且使用范围迅速扩大,如下面的图1和图2所示:

在撰写本文时,我们的go monroepo中有超过70,000个文件。因为我们通常不提交生成的代码,所以这些GO文件主要是手动编写的。我们Go monrepo的巨大增长鼓励我们评估新的构建解决方案,如Bazel,以满足我们的发展需求。

Bazel是为大规模工作而设计的,并支持跨分布式基础设施的增量密封构建,这对于Uber的大型代码库是必要的。使用官方的Bazel Go规则集,我们能够管理Go工具链和外部库,而不依赖于本地安装的工具链和外部库。还有一个官方的Bazel项目,Gazelle,我们用它来生成Go和Protocol Buffers规则。有了Gazelle,我们可以在我们的Go Monorepo中为大多数围棋包生成Bazel规则,而只需最少的人工投入。瞪羚还可以将围棋模块的版本导入到Bazel规则中,这样我们就可以方便高效地构建外部库。

有了Bazel的远程缓存,我们的构建服务器也可以共享它们的构建构件。只有当包或其依赖项中的某些内容发生更改时,才会生成和测试包。

开箱即用的软件解决方案很少适用于像Uber的go monorepo这样大而复杂的代码库。我们增加和改进了Bazel以更好地满足我们的需求,改进了规则生成器,开发了几个新的Bazel规则和功能,以及用于大规模构建大型代码库的工具。在此过程中,我们修复了Go和Bazel开源项目中的大量错误。

Bazel要求使用构建规则显式定义所有构建目标。每个GO包至少有两个构建目标,一个用于将其构建为库,以便其他包可以导入它,另一个用于运行该包的单元测试。在Uber大小的存储库中创建和维护大量构建规则是一项乏味且容易出错的任务。幸运的是,Go和Protocol Buffers的大多数构建配置都可以从其源代码中推断出来,这为自动生成那些Bazel规则提供了机会。这就是瞪羚发挥作用的地方。

如前所述,我们的Go Monorepo可能是迄今为止使用Bazel和Gazelle的最大围棋存储库,这导致了Bazel和Gazele的设计者在大规模使用该软件时不一定能预见到的复杂场景。我们与开放源码社区密切合作,消除了这些障碍,修复了bug,并添加了一些新功能。

在Bazel中,使用GO_REPORATION规则下载外部GO模块。Gazelle为go.mod和go.sum文件中的每个模块生成一个这样的规则,这两个文件由go工具链管理。在Go Monorepo中,我们有1000多个外部模块。

作为Go Monorepo开发的一部分,Uber向Gazelle贡献了几个功能,以改进其生成和管理Go_Repository规则的方式。例如,Gazelle只能在Bazel的工作区文件中生成所有go_pository规则,该文件还包含手动编写和维护的工作区规则和宏。这些手动规则和宏中的一些必须放在生成的go_pository规则之前,另一些放在生成的规则之后。但是,Gazelle只能将新的go_pository规则附加到工作区文件的末尾。我们向Gazelle添加了一个特性(#480,#493),以便它现在可以将GO_REPORATION规则写入单独的宏文件,并将其加载到工作区文件中。因此,所有生成的规则都保留在工作区文件之外,从而使此文件更小且更易于维护。

我们的Go Monorepo还允许工程师添加或删除外部模块。删除模块时,Go工具链会将其从go.mod和go.sum文件中删除。但是,Gazelle无法清除不必要的GO_REPORATION规则。我们向Gazelle添加了一个选项,以删除不需要的go_pository规则(#514)。在GO_REPORATION规则下载GO模块之后,它调用Gazelle来生成Bazel规则来构建该模块。我们还向go_pository规则添加了参数(#603,#649),以便在外部模块中配置Gazelle的行为。这些改进,加上我们的团队多年来贡献的许多较小的功能和漏洞修复,推动两名优步工程师在Gazelle的贡献者名单上分别位居第二和第三位,截至2020年4月。

瞪羚是为支持多种不同语言的Bazel规则而设计的。Gazelle的官方扩展可以为Go和Protocol Buffers生成Bazel规则。然而,Uber在我们的Go Monorepo中利用了许多其他类型的规则,包括Apache Thrift、ThiftRW、Apache Avro和GoMock的规则。开源社区制定了其中一些规则,而其他规则则是优步内部制定的。我们还开发了几个Gazelle扩展来涵盖这些额外的规则,并计划在不久的将来开源我们的新规则和Gazelle扩展。

为了保持GO Monorepo的主分支处于绿色状态,这意味着主分支上的所有代码都可以在任何时候成功编译和测试,在提交给主分支之前,我们要执行一系列检查。这些检查包括构建和测试在该提交中更改的所有包,以及所有依赖包。根据受提交影响的包的数量,检查可能需要几分钟到几小时不等。

如果我们按顺序提交,则每次提交都必须等到之前所有提交都登陆后才能检查,这可能会导致很长的登陆时间。使问题进一步复杂化的是,我们的Monorepo变得越大,进来的提交就越多,堆积起来并创建了更长的队列。我们知道最终提交会来得太快,我们无法按顺序检查它们。

为了跟上我们的高提交率,优步的工程师使用SubmitQueue来并行检查和登陆提交,同时防止代码冲突。为此,SubmitQueue需要知道受给定提交影响的构建目标列表。Uber的其他存储库使用Buck作为其构建系统,能够在提交前后对修订版本运行“buck target-show-target-hash”命令,找出哪些目标的散列因提交而更改,并将目标列表传递给SubmitQueue。不幸的是,即使Bazel知道它需要在内部重新构建哪些目标和操作,它也不会从其命令行界面公开操作或目标的散列键。

在与谷歌的Bazel团队在Github和线下讨论了这个问题后,我们决定在Bazel之外开发我们的解决方案。生成的工具遍历Bazel的构建图,通过组合规则定义、属性、输入文件和它所依赖的其他目标的散列来计算图中每个构建目标的散列。使用每个构建目标的散列,我们可以识别受提交影响的构建目标列表,并将该列表传递给SubmitQueue。我们打算在将来将这个工具开源。

一旦SubmitQueue知道哪些构建目标受到影响,它就需要调用Bazel来构建和测试这些目标,以确保将提交安全地放在主分支上。当提交升级核心库(例如,升级GO规则集版本)时,目标列表可以非常大。随着monorepo的增长,构建目标列表增加到无法通过Bazel的命令行界面传递的地步。在讨论了Github上的问题之后,我们最初能够通过使用特定的构建配置(而不是命令行)传递构建目标列表来解决该问题。随着monorepo持续增长,此解决方法再次失败。

最后,我们为Bazel贡献了一个特性,这样它就可以从文件中读取构建目标列表。该功能已被接受并合并到Bazel的主存储库中,并且从Bazel3.1开始可用。

虽然我们成功地采用了Bazel作为Uber的Go Monorepo的构建系统,但仍有很多工作要做。例如,当前的Go IDE并不像我们希望的那样支持Bazel。我们发现IDE无法定位在构建时生成和下载的Go包和模块,并计划与开放源码社区合作来弥补这一差距。

我们的Go Monorepo也是最早使用Bazel的Uber存储库之一。我们正计划与优步的其他团队分享我们的经验和技术,帮助他们迁移到Bazel作为他们的构建系统。我们构建的许多工具和功能都考虑到了这一愿景,它们的设计具有足够的通用性和可扩展性,可以在我们的Go Monorepo之外甚至在Uber之外重用。