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 和 Maven,但将来会包括 Bazel 和其他工具。要了解有关 Gradle Enterprise 中针对 Gradle、Maven 和即将推出的 Bazel 构建工具提供的加速技术的更多信息,请观看名为“加速 Maven 构建 | Maven 构建缓存技术和业务案例说明”的视频。
性能
构建系统在您的项目中的执行效果取决于多种因素。这些因素包括项目的规模和结构、您使用的工具链以及您的工作流程。在本节中,我们将描述我们进行性能比较的方法以及基于代表性场景的结果。
关于性能比较
Gradle 和 Bazel 在性能优化方面采用了不同的方法和重点领域。很容易创建一些测试项目来表明 Gradle 在特定性能场景中明显快于 Bazel,反之亦然。问题始终是这些测试项目的成果在多大程度上适用于现实项目,更重要的是,适用于您的项目。
我们比较的方法是模拟常见的 Java 项目类型。这涵盖了从数百万行代码的大型代码库到小型库/微服务项目。本文不会涵盖各种代码结构方法的优缺点,无论是单个大型源代码库内还是跨多个较小的源代码库。这两种方法在 JVM 生态系统中都有很大的影响力,通常也存在于单个组织内。从一种方法切换到另一种方法需要大量的迁移工作以及工作流程和文化的改变。JVM 的构建系统应该很好地支持这两种方法。
性能测量场景
不同的场景使用以下项目大小和形状进行测量
项目名称 | 模块数量 | 生产类数量 | 测试类数量 | LOC |
---|---|---|---|---|
小型多项目 | 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 用户来说很有趣,可以让他们了解性能优势是否来自缓存或其他因素,因为他们通常出于可靠性原因进行干净构建。
即使没有构建缓存和增量构建优化,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 在不运行干净的情况下不可靠。即使没有干净,它也仍然从头开始执行大多数目标。因此,您冒着构建损坏的风险,而性能提升却很有限。如果您想要更快的可靠增量构建,可以使用 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 包指定构建文件,如下所示。
在 Bazel 中使用这种方法时,包之间的依赖关系在构建文件中显式声明。不允许包之间的循环依赖关系,并且必须显式声明传递依赖关系。这不仅会导致大量构建文件,还会导致构建文件冗长,并且随着代码库的演变,需要付出大量的维护工作。
这种推荐的构建文件粒度有一些后果,在尝试这种设置之前需要了解这些后果。
性能影响
为了获得最佳性能,建议在 Bazel 中使用包级粒度。它使 Bazel 能够执行许多优化,包括避免编译、并行执行,以及如果可用,在远程机器上执行工作。
Gradle 与 Java 工具链的集成更加深入,允许进行增量编译和注释处理等优化,而无需额外的仪式或结构约束。
这解释了为什么 Gradle 在我们测试的许多场景中具有显著的性能优势。如果我们使用每个 Java 包粒度的构建文件,我们可能会看到 Bazel 的结果有所改善。但是,这种方法对于大多数现有项目来说是不可行的,我们将在稍后解释。
架构影响
作为旁注,我们认为从架构的角度来看,避免包之间的循环依赖是 Bazel 模型的一个积极副作用。我们热烈鼓励使用良好模块化的代码库。这对于远程缓存的有效性也很重要。Gradle 用户可以使用 ArchUnit 或 Java 平台模块系统 (JPMS) 等工具来强制执行此操作。
迁移和维护成本影响
几乎所有 Java 项目的现实情况是,包之间存在循环依赖关系。这些包通常也不小,并且包含许多相互依赖的源代码类。拥有每个包的小型、细粒度的模块,不仅仅是配置构建逻辑的问题;它通常需要进行重大重构,如果不是完全重写的话。
如果没有无缝的自动化工具,Bazel 构建中的细粒度构建文件维护起来非常昂贵。在 Google 内部,这种自动化是由专有工具提供的。对于 Bazel 开源,用户需要手动声明包之间的依赖关系。Bazel 生态系统中已经有一些倡议来为 JVM 构建创建此类工具。但是,我们的调查发现,所有这些项目要么已存档,要么已废弃。
Gradle 在没有细粒度构建文件的情况下速度很快。更少的构建文件和更少的冗长构建声明意味着减少维护和在更改正在构建的代码时减少仪式。
JVM 与多语言支持
两种工具在方法上的差异也反映在它们对 JVM 领域的深度支持和对其他语言的广度支持上。
Bazel 提供了一个高效的执行引擎,它委托给外部命令和工具来完成实际工作。这使得 Bazel 更容易为更多编程语言提供支持。因此,Bazel 包含对包括 C++、Python 和 Go 在内的多种非 JVM 编程语言和平台的官方支持。
另一方面,Gradle 不仅仅是一个高效的执行引擎,而是一个完整的构建工具。它为 JVM 和 Android 提供了最深层的支持。这使得在这些生态系统中创建和维护高性能构建变得更加容易。尽管如此,Gradle 仍然为 C++ 和 Swift 等多种非 JVM 语言提供官方支持,并且社区插件可用于包括 Python 和 Go 在内的其他几种语言。
可扩展性
复杂的构建很少只依赖于构建工具的内置功能。通常,用户依赖于扩展,其中许多由用户社区提供。
自定义插件/规则
Gradle 和 Bazel 提供了机制来共享可重用的自定义构建逻辑片段。主要分别是 自定义插件 和 规则。这些也是内部用于提供内置功能的机制。
Bazel 规则是用 Starlark 实现的,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 支持
Gradle 在 Intellij IDEA 中开箱即用,并且为 Eclipse 提供了官方插件。Red Hat 为 Java 提供支持,包括 Visual Studio Code 中的 Gradle 支持。Bazel 仅为 IDEA 提供官方插件。目前,Bazel 对 IDEA 的支持 仅在 macOS 上提供“尽力而为”的支持,并且在 Windows 上不受支持。Bazel 的官方 Eclipse 支持是 未维护的。
Spring Boot 支持
Spring Boot Gradle 插件由 Spring 团队维护。目前,Bazel 仅存在非官方的 Spring Boot 规则和示例。
Kotlin 支持
Gradle 的 Kotlin 插件 由 JetBrains 官方支持和维护,绝大多数 Kotlin 项目使用 Gradle。Bazel 提供了官方的 Kotlin 构建规则。Kotlin 多平台 利用 Gradle 提供的内置变体感知依赖管理。
流行度
Gradle 构建工具是 GitHub 上最流行的开源 JVM 项目构建工具。根据 Jetbrains 的 开发者生态系统现状 2019 调查,大多数 Java 开发人员定期使用它。自 2013 年以来,Gradle 构建工具的下载量大约每年翻一番,现在每月下载量超过 1500 万次。Gradle 构建工具拥有一个明显更大的插件生态系统(>4300 个 Gradle 社区插件 vs >120 个由 Google 或社区维护的 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 来提高开发人员效率,选择更快的构建工具还不够,如果没有持续的努力来保持工具链快速可靠,则无法实现这一目标。这就是我们建议您采用 开发人员效率工程 的纪律和在所有层级建立开发人员效率文化的理由。