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

目录

引言

我们非常自豪地宣布,新发布的 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) 或样板代码减少 (Lombok, Autovalue, Butterknife, …)。但是,使用注解处理器可能会对构建的性能产生非常不利的影响。

注解处理器是做什么的? #

基本上,注解处理器是一个 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 不知道它们将要生成哪些文件。它也不知道在哪里以及基于什么条件生成。因此,如果正在使用注解处理器,即使您像我们刚才所做的那样显式声明它们,Grade 也会禁用 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 中需要什么与您的实现需要什么。换句话说,您正在将组件的编译依赖项泄漏给下游使用者

假设我们正在构建一个 IoT 应用程序 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 提供的更干净!让我们看看我们使用 maven-publish 插件为 heat-sensor 生成的内容

<?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 配置,它可以用于声明仅在编译组件时才需要的依赖项,而在运行时不需要(典型的用例是将库嵌入到 fat jar 或 shadowed 中)。java-library 插件提供了从 java 插件的平滑迁移路径:如果您正在构建应用程序,则可以继续使用 java 插件。否则,如果是库,只需使用 java-library 插件。但在两种情况下

  • 而不是 compile 配置,您应该改用 implementation
  • 而不是 runtime 配置,您应该使用 runtimeOnly 配置来声明仅在运行时应可见的依赖项
  • 要解析组件的运行时,请使用 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 库插件的文档,并发现它可以为您做的一切!

讨论