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

构建缓存键

构建缓存中的工件通过 构建缓存键 进行唯一标识。在启用构建缓存的情况下运行时,会为每个可缓存的任务分配一个构建缓存键,该键用于将任务输出加载到构建缓存和从构建缓存中存储任务输出。以下输入会影响任务的构建缓存键

  • 任务实现

  • 任务操作实现

  • 输出属性的名称

  • 任务输入的名称和值

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

可重复的任务输出

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

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

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

在某些情况下,即使是信誉良好的工具也可能产生不可重复的输出,并导致级联效应。一个例子是 Oracle 的 Java 编译器,由于一个 bug,它会根据向其呈现的源文件顺序生成不同的字节码。如果您使用 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 能够通过使用增量构建来避免 Java 编译,或从缓存中加载由不同(但 ABI 兼容)版本的依赖项生成的結果。有关编译避免的更多信息,请参阅相应部分

运行时类路径规范化

与编译避免类似,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。在本地移动文件通常无法通过从缓存中复制它们来加速。缓存这些任务甚至会浪费宝贵的资源,因为将所有这些冗余结果存储在缓存中。

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