Gradle 3.4 中的增量编译、Java 库插件和其他性能特性

目录

引言

我们非常自豪地宣布,新发布的 Gradle 3.4 显著改进了对构建 Java 应用程序的支持,适用于各类用户。本文详细解释了我们修复、改进和添加了什么。我们将特别关注:

  • 极快的增量构建
  • 可怕的编译类路径泄露的终结

我们所做的改进可以显著缩短您的构建时间。以下是我们的测量结果:

基准测试是公开的,您可以自己尝试,它们是代表我们消费者报告的真实世界问题的合成项目。特别是,在持续开发过程中重要的是增量性(微小的更改绝不应导致长时间的构建)。

对于那些在单个项目中有大量源代码的人

  • 在一个大型单一项目中更改单个文件并重新编译
  • 在一个中型单一项目中更改单个文件并重新编译

对于多项目构建

  • 在子项目中以 ABI 兼容的方式(例如,更改方法体但不更改方法签名)进行更改并重新编译
  • 在子项目中以 ABI 不兼容的方式(例如,更改公共方法签名)进行更改并重新编译

对于所有这些场景,Gradle 3.4 都快得多。让我们看看我们是如何做到的。

所有编译皆可避免 #

Gradle 3.4 在 Java 支持方面最重大的变化之一是免费提供的:升级到 Gradle 3.4 并受益于编译避免。编译避免与增量编译不同,我们将在后面介绍。那么它意味着什么呢?实际上很简单。想象一下您的项目 app 依赖于项目 core,而 core 本身依赖于项目 utils

app

public class Main {
   public static void main(String... args) {
        WordCount wc = new WordCount();
        wc.collect(new File(args[0]);
        System.out.println("Word count: " + wc.wordCount());
   }
}

core

public class WordCount {  // WordCount lives in project `core`
   // ...
   void collect(File source) {
       IOUtils.eachLine(source, WordCount::collectLine);
   }
}

utils

public class IOUtils { // IOUtils lives in project `utils`
    void eachLine(File file, Callable<String> action) {
        try {
            try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
                // ...
            }
        } catch (IOException e) {
            // ...
        }
    }
}

然后,更改 IOUtils 的实现。例如,更改 eachLine 的主体以引入预期的字符集

public class IOUtils { // IOUtils lives in project `utils`
    void eachLine(File file, Callable<String> action) {
        try {
            try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(file), "utf-8") )) {
                // ...
            }
        } catch (IOException e) {
            // ...
        }
    }
}

现在重新构建 app。会发生什么?到目前为止,utils 必须重新编译,但由于依赖链,它还会触发 core 的重新编译,最终是 app 的重新编译。乍一看似乎合理,但真的如此吗?

IOUtils 中发生的变化纯粹是内部细节。eachLine 的实现发生了变化,但其公共 API 没有。以前针对 IOUtils 编译的任何类文件仍然有效。Gradle 现在足够智能,能够意识到这一点。这意味着如果您进行这样的更改,Gradle 将只重新编译 utils,而不会重新编译其他任何东西!虽然这个例子听起来很简单,但它实际上是一个非常常见的模式:通常,一个 core 项目被许多子项目共享,并且每个子项目都依赖于不同的子项目。对 core 的更改将触发所有项目的重新编译。使用 Gradle 3.4,这种情况将不再发生,这意味着它会识别 ABI(应用程序二进制接口)破坏性更改,并且只在这种情况下触发重新编译。

这就是我们所说的编译避免。但即使在编译无法避免的情况下,Gradle 3.4 也会在增量编译的帮助下使事情变得更快。

改进的增量编译 #

多年来,Gradle 一直支持 Java 的实验性增量编译器。在 Gradle 3.4 中,这个编译器不仅稳定,而且我们还显著提高了其健壮性和性能!现在就使用它:我们很快就会将其设为默认!要启用 Java 增量编译,您只需在编译选项中进行设置:

tasks.withType(JavaCompile) {
   options.incremental = true // one flag, and things will get MUCH faster
}

如果我们在项目 core 中添加以下类:

public class NGrams {  // NGrams lives in project `core`
   // ...
   void collect(String source, int ngramLength) {
       collectInternal(StringUtils.sanitize(source), ngramLength);
   }
   // ...
}

并在项目 utils 中添加这个类:

public class StringUtils {
   static String sanitize(String dirtyString) { ... }
}

想象一下,我们更改了类 StringUtils 并重新编译我们的项目。您可以很容易地看到我们只需要重新编译 StringUtilsNGrams,而不需要重新编译 WordCountNGramsStringUtils 的依赖类。WordCount 不使用 StringUtils,那么为什么它需要重新编译呢?这就是增量编译器所做的:它分析类之间的依赖关系,并且只在类本身或其依赖的类发生更改时才重新编译。

那些以前尝试过增量 Java 编译器的人可能已经发现,当更改的类包含常量时,它不是很智能。例如,这个类包含一个常量:

public class SomeClass {
    public static final int MAGIC_NUMBER = 123;
}

如果这个类被修改,那么 Gradle 就会放弃,不仅重新编译该项目的所有类,还会重新编译依赖于该项目的所有项目的类。如果你想知道为什么,你必须明白 Java 编译器会内联这样的常量。所以当我们分析编译结果时,如果一个类的字节码包含字面量123,我们不知道这个字面量是在哪里定义的。它可能在类本身中,或者在类路径上任何地方找到的任何依赖项的常量中。在 Gradle 3.4 中,我们让这个行为变得更智能,只重新编译可能受到更改影响的类。换句话说,如果类被修改,但常量没有修改,我们就不需要重新编译。同样,如果常量被修改,但依赖者在它们的字节码中没有旧值的字面量,我们就不需要重新编译它们:我们只会重新编译那些有候选字面量的类。这也意味着并非所有常量都是平等的:一个常量值0在更改时更有可能触发完整重新编译,而不是常量值188847774

我们的增量编译器现在还支持在 Gradle daemon 中跨构建存在的内存缓存,因此它比以前显著更快:提取 Java 类的 ABI 是一项昂贵的操作,以前只在磁盘上缓存。

如果您将所有这些增量编译改进与我们在这篇文章中前面描述的编译避免结合起来,Gradle 在重新编译 Java 代码时现在非常快。更好的是,它也适用于外部依赖项。想象一下您从foo-1.0.0升级到foo-1.0.1。如果两个库版本之间唯一的区别是,例如,一个错误修复,并且 API 没有改变,编译避免将启动,并且外部依赖项中的此更改不会触发您的代码重新编译。如果外部依赖项的新版本修改了公共 API,Gradle 的增量编译器将分析您的项目对外部依赖项单个类的依赖项,并仅在必要时重新编译。

关于注解处理器 #

注解处理器是一种非常强大的机制,允许仅通过注解源文件来生成代码。典型的用例包括依赖注入(Dagger)或减少样板代码(LombokAutovalueButterknife 等)。然而,使用注解处理器可能会对构建的性能产生非常负面的影响。

注解处理器做什么? #

基本上,注解处理器是一个 Java 编译器插件。每当 Java 编译器识别出由处理器处理的注解时,它就会被触发。从构建工具的角度来看,它是一个黑盒:我们不知道它会做什么,特别是它会生成什么文件,以及在哪里生成。

因此,每当注解处理器实现发生更改时,Gradle 都需要重新编译所有内容。这本身并没有那么糟糕,因为这可能不会经常发生。但由于即将解释的原因,情况要糟糕得多,当注解处理器未明确声明时,Gradle 必须禁用编译避免。但首先让我们了解发生了什么。通常,注解处理器现在会添加到编译类路径中。

虽然 Gradle 可以检测哪个 jar 包含注解处理器,但它无法检测编译类路径中哪些其他 jar 被注解处理器实现使用。它们也有依赖关系。这意味着编译类路径中的任何更改都可能以 Gradle 无法理解的方式影响注解处理器的行为。因此,编译类路径中的任何更改都将触发完全重新编译,我们又回到了原点。

但有一个解决方案。

显式声明注解处理器类路径 #

一个使用外部依赖项的编译器插件——注解处理器,是否应该影响你的编译类路径?不,注解处理器的依赖项绝不应该泄露到你的编译类路径中。这就是为什么javac有一个特定的-processorpath选项,它与-classpath不同。以下是如何在 Gradle 中声明它:

configurations {
    apt
}
dependencies {
    // The dagger compiler and its transitive dependencies will only be found on annotation processing classpath
    apt 'com.google.dagger:dagger-compiler:2.8'

    // And we still need the Dagger annotations on the compile classpath itself
    compileOnly 'com.google.dagger:dagger:2.8'
}

compileJava {
    options.annotationProcessorPath = configurations.apt
}

在这里,我们正在创建一个配置 apt,它将包含我们使用的所有注解处理器,因此也包含它们特定的传递依赖项。然后我们将 annotationProcessorPath 设置为这个配置。这有两个好处:

  • 它禁用编译类路径上的自动注解处理器检测,使任务启动更快(更快的最新检查)
  • 它将使用 Java 编译器的 processorpath 选项,并正确分离编译依赖项和注解处理路径
  • 它将启用编译避免:通过明确声明您使用注解处理器,我们现在可以确保类路径上找到的所有内容都只是二进制接口

特别是,您会注意到Dagger如何清晰地将其编译器与其注解分开:我们有dagger-compiler作为注解处理依赖项,以及dagger(注解本身)作为compile依赖项。对于 Lombok,您通常需要将相同的依赖项同时放在compileapt中,才能再次受益于编译避免。

然而,一些注解处理器没有正确分离这些关注点,从而将其实现类泄露到您的类路径中。在这种情况下,编译避免仍然有效:您只需要将 jar 文件同时放在 aptcompileOnly 配置中。

带注解处理器的增量编译 #

如上所述,使用注解处理器时,Gradle 不知道它们将生成哪些文件。它也不知道在哪里以及基于什么条件生成。因此,即使您明确声明了注解处理器,Gradle 也会在它们被使用时禁用 Java 增量编译器。然而,可以将这种影响限制在真正使用注解处理器的类集上。简而言之,您可以声明一个不同的源集,使用不同的编译任务,该任务将使用注解处理器,并让其他编译任务不进行任何注解处理:因此,对不使用注解处理器的类的任何更改都将受益于增量编译,而对使用注解的源的任何更改都将触发完整重新编译,但仅限于该源集。以下是示例:

configurations {
    apt
    aptCompile
}
dependencies {
    apt 'com.google.dagger:dagger-compiler:2.8'
    aptCompile 'com.google.dagger:dagger:2.8'
}

sourceSets {
   processed {
       java {
          compileClasspath += configurations.aptCompile
       }
   }
   main {
       java {
          compileClasspath += processed.output
       }
   }
}

compileProcessedJava {
    options.annotationProcessorPath = configurations.apt
}

实际上,这可能不是一个容易实现的拆分,这取决于main源对processed类中找到的类的依赖程度。然而,我们正在探索在存在注解处理器时启用增量编译的选项,这意味着这在未来不应该成为问题。

Java 库 #

我们 Gradle 团队长期以来一直在解释为什么 Maven 的依赖模型是破损的,但通常很难在没有具体例子的情况下意识到这一点,因为用户只是习惯了这种缺陷并将其视为自然而然。特别是,pom.xml 文件既用于构建组件,也用于其发布元数据。Gradle 一直以不同的方式工作,它有构建脚本作为构建组件的“食谱”,以及可以发布到 Maven、Ivy 或您需要支持的任何其他存储库的发布。发布包含有关如何使用项目的元数据,这意味着我们明确区分了构建组件所需的内容和作为其消费者所需的内容。将这两个角色分开非常重要,它允许 Gradle 3.4 为 Java 依赖管理添加一个根本性的改进。您可以通过此新功能获得多重好处。一个好处是更好的性能,因为它补充了我们上面描述的其他性能功能,但还有更多。

我们一直都做错了 #

在构建 Java 项目时,需要考虑两件事:

  • 我需要什么来编译项目本身?
  • 我需要在运行时执行项目什么?

这自然而然地引导我们以两种不同的范围声明依赖项:

  • compile:我需要编译项目所需的依赖项
  • runtime:我需要运行项目所需的依赖项

Maven 和 Gradle 多年来一直使用这种方式。但从一开始,我们就知道我们错了。这种观点过于简单,因为它没有考虑你的项目的消费者。特别是,Java 世界中至少有两种项目:

  • 应用程序,它们是独立的、可执行的,并且不暴露任何 API
  • 库,它们被其他库或应用程序用作构建软件的模块,因此暴露一个 API

使用两个配置(Gradle)或范围(Maven)的简单方法的问题在于,您没有考虑您的 API 中需要什么,以及您的实现中需要什么。换句话说,您正在**将组件的编译依赖项泄露给下游消费者**。

想象一下,我们正在构建一个物联网应用程序 home-automation,它依赖于 heat-sensor 库,该库在其编译类路径中包含 commons-math3.jarguava.jar。那么 home-automation 的编译类路径将包含 commons-math3.jarguava.jar。这会带来几个后果:

  • home-automation 可能会开始使用 commons-math3.jarguava.jar 中的类,而没有真正意识到它们是 heat-sensor 的传递依赖项(传递依赖项泄露)。
  • home-automation 的编译类路径更大
    • 这增加了花在依赖解析、最新检查、类路径分析和 javac 上的时间。
    • 新的 Gradle 编译避免效率会降低,因为类路径中更容易发生变化,并且编译避免将不会生效。特别是,当您使用注解处理器时,Gradle 增量编译被禁用,这将带来高昂的代价。
  • 您增加了依赖地狱(类路径上相同依赖项的不同版本)的可能性

但最糟糕的问题是,如果 guava.jar 的使用是 heat-sensor 的纯内部细节,而 home-automation 因为在类路径上找到它而开始使用它,那么 heat-sensor 的演进将变得非常困难,因为它会破坏消费者。依赖项的泄露是一个可怕的问题,它导致软件演进缓慢和功能冻结,以牺牲向后兼容性为代价。

我们知道我们一直都做错了,是时候修复它,并引入新的 Java 库插件了!

介绍 Java 库插件 #

从 Gradle 3.4 开始,如果您构建一个 Java 库,也就是说一个旨在被其他组件使用的组件(一个作为另一个组件依赖项的组件),那么您应该使用新的 Java 库插件。而不是编写:

apply plugin: 'java'

使用

apply plugin: 'java-library'

它们都共享一个共同的基础设施,但是java-library插件暴露了API的概念。让我们迁移我们的heat-sensor库,它本身有两个依赖项:

dependencies {
   compile 'org.apache.commons:commons-math3:3.6.1'
   compile 'com.google.guava:guava:21.0'
}

当您研究 heat-sensor 中的代码时,您会发现 commons-math3 暴露在公共 API 中,而 guava 纯粹是内部的。

import com.google.common.collect.Lists;
import org.apache.commons.math3.stat.descriptive.SummaryStatistics;

public class HeatSensor {
    public SummaryStatistics getMeasures(int lastHours) {
         List<Measurement> measures = Lists.newArrayList(); // Google Guava is used internally, but doesn't leak into the public API
         // ...
         return stats;
    }
}

这意味着如果明天,heat-sensor 想要从 Guava 切换到另一个集合库,它可以这样做而不会对其消费者产生任何影响。但实际上,只有当我们清晰地将这些依赖项分成两个类别时才可能实现:

dependencies {
   api 'org.apache.commons:commons-math3:3.6.1'
   implementation 'com.google.guava:guava:21.0'
}

api 桶用于声明在编译时应由下游消费者传递可见的依赖项。implementation 桶用于声明不应泄露到消费者编译类路径中的依赖项(因为它们纯粹是内部细节)。

现在,当 heat-sensor 的消费者要被编译时,它将**只**在编译类路径上找到 commons-math3.jar,而不是 guava.jar。所以如果 home-automation 意外地尝试使用 Google Guava 中的类,它将在编译时失败,并且消费者需要决定它是否真的想引入 Guava 作为依赖项。另一方面,如果它尝试使用 Apache Math3 中的类(这是一个 API 依赖项),那么它将成功,因为 API 依赖项在编译时是绝对必需的。

比 Maven 更好的 POM #

那么 implementation 何时重要呢?它只在运行时重要!这就是为什么,现在,当您选择发布到 Maven 仓库时,Gradle 生成的 pom.xml 文件比 Maven 提供的更清晰!让我们看看我们为 heat-sensor 生成了什么,使用 maven-publish 插件:

<?xml version="1.0" encoding="UTF-8"?>
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.acme</groupId>
  <artifactId>heat-sensor</artifactId>
  <version>1.0.0-SNAPSHOT</version>
  <dependencies>
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-math3</artifactId>
      <version>3.6.1</version>
      <scope>compile</scope>
    </dependency>
    <dependency>
      <groupId>com.google.guava</groupId>
      <artifactId>guava</artifactId>
      <version>21.0</version>
      <scope>runtime</scope>
    </dependency>
  </dependencies>
</project>

您看到的是已发布的pom.xml文件,因此是消费者使用的文件。它说了什么?

  • 为了编译 heat-sensor,您需要在compile类路径上添加commons-math3
  • 为了运行 heat-sensor,您需要在runtime类路径上添加guava

这与编译组件和消费组件都使用相同的 pom.xml 文件非常不同。因为要编译 heat-sensor 本身,您需要在 compile 中包含 guava。简而言之:Gradle 生成的 POM 文件比 Maven 更好,因为它区分了生产者和消费者。

更多用例,更多配置 #

您可能知道Gradle 2.12 中引入的 compileOnly 配置,它可用于声明仅在编译组件时需要但在运行时不需要的依赖项(典型用例是嵌入到胖 jar 或阴影化的库)。java-library 插件提供了从java插件平滑迁移的路径:如果您正在构建应用程序,您可以继续使用java插件。否则,如果它是一个库,只需使用java-library插件。但这两种情况都适用:

  • 您应该使用 implementation 代替 compile 配置。
  • 您应该使用 runtimeOnly 配置来声明只在运行时可见的依赖项,而不是 runtime 配置。
  • 要解析组件的运行时,请使用 runtimeClasspath 而不是 runtime

对性能的影响 #

为了向您展示对性能的影响,我们添加了一个基准测试,它比较了两种情况:

  • 在库中进行 ABI 兼容的更改,然后重新编译
  • 在库中进行 ABI 不兼容的更改,然后重新编译

只有 Gradle 3.4 支持库的概念,因此使用 Java 库插件。为了更清楚地说明,此基准测试使用增量编译器(这将使事情更快,更新几乎是无操作)。

如您所见,除了更好的建模之外,对性能也有很大的影响!

结论 #

Gradle 3.4 为 Java 生态系统带来了显著的改进。更好的增量编译和编译避免将显著提高您的生产力,而 API 和实现依赖项的清晰分离将避免意外的依赖项泄露,并帮助您更好地建模您的软件。请注意,我们还有更多好东西即将推出。特别是,API 和实现的分离是 Java 9 成功的关键,随着 Project Jigsaw 的觉醒。我们将添加一种声明哪些属于您的 API 的方法,使其更接近 Jigsaw 将提供的功能,但也支持旧的 JDK。

此外,Gradle 4.0 将随附一个构建缓存,它将极大地受益于本文中描述的改进:它是一种机制,允许在本地机器或通过网络重用和共享任务执行结果。典型用例包括切换分支,或者只是签出已经由同事或在 CI 上构建过的项目。换句话说,如果您或其他人已经构建了您需要的东西,您将从缓存中获取它,而不必在本地构建它。为此,构建缓存需要生成一个缓存键,对于 Java 编译任务,该键通常对编译类路径敏感。3.4 中提供的改进将使此缓存键更有可能被命中,因为我们将忽略与消费者无关的内容(只有 ABI 才重要)。

我们鼓励您现在就升级,查看新的 Java 库插件的文档,并发现它能为您做的一切!

讨论