您的构建有多少是从缓存加载的取决于许多因素。在本节中,您将看到一些对于良好缓存构建至关重要的工具。构建扫描是该工具链的一部分,并将在本指南中贯穿使用。

构建缓存键

构建缓存中的工件由构建缓存键唯一标识。当启用构建缓存运行时,会为每个可缓存任务分配一个构建缓存键,该键用于从构建缓存加载和存储任务输出。以下输入有助于任务的构建缓存键

  • 任务实现

  • 任务操作实现

  • 输出属性的名称

  • 任务输入的名称和值

如果两个任务的相关构建缓存键相同,它们可以使用构建缓存来重用其输出。

可重复的任务输出

假设您的构建中有一个代码生成器任务。当您的构建完全最新,并且您在相同的代码库上清理并重新运行代码生成器任务时,它应该生成完全相同的输出,这样任何依赖该输出的东西都将保持最新。

也可能您的代码生成器在其输出中添加了一些不依赖于其声明输入的额外信息,例如时间戳。在这种情况下,重新执行任务导致生成不同的代码(因为时间戳会更新)。依赖代码生成器输出的任务将需要重新执行。

当一个任务是可缓存的,那么任务输出缓存的本质确保了对于给定的一组输入,任务将具有相同的输出。因此,可缓存任务应该具有可重复的任务输出。如果它们没有,那么执行任务和从缓存加载任务的结果可能会不同,这可能导致难以诊断的缓存未命中。

在某些情况下,即使是备受信任的工具也可能产生不可重复的输出,并导致连锁效应。一个例子是 Oracle 的 Java 编译器,它由于一个错误,根据提供给它的源文件顺序,会产生不同的字节码。如果您使用 Oracle JDK 8u31 或更早版本编译buildSrc子项目中的代码,这可能导致您的所有自定义任务偶尔出现缓存未命中,因为它们的类路径(包括buildSrc)存在差异。

这里的关键是可缓存任务不应使用不可重复的任务输出作为输入。

稳定的任务输入

如果一个任务的输入一直在变化,那么它重复地产生相同的输出是不够的。这些不稳定的输入可以直接提供给任务。考虑一个在 jar 文件的 manifest 中包含时间戳的版本号

build.gradle.kts
version = "3.2-${System.currentTimeMillis()}"

tasks.jar {
    manifest {
        attributes(mapOf("Implementation-Version" to project.version))
    }
}
build.gradle
version = "3.2-${System.currentTimeMillis()}"

tasks.named('jar') {
    manifest {
        attributes('Implementation-Version': project.version)
    }
}

在上面的例子中,jar任务的输入对于每次构建执行都将不同,因为这个时间戳将不断变化。

不稳定输入的另一个例子是来自版本控制的提交 ID。也许您的版本号是通过git describe生成的(并且您将其包含在 jar manifest 中,如上所示)。或者也许您直接将提交哈希包含在version.properties或 jar manifest 属性中。无论哪种方式,任何依赖此类数据的任务产生的输出都只能被针对完全相同提交运行的构建重用。

另一个常见但不太明显的非稳定输入来源是当一个任务消耗另一个产生不可重复结果的任务的输出时,例如前面提到的代码生成器在其输出中嵌入时间戳的例子。

一个任务只有在具有稳定任务输入的情况下才能从缓存中加载。不稳定的任务输入会导致任务在每次构建中都具有一组唯一的输入,这总是导致缓存未命中。

通过输入规范化实现更好的重用

拥有稳定的输入对于可缓存任务至关重要。然而,为每个任务实现逐字节相同的输入可能具有挑战性。在某些情况下,清理任务的输出以删除不必要的信息可能是一个好方法,但这同时也意味着任务的输出只能用于单一目的。

这就是输入规范化发挥作用的地方。Gradle 使用输入规范化来确定两个任务输入是否本质上相同。Gradle 在进行最新检查以及确定是否可以重用缓存结果而不是执行任务时使用规范化输入。由于输入规范化由消费数据的任务声明为输入,因此不同的任务可以定义不同的方式来规范化相同的数据。

对于文件输入,Gradle 可以规范化文件的路径及其内容。

路径敏感性与可重定位性

在计算机之间共享缓存结果时,很少有人能在其计算机上的完全相同位置运行构建。为了即使在构建从不同根目录执行时也能共享缓存结果,Gradle 需要了解哪些输入可以重定位,哪些不能。

将文件作为输入的任务可以声明文件中对其重要的部分:这被称为输入的路径敏感性。声明为ABSOLUTE路径敏感性的任务属性被认为是不可重定位的。对于未声明路径敏感性的属性,这也是默认值。

例如,Java 编译器生成的类文件取决于 Java 源文件的文件名:重命名包含公共类的源文件将导致构建失败。尽管移动文件对编译结果没有影响,但对于增量编译,JavaCompile任务依赖相对路径来查找同一包中的其他类。因此,JavaCompile任务源的路径敏感性是RELATIVE。正因为如此,只有 Java 源文件的规范化(相对)路径才被视为JavaCompile任务的输入。

Java 编译器只遵守 Java 源文件中的包声明,而不遵守源文件的相对路径。因此,Java 源的路径敏感性是NAME_ONLY而不是RELATIVE

内容规范化

Java 的编译避免

对于JavaCompile任务的依赖项(即其编译类路径),只有这些依赖项的应用程序二进制接口(ABI)发生更改时才需要执行编译。Gradle 对编译类路径有深入的理解,并为其使用复杂的规范化策略。只要编译类路径上的类的 ABI 保持不变,任务输出就可以被重用。这使得 Gradle 能够通过增量构建避免 Java 编译,或者从由不同(但 ABI 兼容)版本依赖项生成的缓存中加载结果。有关编译避免的更多信息,请参阅相应部分

运行时类路径规范化

与编译避免类似,Gradle 也理解运行时类路径的概念,并使用定制的输入规范化来避免运行例如测试。对于运行时类路径,Gradle 检查 jar 文件的内容,并忽略 jar 文件中条目的时间戳和顺序。这意味着重新构建的 jar 文件将被视为相同的运行时类路径输入。有关 Gradle 在检测类路径更改方面具有的理解级别以及哪些被视为类路径的详细信息,请参阅此部分

过滤运行时类路径

对于运行时类路径,可以通过配置输入规范化来向 Gradle 提供哪些文件对于输入至关重要的更好见解。

假设您想在所有生成的 jar 文件中添加一个build-info.properties文件,其中包含关于构建的易变信息,例如构建开始时的时间戳或用于标识发布工件的 CI 作业 ID。此文件仅用于审计目的,对运行测试的结果没有影响。尽管如此,此文件仍是test任务运行时类路径的一部分。由于文件在每次构建调用时都会更改,因此测试无法有效缓存。要解决此问题,您可以通过在消费项目中的构建脚本中添加以下配置来忽略任何运行时类路径上的build-info.properties

build.gradle.kts
normalization {
    runtimeClasspath {
        ignore("build-info.properties")
    }
}
build.gradle
normalization {
    runtimeClasspath {
        ignore 'build-info.properties'
    }
}

如果您将此类文件添加到 jar 文件是您所有项目中都会做的事情,并且您希望为所有消费者过滤此文件,您可以将上述配置包装在根构建脚本的allprojects {}subprojects {}块中。

此配置的效果是,对build-info.properties的更改将同时被更新检查和任务输出缓存忽略。项目中所有任务的所有运行时类路径输入都将受到影响,其中此配置已完成。这将不会改变test任务的运行时行为——即,任何测试仍然能够加载build-info.properties,并且运行时类路径保持不变。

反对重叠输出的情况

当两个任务写入相同的输出目录或输出文件时,Gradle 很难确定哪个输出属于哪个任务。存在许多边缘情况,并且并行执行任务无法安全完成。出于同样的原因,Gradle 无法为这些任务删除过时输出文件。具有离散、不重叠输出的任务始终可以由 Gradle 以安全的方式处理。由于上述原因,任务输出缓存对于输出目录与另一个任务重叠的任务会自动禁用。

构建扫描在时间轴中显示了因输出重叠而禁用缓存的任务

overlapping outputs timeline

不同任务之间输出的重用

有些构建表现出令人惊讶的特性:即使在空缓存下执行,它们也会产生从缓存加载的任务。这怎么可能呢?请放心,这完全正常。

在考虑任务输出时,Gradle 只关心任务的输入:任务类型本身、输入文件和参数等,但它不关心任务的名称或它可以在哪个项目中找到。运行javac将产生相同的输出,无论调用它的JavaCompile任务的名称是什么。如果您的构建包含两个共享所有输入的任务,则后执行的任务将能够重用第一个任务产生的输出。

在同一个构建中存在两个执行相同操作的任务可能听起来像一个需要解决的问题,但这不一定是坏事。例如,Android 插件为项目的每个变体创建了几个任务;其中一些任务可能会做同样的事情。这些任务可以安全地重用彼此的输出。

前面讨论的,您可以使用 Develocity 来诊断这些意外缓存命中的源构建。

不可缓存的任务

您已经看到了很多关于可缓存任务的内容,这意味着也有不可缓存的任务。如果缓存任务输出听起来很棒,为什么不缓存每个任务呢?

有些任务绝对值得缓存:那些进行复杂、可重复处理并产生适量输出的任务。编译任务通常是缓存的理想选择。另一方面是 I/O 密集型任务,如CopySync。在本地移动文件通常不能通过从缓存复制来加速。缓存这些任务甚至会通过在缓存中存储所有这些冗余结果来浪费宝贵的资源。

大多数任务要么明显值得缓存,要么明显不值得。对于那些介于两者之间的任务,一个好的经验法则是看下载结果是否比在本地生成它们快得多。