Gradle 与 Bazel 对比 JVM 项目

目录

引言

简介 #

Gradle 已成为 JVM 生态系统(包括 Kotlin)中项目的首选构建工具。它是 GitHub 上开源 JVM 项目中最受欢迎的构建工具。平均每月下载量超过 1500 万次,并被 Techcrunch 评为“IT 领域最受欢迎的 20 个开源项目”之一。许多热门项目已从 Maven 迁移到 Gradle,其中 Spring Boot 是一个突出的例子。

最近,我们收到了关于 Google 的 Bazel 构建工具是否适合在 JVM 环境中使用的问询。以下是详细的对比性能分析和关键功能评估,得出了三个主要结论:

  • 尽管 Bazel 在性能和可扩展性方面享有盛誉且名副其实,但在我们测试的几乎所有场景中,Gradle 的表现都优于 Bazel。
  • 优化 Bazel 项目需要付出巨大的构建编写和维护成本。
  • Gradle 为 JVM 项目的常见用例提供了更具吸引力的功能和便利性。

总而言之,数据和分析清楚地表明,对于大多数 JVM 项目而言,Gradle 是比 Bazel 更好的选择。我们将在后续文章中提供 Android 项目的相应比较。

同时,认识到不同的工具在满足特定开发者生态系统和特定用例的需求和要求方面具有独特的优势,我们预计构建工具的专业化和碎片化将继续是这些生态系统(例如 Gradle 和 Maven)以及这些生态系统内部的常态。

有鉴于此,Gradle Enterprise 为 Gradle 提供了分析和加速功能,而不仅仅是 Gradle。这使得用户无需迁移到任何特定的构建工具即可从更快、更可靠的构建中受益。目前,这包括 Gradle 和 Maven,但将来还将包括 Bazel 和其他工具。要了解有关 Gradle Enterprise 为 Gradle、Maven 和即将推出的 Bazel 构建工具提供的加速技术的更多信息,请观看题为“加速 Maven 构建 | Maven 构建缓存技术及业务案例详解”的视频。

性能 #

构建系统在您的项目上的性能表现如何取决于多种因素。这些因素包括项目的规模和结构、您使用的工具链以及您的工作流程。在本节中,我们将描述我们进行性能比较的方法以及基于代表性场景的结果。

关于性能比较 #

Gradle 和 Bazel 在性能优化方面拥有不同的方法和关注点。很容易创建一些测试项目来证明 Gradle 在特定性能场景中比 Bazel 快得多,或者反之亦然。问题始终在于这些测试项目的结果对于真实项目,尤其是您的项目有多大适用性。

我们比较的方法是模拟常见的 Java 项目类型。这涵盖了从包含数百万行代码的大型代码库到小型库/微服务项目。在本文中,我们不会讨论在单个大型源代码仓库或跨多个小型源代码仓库组织代码的各种方法的优缺点。这两种方法在 JVM 生态系统中,甚至在一个组织内部都有重要的影响。从一种方法切换到另一种方法需要进行大量的迁移工作以及工作流程和文化上的改变。适用于 JVM 的构建系统应该很好地支持这两种方法。

性能测量场景 #

不同场景的测量基于以下项目规模和结构:

项目名称 模块数 生产类数 测试类数 代码行数
小型多项目 10 500 500 90k
中型多项目 100 10 000 10000 1.8M
大型多项目 500 50000 50000 9M
大型单体 1 50000 50000 7M

所有测试项目以及用于进行测量的运行器都是开源的。有关如何重现这些测量的详细信息,请参阅说明

对于下面的每个场景,Gradle 使用 Gradle 6.5 的最新性能优化进行基准测试,包括配置缓存文件系统监控。使用的一些优化是实验性的,并通过功能标志启用。

场景 1:完整构建 #

此场景是一个完整构建,构建输出目录中没有现有输出,也没有可用的本地或远程构建缓存。外部依赖项可在本地依赖项缓存中使用。此构建包括构建项目的所有类文件、将其打包为 JAR 以及运行所有单元测试。

Gradle 和 Bazel 的用户很少在本地运行完整构建。即使在 CI 中,由于构建缓存(稍后会详细介绍),通常也可以避免完全的完整构建。尽管如此,这种情况很有趣,因为它近似于影响代码库大部分内容的一次更改,例如大多数代码库使用的库中的 API 更改。

此外,对于 Maven 用户来说,这种情况很有趣,可以了解相对于 Maven 的性能优势是否来自缓存或其他因素,因为他们通常为了可靠性而执行干净的构建。

即使没有构建缓存和增量构建优化,Gradle 和 Bazel 在性能上都优于 Maven。Gradle 在此类别中获胜。

场景 2:远程缓存 #

使构建和测试更快的最有效方法是仅重新构建已更改的内容。此方法有多个层次。其中之一是远程构建缓存。远程构建缓存跨机器提供构建操作的输出,前提是输入未更改。

通过这种方式加速的两种主要工作流程是:

第一种是 CI 构建的速度。CI 构建运行在多个代理上,通常在开始之前会丢弃任何先前的状态。因此,它们通常从头开始构建一切,尽管它们正在构建的实际新提交仅需要重新构建代码库的少数区域。使用远程构建缓存,输入未受新提交影响的构建操作可以从您组织中任何其他 CI 机器上的另一个先前构建中检索输出。这大大减少了 CI 构建时间并节省了大量的计算资源。

当您从版本控制中拉取最新更改并在 CI 构建已经填充了缓存后在本地运行构建时,远程缓存也有助于加快本地构建速度。

Gradle 和 Bazel 都提供远程缓存支持。两种工具都有托管缓存后端(例如 Gradle Enterprise 和 Google Cloud Storage)。Gradle Enterprise 还为 Maven 提供远程构建缓存。

此场景执行完整构建,就像场景 1 中一样,但这次利用了具有每个构建操作匹配输出的远程构建缓存。这近似于一个瞬时的(即不重用先前本地状态的)CI 构建代理,它为仅影响代码库一小部分的新提交运行构建,或者在本地机器上运行第一个构建。

在此场景中,Gradle 也提供了最佳性能。

请注意,远程缓存场景最难比较,因为用户根据其网络连接速度和与远程缓存节点的距离获得的结果大相径庭。对于所有测试,远程缓存都部署在地理上接近的数据中心,并通过快速可靠的家庭互联网连接进行访问。

该比较不包括“单体”场景,因为代码全部位于一个模块中,因为远程缓存对此类项目几乎没有好处。

场景 3:增量构建 #

开发人员生产力最重要的方面之一是本地增量构建的反馈时间。这可以通过开发人员构建在更改了一两行代码后获得反馈所需的时间来衡量。重用同一台机器上先前构建运行的本地状态是此类场景中表现出色的关键。由于无需通过网络传输任何内容,甚至很多时候无需复制,因此这比使用远程缓存更快。

在下一个场景中,项目已成功构建。在更改了单行代码后运行另一个构建。这近似于典型的本地构建。此场景有两种变体 — 包含或不包含类/方法公共接口的更改。不影响 API 的更改允许构建工具应用额外的优化。

 

对于第一个 Maven 列中的数字,我们没有运行干净的构建,并且重要的是要注意,Maven 在未运行时不可靠。即使没有干净,它也仍然从头开始执行大多数目标。因此,您冒着构建损坏的风险来获得适度的性能提升。如果您想要更快且仍然可靠的增量 Maven 构建,您可以使用 Gradle Enterprise,它为 Maven 提供本地构建缓存

Gradle 和 Bazel 在没有删除先前构建的输出(使用 clean)的情况下都能可靠地构建。它们在具有良好模块化项目的增量场景中都表现良好。Gradle 在此场景中略胜于 Bazel。

一个值得详细阐述的重要结果是,在执行单体增量更改时,Gradle 的速度比 Bazel 快 5-16 倍。这是因为 Gradle 在处理增量更改时有两个级别的流程;其中一个与 Bazel 的流程非常相似。两者都取决于某个模块所需的某个操作的输入依赖项是否已更改,来判断是否需要重新构建。然而,当 Bazel 由于输入更改而需要重新运行构建操作时,该模块的构建操作会重新构建所有内容。相比之下,Gradle 有第二个流程层。通过 Gradle,许多构建操作与工具链深度集成,并且只重新构建所需的内容。例如,Gradle 编译构建操作仅重新编译模块中受更改影响的源类。Bazel 会重新编译所有源类。Gradle 还有许多其他任务可以进行增量重建,任何 Gradle 插件都可以利用这一点,例如 Gradle Android 插件。

Bazel 改进增量构建的解决方案是将其分解为更小的模块,并可能重新设计依赖关系图。从构建系统实现的视角来看,这种方法有一种吸引人的简洁性,但它会给最终用户带来维护和适用性方面的代价。从我们的角度来看,它也反映了缺乏建模。这在构建编写和维护部分进行了讨论。

分布式构建和测试支持 #

Bazel 支持一个将单个构建分发到多台机器的协议。Gradle Enterprise 为 Gradle 提供分布式测试执行,并即将支持 Maven 兼容性。一些 CI 产品也提供分发构建某些部分的功能(例如 Jenkins 中的并行阶段)。与构建缓存不同,您通常需要投入大量精力重构代码库才能从分发整个构建中获得实质性好处。对于大多数项目,测试执行时间是总构建时间的决定性因素。Gradle Enterprise 的测试分发通过分发单个测试来加速现有测试套件,而无需进行重大重构。

分布式测试的详细信息及其性能影响将在另一篇文章中介绍。

构建编写和维护 #

上面的性能部分展示了 Gradle 和 Bazel 之间的性能差异。然而,原始性能只是故事的一部分。优化 Bazel 构建以获得最佳性能是一个很大的主题,对构建编写和维护有重大影响。在本节中,我们将深入探讨这些关键考虑因素。

方法上的差异 #

Gradle 和 Bazel 帮助用户实现类似的目标,但它们在设计和提供的功能方面实际上是截然不同的工具。

Gradle 旨在提供完整的软件构建解决方案。它针对 JVM 和 Android 项目进行了高度优化,并与工具链深度集成,以提供性能和便利性。例如,为了编译 Java 代码,Gradle 通过基于复杂的内置依赖分析来确定要重新编译哪些类,从而实现增量编译。

Bazel 提供了一个执行引擎,它将工作委托给外部工具来完成实际工作。例如,在增量编译的情况下,Bazel 仅将工作委托给 javac,并依赖于细粒度的显式构建文件来以增量方式编译整个项目。

Gradle 和 Bazel 之间这种根本性的方法差异远远超出了增量编译问题。它对构建编写和维护具有重大影响,并影响包括构建性能、系统架构、JVM 支持深度以及非 JVM 编程语言支持广度在内的多个问题。

构建文件的粒度 #

Gradle 的构建文件通常在项目级别定义,由多个生产代码和测试包以及相关资源组成。

Bazel 的构建文件可以以不同的粒度级别定义。

人们可以使用与 Gradle 相同的方法,即每个项目一个构建文件。这是我们在一些使用 Bazel 的开源项目中观察到的模式。正如我们稍后将详细介绍的,对于大多数项目来说,这种方法是最实用的。出于这些原因,这是我们为性能比较选择的 Bazel 构建文件粒度。

正如 Bazel 为 Java 项目推荐的方法一样,为每个 Java 包指定构建文件,如下所示。

Gradle vs Bazel build files

当在此方法中使用 Bazel 时,包之间的依赖关系会在构建文件中显式声明。不允许包之间存在循环依赖,并且传递依赖项必须显式声明。这不仅导致了大量的构建文件,而且随着代码库的演变,构建文件还变得冗长且需要大量的维护。

这种推荐的构建文件粒度会产生一些在尝试进行此类设置之前理解起来很重要的后果。

性能影响 #

包级别粒度是 Bazel 推荐以获得最佳性能的。它使 Bazel 能够执行许多优化,包括编译避免、并行执行,以及在拥有所需基础设施的情况下,在远程机器上执行工作。

Gradle 与 Java 工具链的集成更为深入,使得增量编译和注解处理等优化能够有效发挥作用,而无需额外的仪式或结构约束。

这解释了为什么 Gradle 在我们测试的多个场景中具有显著的性能优势。如果我们使用每个 Java 包的构建文件粒度,我们可能会看到 Bazel 的结果有所改善。然而,正如我们稍后将解释的,对于大多数现有项目来说,这种方法是不可行的。

架构影响 #

顺便说一句,从架构角度来看,我们认为避免包之间的循环依赖是 Bazel 模型的一个积极的副作用。我们积极鼓励代码库具有良好的模块化。这对于远程缓存的有效性也很重要。Gradle 用户可以使用 ArchUnit 或 Java Platform Module System (JPMS) 等工具来强制执行此操作。

迁移和维护成本影响 #

几乎所有 Java 项目的现实情况是,包之间存在循环依赖。包通常也不小,并且有许多类相互依赖。拥有每个包的细粒度模块,不仅仅是配置构建逻辑的问题;它通常需要进行重大重构,如果不是完全重写的话。

如果没有无缝的自动化工具,Bazel 构建中的细粒度构建文件维护起来成本非常高。在 Google 内部,这种自动化是由专有工具提供的。对于 Bazel 开源,用户需要手动声明包之间的依赖关系。Bazel 生态系统中有各种倡议来为 JVM 构建创建此类工具。然而,我们的调查发现所有这些项目要么已被存档,要么已被放弃。

Gradle 在没有细粒度构建文件的情况下仍然很快。更少的构建文件和更少的冗长构建声明意味着在更改正在构建的代码时,维护成本降低,仪式也更少。

JVM 与多语言支持 #

工具之间方法上的差异也体现在 JVM 领域的支持深度以及其他语言的支持广度上。

Bazel 提供了一个高效的执行引擎,它将工作委托给外部命令和工具来完成实际工作。这使得 Bazel 更容易为更多编程语言提供支持。因此,Bazel 官方支持多种非 JVM 编程语言和平台,包括 C++、Python 和 Go。

另一方面,Gradle 不仅仅是一个高效的执行引擎,而是一个功能齐全的构建工具。它为 JVM 和 Android 提供了最深入的支持。它使得在这些生态系统中创建和维护高性能构建变得更加容易。尽管如此,Gradle 官方支持多种非 JVM 语言,如 C++ 和 Swift,并且有社区插件可用于其他几种语言,包括 Python 和 Go。

可扩展性 #

复杂的构建很少仅依赖于构建工具的内置功能。通常用户依赖于扩展,其中许多是由用户社区提供的。

自定义插件/规则 #

Gradle 和 Bazel 提供了共享可重用自定义构建逻辑片段的机制。主要的机制分别是自定义插件规则。这些也是在内部提供内置功能的机制。

Bazel 规则是用 Starlark 实现的,这是一种受 Python 启发的语言。在实践中,许多 Bazel 规则将工作委托给外部工具和 bash 脚本。

Gradle 插件可以用任何 JVM 语言(通常是 Java、Kotlin 或 Groovy)实现,并使用丰富的 Gradle API 来满足常见的构建需求。通过使用 Gradle 的灵活 DSL 和自定义插件,Gradle 用户可以创建优雅且声明式的构建脚本。它可以用于非常广泛的用例,并使 Gradle 成为一个强大的工具,即使是最具挑战性的集成场景。

构建的正确性 #

诸如增量构建和构建缓存之类的工作规避优化带来的一个挑战是确保构建的正确性。构建结果应与是否为完整构建或是否规避了一些工作相同。

构建中的所有步骤都可以描述为基于输入生成输出的操作。例如,Java 编译从源文件生成类文件(并具有一些其他输入,如编译器选项等)。为了使许多优化正确工作,必须正确声明输入和输出。

Gradle 和 Bazel 都提供经过广泛正确性测试的内置插件/规则。在这方面,两种工具之间的主要区别在于如何确保自定义构建逻辑的正确性。

Bazel 依赖于沙箱来确保所有输入和输出都已正确声明。如果不是,自定义规则将根本无法工作,从而使正确性问题更容易发现。然而,沙箱会带来性能开销,许多 Bazel 用户选择禁用它,尤其是在 MacOS 上。沙箱在 Windows 上也不支持

Gradle 依赖用户在其自定义构建逻辑和插件中正确声明输入和输出。用户可以使用TestKit 库来测试其自定义代码。许多潜在问题会被Gradle 插件开发插件自动捕获。此外,Gradle 在这方面正变得越来越严格。例如,Gradle 6.0弃用了一些潜在有问题的行为,这些行为将在下一个主要版本中完全阻止。用户已经可以通过--warning-mode=fail 标志选择使其构建因这些问题而失败。

依赖管理 #

绝大多数软件项目都需要外部依赖。管理它们可能是一个繁琐且易出错的过程。依赖管理是一个复杂的问题,也是构建工具的关键功能。

现代 JVM 项目通常会声明它们所需的依赖项,并依赖于构建工具来自动化从外部存储库获取依赖项(及其依赖项)的过程,同时管理版本冲突、缓存以及许多其他注意事项。

Gradle 和 Bazel 都提供对依赖管理工作流程的支持。Gradle 内置支持 Maven 和 Ivy 存储库。Bazel 提供了一个官方规则,仅支持 Maven 存储库,并且需要一些样板代码才能使用它。两种工具都提供某种形式的冲突解决并在本地缓存工件。Gradle 提供了更高级的传递依赖管理功能,如下所述。

动态和丰富的版本声明 #

Bazel 和 Gradle 都支持使用静态、确定性版本声明依赖项。

Gradle 还支持版本范围动态版本以支持各种二进制集成场景。此外,丰富的版本声明允许更精确地定义依赖项版本,以便 Gradle 可以就选择哪个版本做出更好的决策,并最终帮助用户避免各种依赖项问题。用户可以锁定版本范围和动态版本以确保可重复的构建。

传递依赖管理 #

传递依赖管理是一个复杂的主题,简单的版本冲突解决方法是不够的。声明的依赖项中的微小变化经常会导致意外的编译甚至运行时错误,因为传递依赖项会发生变化。

Gradle 为以下一些常见的传递依赖管理问题提供了头等解决方案:

  • 需要降级依赖项版本,因为项目需要比传递引入的旧版本外部依赖项,并且选择最新版本的默认解析策略不合适。
  • 相关依赖项需要对齐到同一版本才能正确协同工作(例如,流行 Jackson 库的不同模块)。
  • 依赖项是互斥的,因为它们提供相同的功能(例如,多个日志记录器实现)。
  • 依赖项提供不同的变体,这些变体针对特定的 Java 版本进行了优化(例如,Guava 库及其 Android 和 JRE 版本)。
  • 依赖项提供错误的元数据
  • 依赖项仅为某些功能需要额外的依赖项。

在 Bazel 中,以上任何用例都不受支持。用户需要依赖显式依赖项声明和排除有问题的版本来尝试解决上述问题。对于具有非平凡依赖关系图的项目,这非常昂贵且难以维护。随着代码库的演变,它经常会导致难以调试的构建和测试失败。如果构建的软件是一个库,它还会将解决相同问题的负担推给库的消费者。

其他功能 #

构建和测试功能 #

Gradle 为构建和测试 Java 和 JVM 项目提供了许多便利。Bazel 目前缺乏 JVM 项目基本操作的内置结构。

例如,Bazel 用户通常依赖自定义 Python 或 shell 脚本以及手工制作的 pom.xml 文件来发布工件。相比之下,Gradle 为发布提供了内置支持,具有高级 DSL 和合理的默认值。

Bazel 中的测试也更复杂,因为没有测试检测。相反,用户必须显式声明要编译和运行的测试类或测试套件。要运行多个测试,Bazel 用户通常依赖于构建中的自定义步骤来生成 JUnit 测试套件。相比之下,Gradle 默认运行它检测到的所有测试。此外,JUnit 5 目前在 Bazel 中不受官方支持,而在 Gradle 中,它可以通过一行声明式代码启用。

IDE 支持 #

Intellij IDEA 开箱即用地支持 Gradle,并且有适用于 Eclipse 的官方插件。Red Hat 在 Visual Studio Code 中为 Java 提供支持,包括 Gradle 支持。Bazel 仅为 IDEA 提供官方插件。目前,IDEA 的 Bazel 支持在 macOS 上是“尽力而为”的,并且不支持 Windows。Bazel 的官方 Eclipse 支持未维护

Spring Boot 支持 #

Spring Boot Gradle 插件由 Spring Team 维护。目前,Bazel 只有非官方的 Spring Boot 规则和示例。

Kotlin 支持 #

Gradle 的 Kotlin 插件由 JetBrains 官方支持和维护,绝大多数 Kotlin 项目使用 Gradle。Bazel 提供官方的Kotlin 构建规则。Kotlin Multiplatform 利用了 Gradle 提供的内置的、感知变体的依赖管理。

流行度 #

Gradle 构建工具是 GitHub 上开源 JVM 项目最受欢迎的构建工具。根据 Jetbrains 的2019 年开发者生态系统状况调查,大多数 Java 开发人员定期使用它。自 2013 年以来,Gradle 构建工具的下载量大约每年翻一番,目前每月下载量超过 1500 万次。Gradle 构建工具拥有明显更大的插件生态系统(>4300 个 Gradle 社区插件 vs Google 或社区维护的>120 个 Bazel 规则)。

总结 #

Gradle 和 Bazel 都是快速的构建系统,与 Maven 相比具有显著优势,而 Gradle Enterprise for Maven 正在缩小这一差距。虽然我们认为 Bazel 的方法有很多优点,特别是对于像 Google 那样的超大型单体仓库,但以上分析表明,对于大多数 JVM 项目而言,Gradle 是比 Bazel 更好的选择。以下是主要原因的摘要:

  • 在几乎所有测量过的项目和场景中,包括由数百万行代码组成的超大型单体项目,Gradle 的速度都比 Bazel 快。这种性能优势源于多种因素,包括与 Java 工具链的深度集成。
  • 优化 Bazel 项目需要付出巨大的构建编写和维护成本,因为需要细粒度和冗长的构建文件声明,而 Gradle 则没有这个问题。对 Bazel 性能优化充分利用对于大多数现有项目来说是不切实际的,尤其是在没有适用于 JVM 构建的附加工具的情况下。
  • Gradle 为 JVM 项目的常见用例提供了更具吸引力的功能和便利性,包括关键的依赖管理和工具集成功能。Bazel 目前缺乏 JVM 构建工具的预期基本功能,例如轻松运行所有测试或发布工件的能力。

无论您是使用 Gradle 还是 Bazel 来提高开发人员的生产力,仅仅选择更快的构建工具是不够的,除非与专门的、持续的努力相结合,以使您的工具链快速可靠。这就是为什么我们建议在所有级别拥抱开发人员生产力工程和开发人员生产力文化。

讨论