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 守护进程中,跨构建有效,因此比以前快得多:提取 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 有一个与 -classpath 不同的特定 -processorpath 选项。以下是如何使用 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 库,它本身有 2 个依赖项

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 切换到另一个集合库,它可以做到,而不会对它的消费者有任何影响。但在实践中,只有当我们干净地将这些依赖项分成 2 个桶时,这才是可能的。

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 更好,因为它区分了生产者和消费者。

更多用例,更多配置

您可能知道 compileOnly 配置,它是在 Gradle 2.12 中引入的,可用于声明仅在编译组件时才需要的依赖项,而不是在运行时(典型用例是嵌入到胖 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 库插件的文档,并发现它可以为您做些什么!

讨论