单体代码仓库:Uber 的 Android 代码仓库演化史

  • 薛命灯

2017 年 5 月 21 日

话题:语言 & 开发架构运维

Uber 技术日开幕式上,软件工程师 Aimee Lucido 呈现了一个有关Uber Android 代码库历史的演讲。在这篇文章里,她继续展开说明 Uber 为什么要构建一个单体仓库来支持 Uber 的 Android 开发。

今天,你们要开始构建一个全新的 Android 应用,对于你们来说,开头部分总是最困难的。那么要如何开始呢?

如果你们跟我一样,那你们一定也是先在Android Studio里创建一个新的项目,然后创建一个主 Activity,配置Gradle,或者再创建一个 git 仓库,这样其他人就可以与你一起合作开发这个应用。那么恭喜!你的代码结构就组成了第一个版本的 Uber 搭车应用。

我们使用一个大纸箱表示 Uber 的第一个 Android 代码基库:一个装着代码的大箱子。

我们在 2010 年启动了我们的第一个 Android 打车应用,那时我们还是一个小公司。Uber 的工程团队只有寥寥数人。我们有一个合同工负责 Android 平台的开发,而且(如果你能够想象得到)我们甚至还没有 Android 的司机应用。我们的光杆 Android 工程师基于一个单独的代码库开发了打车应用的第一个版本:一个装着代码的大箱子。

在早期,使用单独的代码仓库来创建一个新的应用有几个好处:

  • 开箱即用的 Android:Eclipse(开发第一个版本打车应用所使用的 IDE)提供的代码结构就是一个单独的代码库。在 2017 年,构建一个单独的代码仓库比在 2010 年要容易得多,因为我们现在有 Android Studio,可以使用更多的 Android 类库。不过,不管你使用的是哪一种 IDE,这些 IDE 都会为你提供一个默认的仓库结构和一些基本工具,比如构建脚本和 git 集成。
  • 加快小型团队的开发:因为所有的依赖都集中在一个地方,对于一个小型的开发团队来说,使用单独的代码仓库可以提升他们的效率,共享代码和重构也会变得更简单。

几年之后,Uber 的 Android 开发有了小规模的扩张。到了 2013 年,我们聘请了第一个全职 Android 工程师,在这一时期,我们的工程团队已经比之前翻了一翻。也就是从那个时候,我们才开始开发第一个司机应用。

开发司机应用让我们有机会对我们的代码基库结构进行改进。打车应用仍然放在单独的代码基库里,不过我们将一些可重用的组件抽取出来,因为我们已经拥有了必要的资源和工具来做这件事情。核心的代码仍然放在它自己的仓库里,不过我们构建了一个类库,包含了两个应用都要用到的公共组件。

到了 2014 年,我们的增长规模要求我们去寻找其他不同的解决方案。Uber 的工程师已经超过一百个。Android 工程团队的工程师也从一个变成了八个。随着工程师数量的增长,我们的代码基库规模也在增长。

我们看着前行的方向,我们意识到,如果我们不做出一些改变,我们将会碰到如下几个问题:

  • 长时间的构建:我们最初的 Eclipse 项目使用Ant作为默认的构建工具,而使用 Ant 构建大型的代码库会越来越慢。
  • 功能耦合:共享代码很容易导致过度共享。随着 Android 应用功能不断的增加,我们担心功能会无机地耦合在一起。
  • 破坏 Master 分支:将变更 rebase 到最新的 Master 分支上之后发生构建错误,然后需要花很多时间进行调试,这种事情经常发生。而你会发现,构建错误与你的代码并没有关系,而是之前有人在没有运行测试的情况下将代码 rebase 到 Master 分支上。如果你跟我一样,那么也已经处在一个两难的境地。多个工程师在同一个代码基库上贡献代码,却不使用持续集成工具(见后面的Submit Queue),那么就存在产生冲突的风险。这会对 Master 分支造成破坏,而且会浪费时间。

所以,在 2013 年到 2014 年期间,为了解决这些问题,我们做出了一系列改变,我们迁移到了多仓库的代码基库。2013 年,我们使用 IntelliJ 和 Maven 代替了 Eclipse 和 Ant,这样我们就可以从服务器上拉取依赖包,并将我们的包仓库拆分成 20 多个更小的独立仓库。(例如,网络相关的包被移到自己的仓库里,然后应用程序在编译时通过 Maven 将它们拉取到本地。)同时,我们改用 Gradle 构建脚本,迈出了向多仓库侵袭的第一步。

我们使用几个纸箱表示多库代码仓库,Uber 在 2013 年将代码基库转成这种结构,以便满足不断增长的用户需求。

Uber 的多库代码基库由一些小代码基库组成,每个小代码基库代表了一个单独的离散想法,包含了一些类库,打车应用和司机应用在编译时拉取这些类库。每个代码库就像是一小箱子的代码,包含了自己的 IDE 项目文件、git 仓库和构建脚本。多库代码仓库是一种稳固的具有前瞻性的架构,解决了构建时间长、功能特性耦合和 Master 分支遭到破坏的问题。

现在的问题变成:为什么我们不是一开始就使用多库代码仓库?一句话:成本。将功能特性拆分到独立的仓库需要大量的时间,而且需要很多专家来完成。它需要很多领域的知识,比如 Maven、Gradle、VPN 和类库管理。这种知识的投入也只有在公司的规模增长到一定程度的时候才是值得的。

在将近三年的时间里,我们伴随着多库代码仓库结构一起成长。但到了 2016 年,我们的多库代码仓库开始出现瓶颈,我们的开发人员开始面临新的问题:

  • 架构孤岛:因为功能特性的强去耦,开始出现架构孤岛。我们早期构建了一个统一的 lint 系统,但它并不能防止不同的团队使用各种各样的ActivityFragment,以及MVC架构和我们自己的架构。从某种程度上说,架构孤岛是被允许的,甚至还有好处:工程师可以选择适合他们的架构。但随着规模的增长,我们的团队需要使用越来越多的类库。这意味着开发人员需要经常性地学习新的架构,而且学习曲线非常陡峭。因此,一个类库的架构如果没有被正确实现,将导致与消费者应用或其他功能特性的集成变得很困难,或者需要重度的代码重构。
  • 依赖地狱(Dependency Hell):这个方案确实能够减小依赖地狱问题所带来的影响,不过这也取决于你的变更涉及到多少类库,即使是在代码基库上运行这个工具就需要很长的时间。另外,修复问题可能需要好几天的时间:识别问题的依赖情况,修复代码,然后在受影响的代码库上拉出新的版本。
  • 长时间的构建:我们的代码基库规模已经达到 Gradle 能够进行快速构建的上限。一个新应用需要超过 15 分钟的时间来构建,对一些 XML 小文件的调整可能会占用数个小时的开发时间。

那么我们是怎么解决这些问题的?

我们的解决方案:使用单体仓库,一个包含了多个独立项目的代码仓库。一个代码基库包含一个单体仓库,就像我们最初的打车应用那样。不过与打车应用不同的是,这个箱子里包含了多个逻辑组件,它们是相互独立的。现在,我们可以在工具和架构上投入时间和资源,来修正大型单体仓库的缺点:

  • IDE 支持: Android Studio 被作为默认的轻量级的 Android 开发者平台,不过并不是唯一的选择。我们发现,在一个大型的单体仓库里,IntelliJ 更适合我们目前的规模。
  • 长时间的构建: Uber 目前已经从 Gradle 转向Buck,Buck 是一个模块化的构建系统。我们的代码已经被拆分成离散的组件,所以很容易与 Buck 集成起来。我们自己开发的 Gradle 插件OkBuck将 15 分钟多的构建时间降到了 5 分钟以下,而对于增量构建,只需要不到一分钟的时间。
  • 破坏 Master 分支:我们最近引入了一个叫作 Submit Queue 的系统,用于将变更 rebase 到 Master 分支上,并且在合并之前运行一系列测试。这样可以防止工程师在提交代码时破坏构建过程,保持 Master 分支的整洁。

这看起来花费了不少功夫,事实确实如此。目前还没有开箱即用的单体仓库解决方案,因为很少有公司可以达到需要使用单体仓库的规模。我们现在有了时间、专家和资源来构建工具,防止那些在 2013 年出现过的痛点再次对我们的生产力造成破坏。

Uber 花了数年的时间才达到了目前的开发状态。当我们还是一个由几个工程师组成的团队时,我们没有时间和资源来创建 Submit Queue 或者搭建 Buck。不过,我们早期的洞见促进了架构决策,让我们可以快速地伸缩。现在,我们有了更进一步的发展,我们可以在开发上投入更多,确保未来的服务增长是无缝和高效的。

提升开发者的生产力是块难啃的骨头,但却非常重要。随着每一次小步跑的进步,我们不仅让 Uber 变得更好,也让整个 Android 社区变得更好。

查看英文原文: THE JOURNEY TO ANDROID MONOREPO: THE HISTORY OF UBER ENGINEERING’S ANDROID CODEBASE ORGANIZATION

语言 & 开发架构运维