您的构建有多少内容从缓存加载取决于许多因素。本节将介绍一些对于良好缓存构建至关重要的工具。构建扫描是该工具链的一部分,并将在本指南中全程使用。
构建缓存键
构建缓存中的制品通过构建缓存键唯一标识。当启用构建缓存运行时,每个可缓存任务都会被分配一个构建缓存键,该键用于将任务输出加载和存储到构建缓存中。以下输入项有助于生成任务的构建缓存键:
-
任务实现
-
任务操作实现
-
输出属性的名称
-
任务输入的名称和值
如果两个任务关联的构建缓存键相同,则可以通过使用构建缓存来复用它们的输出。
可重复任务输出
假设您的构建中有一个代码生成器任务。当您的构建完全最新,并且您在相同的代码库上清理并重新运行代码生成器任务时,它应该生成完全相同的输出,这样任何依赖于该输出的内容都将保持最新。
您的代码生成器也可能在其输出中添加一些不依赖于其声明输入项的额外信息,例如时间戳。在这种情况下,重新执行任务将导致生成不同的代码(因为时间戳会更新)。依赖于代码生成器输出的任务将需要重新执行。
当一个任务是可缓存的,任务输出缓存的本质确保该任务对于给定的输入集将拥有相同的输出。因此,可缓存任务应具有可重复的任务输出。如果不是,那么执行任务和从缓存加载任务的结果可能会不同,这可能导致难以诊断的缓存未命中。
在某些情况下,即使是高度可信的工具也可能产生不可重复的输出,并导致级联效应。一个例子是 Oracle 的 Java 编译器,由于一个 bug,它会根据提供给它的源文件编译顺序生成不同的字节码。如果您使用的是 Oracle JDK 8u31 或更早版本来编译 buildSrc
子项目中的代码,这可能导致您的所有自定义任务偶尔出现缓存未命中,原因是它们的类路径存在差异(其中包括 buildSrc
)。
这里的关键是,可缓存任务不应使用不可重复的任务输出作为输入。
稳定任务输入
如果任务的输入一直在变化,即使它能够可重复地产生相同的输出也是不够的。这些不稳定的输入可以直接提供给任务。考虑一个包含时间戳并添加到 jar 文件清单中的版本号:
version = "3.2-${System.currentTimeMillis()}"
tasks.jar {
manifest {
attributes(mapOf("Implementation-Version" to project.version))
}
}
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 编译器生成的 class 文件依赖于 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
文件:
normalization {
runtimeClasspath {
ignore("build-info.properties")
}
}
normalization {
runtimeClasspath {
ignore 'build-info.properties'
}
}
如果向 jar 文件添加此类文件是您在构建中的所有项目都会做的事情,并且您希望为所有消费者过滤此文件,您可以将上述配置包装在根构建脚本的 allprojects {}
或 subprojects {}
块中。
此配置的效果是,对 build-info.properties
的更改将被忽略,无论是对于最新检查还是任务输出缓存。此配置所在项目的所有任务的所有运行时类路径输入都将受到影响。这不会改变 test
任务的运行时行为——即任何测试仍然能够加载 build-info.properties
,并且运行时类路径保持与之前相同。
反对输出重叠的理由
当两个任务写入同一个输出目录或输出文件时,Gradle 很难确定哪个输出属于哪个任务。存在许多边缘情况,并行执行这些任务不安全。出于同样的原因,Gradle 无法为这些任务移除陈旧的输出文件。具有离散、不重叠输出的任务始终可以由 Gradle 以安全的方式处理。由于上述原因,对于输出目录与另一个任务重叠的任务,任务输出缓存会自动禁用。
构建扫描在时间轴中显示因输出重叠而禁用缓存的任务

不同任务之间的输出复用
一些构建表现出令人惊讶的特性:即使在空缓存上执行,它们也能产生从缓存加载的任务。这怎么可能?请放心,这是完全正常的。
在考虑任务输出时,Gradle 只关心任务的输入:任务类型本身、输入文件和参数等,但不关心任务的名称或它可以在哪个项目中找到。无论调用它的 JavaCompile
任务的名称是什么,运行 javac
都将产生相同的输出。如果您的构建包含两个共享所有输入的任务,后执行的任务将能够复用第一个任务产生的输出。
在同一个构建中有两个执行相同操作的任务听起来可能像一个需要解决的问题,但这并不一定是一件坏事。例如,Android 插件为项目的每个变体创建多个任务;其中一些任务可能会执行相同的事情。这些任务可以安全地复用彼此的输出。
如之前讨论的,您可以使用 Develocity 来诊断这些意外缓存命中的来源构建。
不可缓存的任务
您已经了解了许多关于可缓存任务的内容,这意味着也存在不可缓存的任务。如果缓存任务输出听起来如此棒,为什么不缓存所有任务呢?
有些任务绝对值得缓存:执行复杂、可重复处理并产生适量输出的任务。编译任务通常是理想的缓存候选项。另一方面是 I/O 密集型任务,例如 Copy
和 Sync
。在本地移动文件通常无法通过从缓存复制来加速。缓存这些任务甚至会通过在缓存中存储所有冗余结果来浪费宝贵的资源。
大多数任务要么明显值得缓存,要么明显不值得。对于介于两者之间的任务,一个好的经验法则是看看下载结果是否会比在本地生成它们快得多。