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 | 9 万 |
中型多项目 | 100 | 10 000 | 10000 | 180 万 |
大型多项目 | 500 | 50000 | 50000 | 900 万 |
大型单体项目 | 1 | 50000 | 50000 | 700 万 |
所有测试项目都是开源的,包括用于进行测量的运行器。有关如何重现测量的详细信息,请参阅说明。
对于以下每个场景,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 包指定构建文件,如下所示。
在 Bazel 中使用此方法时,包之间的依赖关系在构建文件中显式声明。不允许包之间的循环依赖关系,并且必须显式声明传递依赖关系。这不仅导致大量构建文件,而且还导致构建文件冗长且需要大量精力来维护,因为代码库会不断发展。
这种推荐的构建文件粒度具有许多后果,在尝试进行此类设置之前,务必了解这些后果。
性能影响 #
对于 Bazel,建议使用包级粒度以获得最佳性能。它使 Bazel 能够执行许多优化,包括避免编译、并行执行,以及在所需的infrastructure可用时,在远程机器上执行工作。
Gradle 与 Java 工具链的集成更深入,允许在没有额外仪式或结构约束的情况下有效地进行诸如增量编译和注解处理之类的优化。
这解释了为什么 Gradle 在我们测试的许多场景中具有显着的性能优势。如果我们使用每个 Java 包粒度的构建文件,我们可能会看到 Bazel 的结果有所改善。但是,正如我们稍后将解释的那样,这种方法对于大多数现有项目来说是不可行的。
架构影响 #
作为旁注,我们认为从架构的角度来看,避免包之间的循环依赖关系是 Bazel 模型的一个积极的副作用。我们热情地鼓励模块化良好的代码库。这对于远程缓存的有效性也很重要。Gradle 用户可以使用 ArchUnit 或 Java 平台模块系统 (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 中实现,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 库的不同模块)。
- 依赖项是互斥的,因为它们提供相同的功能(例如,多个 logger 实现)。
- 依赖项提供针对特定 Java 版本优化的不同变体(例如,Guava 库及其 Android 和 JRE 风味)
- 依赖项提供错误的元数据。
- 依赖项仅针对某些特性需要额外的依赖项。
在 Bazel 中,不支持上述任何用例。用户需要依赖显式依赖项声明和排除有问题版本的版本,以尝试解决上述问题。对于具有非平凡依赖关系图的项目,这既昂贵又难以维护。随着代码库的不断发展,它经常会导致难以调试的构建和测试失败。如果构建的软件是一个库,它也会将解决相同问题的责任推给库的使用者。
其他特性 #
构建与测试工具 #
Gradle 为构建和测试 Java 与 JVM 项目提供了许多便利。Bazel 目前缺少 JVM 项目中基本操作的内置构造。
例如,Bazel 用户经常依赖于自定义的 Python 或 shell 脚本以及手工制作的 pom.xml 文件来发布构建产物。相比之下,Gradle 提供了内置的发布支持,它具有高级 DSL 和合理的默认设置。
Bazel 中的测试也更加复杂,因为它没有测试检测功能。相反,用户必须显式声明要编译和运行的测试类或测试套件。为了运行多个测试,Bazel 用户通常依赖于构建中的自定义步骤来生成 JUnit 测试套件。相比之下,Gradle 默认运行它检测到的所有测试。此外,Bazel 目前未正式支持 JUnit 5,而在 Gradle 中,只需一行声明式代码即可启用它。
IDE 支持 #
Intellij IDEA 开箱即用地支持 Gradle,并且 Eclipse 有一个官方插件。Red Hat 在 Visual Studio Code 中提供 Java 支持,包括 Gradle 支持。Bazel 仅为 IDEA 提供官方插件。目前,Bazel 对 IDEA 的支持在 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 社区插件,而 Bazel 规则由 Google 或社区维护的超过 120 个)。
总结 #
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 来提高开发人员的生产力,如果不同时致力于持续努力以保持工具链的快速和可靠,那么选择更快的构建工具是不够的。这就是为什么我们建议在各个层面拥抱开发者生产力工程的原则和开发者生产力文化。