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

构建缓存键

构建缓存中的工件由构建缓存键唯一标识。当在启用构建缓存的情况下运行时,构建缓存键会分配给每个可缓存任务,并用于将任务输出加载和存储到构建缓存。以下输入有助于任务的构建缓存键:

  • 任务执行情况

  • 任务行动实施

  • 输出属性的名称

  • 任务输入的名称和值

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

可重复的任务输出

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

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

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

在某些情况下,即使是值得信赖的工具也可能产生不可重复的输出,并导致连锁效应。一个例子是 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.propertiesjar 清单属性中。无论哪种方式,依赖于此类数据的任何任务产生的输出只能由针对完全相同的提交运行的构建重复使用。

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

如果任务具有稳定的任务输入,则只能从缓存加载任务。不稳定的任务输入会导致任务为每个构建都有一组唯一的输入,这总是会导致缓存未命中。

通过输入规范化更好地重用

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

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

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

路径敏感性和可重定位性

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

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

例如,Java 编译器生成的类文件依赖于 Java 源文件的文件名:重命名其中包含公共类的源文件将使构建失败。尽管移动文件不会影响编译结果,但对于增量编译,任务JavaCompile依赖于相对路径来查找同一包中的其他类。因此,任务源的路径敏感性JavaCompileRELATIVE。因此,只有 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 {}or块中。subprojects {}

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

反对重叠输出的情况

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

构建扫描显示由于时间线中的输出重叠而禁用缓存的任务:

重叠的输出时间线

在不同任务之间重用输出

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

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

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

如前所述您可以使用 Develocity 来诊断这些意外缓存命中的源构建。

不可缓存的任务

您已经了解了很多有关可缓存任务的信息,这意味着也存在不可缓存的任务。如果缓存任务输出就像听起来那么棒,为什么不缓存每个任务呢?

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

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