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

构建缓存键

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

  • 任务实现

  • 任务操作实现

  • 输出属性的名称

  • 任务输入的名称和值

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

可重复的任务输出

假设您有一个代码生成器任务作为构建的一部分。当您有一个完全最新的构建,并且您清理并重新运行同一代码库上的代码生成器任务时,它应该生成完全相同的输出,因此任何依赖于该输出的内容都将保持最新状态。

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

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

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

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

稳定的任务输入

如果任务的输入始终在变化,那么任务可重复地生成相同的输出是不够的。这种不稳定的输入可以直接提供给任务。考虑一个版本号,其中包含添加到 jar 文件清单的时间戳

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 清单中)。或者,也许您直接将提交哈希包含在 version.properties 或 jar 清单属性中。无论哪种方式,依赖于此类数据的任何任务产生的输出只能被针对完全相同提交运行的构建重用。

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

只有当任务具有稳定的任务输入时,才能从缓存加载任务。不稳定的任务输入会导致任务对于每次构建都具有一组唯一的输入,这将始终导致缓存未命中。

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

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

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

当涉及到文件输入时,Gradle 可以规范化文件的路径及其内容。

路径敏感性和可重定位性

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

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

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

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

内容规范化

Java 的编译避免

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

运行时类路径规范化

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

过滤运行时类路径

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

假设您想将文件 build-info.properties 添加到所有生成的 jar 文件中,其中包含有关构建的易失信息,例如构建开始的时间戳或用于标识发布工件的 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。在本地移动文件通常无法通过从缓存复制它们来加速。缓存这些任务甚至会浪费良好的资源,将所有这些冗余结果存储在缓存中。

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