构建中的小问题(例如忘记声明配置文件作为任务的输入)很容易被忽视。配置文件可能很少更改,或者仅在其他某些(正确跟踪的)输入也发生更改时才更改。最糟糕的情况是您的任务没有在应该执行的时候执行。开发人员始终可以使用 重新运行构建clean
,并以缓慢重建的代价“修复”其构建。最终,没有人在工作中受到阻碍,这一事件被归结为“Gradle 又出事了”。
对于可缓存的任务,错误的结果会被永久存储,并且以后可能会再次困扰您;在这种情况下重新运行clean
也无济于事。当使用共享缓存时,这些问题甚至跨越机器边界。在上面的示例中,Gradle 最终可能会加载使用不同配置生成的任务结果。因此,当启用任务输出缓存时,解决构建中的这些问题变得更加重要。
构建的其他问题不会导致它产生不正确的结果,但会导致不必要的缓存未命中。在本章中,您将了解一些典型问题以及避免这些问题的方法。解决这些问题将带来额外的好处,即您的构建将停止“运行”,并且开发人员可以完全忘记运行构建clean
。
系统文件编码
当未指定特定编码时,大多数 Java 工具都会使用系统文件编码。这意味着在具有不同文件编码的计算机上运行相同的版本可能会产生不同的输出。目前,Gradle 仅在每个任务的基础上跟踪未指定文件编码的情况,但它不会跟踪正在使用的 JVM 的系统编码。这可能会导致错误的构建。您应该始终设置文件系统编码以避免此类问题。
构建脚本使用 Gradle 守护程序的文件编码进行编译。默认情况下,守护程序也使用系统文件编码。 |
为 Gradle 守护进程设置文件编码可以确保跨构建的编码相同,从而缓解上述两个问题。您可以在您的gradle.properties
:
org.gradle.jvmargs=-Dfile.encoding=UTF-8
环境变量跟踪
Gradle 不跟踪任务环境变量的更改。例如,对于Test
任务,结果完全有可能取决于一些环境变量。为了确保在构建之间只重用正确的工件,您需要根据环境变量添加环境变量作为任务的输入。
绝对路径通常也作为环境变量传递。在这种情况下,您需要注意添加为任务输入的内容。您需要确保机器之间的绝对路径相同。大多数时候,跟踪绝对路径指向的文件或目录的内容是有意义的。如果绝对路径表示正在使用的工具,则将工具版本作为输入进行跟踪可能是有意义的。
例如,如果您在Test
名为的任务中使用integTest
依赖于变量内容的工具LANG
,您应该执行以下操作:
tasks.integTest {
inputs.property("langEnvironment") {
System.getenv("LANG")
}
}
tasks.named('integTest') {
inputs.property("langEnvironment") {
System.getenv("LANG")
}
}
如果添加条件逻辑来区分 CI 构建和本地开发构建,则必须确保这不会中断将任务输出从 CI 加载到开发人员计算机上。例如,以下设置将破坏Test
任务缓存,因为 Gradle 始终检测自定义任务操作中的差异。
if ("CI" in System.getenv()) {
tasks.withType<Test>().configureEach {
doFirst {
println("Running test on CI")
}
}
}
if (System.getenv().containsKey("CI")) {
tasks.withType(Test).configureEach {
doFirst {
println "Running test on CI"
}
}
}
您应该始终无条件添加操作:
tasks.withType<Test>().configureEach {
doFirst {
if ("CI" in System.getenv()) {
println("Running test on CI")
}
}
}
tasks.withType(Test).configureEach {
doFirst {
if (System.getenv().containsKey("CI")) {
println "Running test on CI"
}
}
}
这样,该任务在 CI 和开发人员构建上具有相同的自定义操作,并且如果其余输入相同,则可以重复使用其输出。
行结尾
如果您在不同的操作系统上进行构建,请注意某些版本控制系统会在签出时转换行结尾。例如,Windows 上的 Gitautocrlf=true
默认使用将所有行结尾转换为\r\n
.因此,由于输入源不同,编译输出无法在 Windows 上重复使用。如果在您的环境中跨多个操作系统共享构建缓存很重要,那么autocrlf=false
跨构建计算机的设置对于优化构建缓存使用至关重要。
符号链接
使用符号链接时,Gradle 不会将链接存储在构建缓存中,而是存储链接目标的实际文件内容。因此,在尝试重用大量使用符号链接的输出时,您可能会遇到困难。目前没有针对此行为的解决方法。
对于支持符号链接的操作系统,符号链接的目标内容将作为输入添加。如果操作系统不支持符号链接,则将添加实际的符号链接文件作为输入。因此,将符号链接作为输入文件的任务(例如,Test
将符号链接作为其运行时类路径的一部分的任务)将不会在Windows 和Linux 之间进行缓存。如果需要在操作系统之间进行缓存,则不应将符号链接签入版本控制。
Java版本跟踪
Gradle 仅跟踪 Java 的主要版本作为编译和测试执行的输入。目前,它不跟踪供应商或次要版本。尽管如此,供应商和次要版本可能会影响编译生成的字节码。
如果您使用Java Toolchains,Java 主要版本、供应商(如果指定)和实现(如果指定)将被自动跟踪,作为编译和测试执行的输入。 |
如果您使用不同的 JVM 供应商来编译或运行 Java,我们强烈建议您将供应商添加为相应任务的输入。这可以通过使用运行时 API来实现,如以下代码片段所示。
tasks.withType<AbstractCompile>().configureEach {
inputs.property("java.vendor") {
System.getProperty("java.vendor")
}
}
tasks.withType<Test>().configureEach {
inputs.property("java.vendor") {
System.getProperty("java.vendor")
}
}
tasks.withType(AbstractCompile).configureEach {
inputs.property("java.vendor") {
System.getProperty("java.vendor")
}
}
tasks.withType(Test).configureEach {
inputs.property("java.vendor") {
System.getProperty("java.vendor")
}
}
关于跟踪 Java 次要版本,存在不同的竞争方面:开发人员在 CI 上具有缓存命中和“完美”结果。基本上有两种情况需要跟踪 Java 的次要版本:编译版本和运行时版本。在编译的情况下,不同次要版本生成的字节码有时可能存在差异。但是,字节码仍应导致相同的运行时行为。
Java 编译避免将同样对待该字节码,因为它提取了 ABI。 |
将次要编号视为输入可以降低开发人员构建缓存命中的可能性。根据您团队中标准开发环境的情况,使用许多不同的 Java 次要版本是很常见的。
即使不跟踪 Java 次要版本,由于一些本地编译的类文件构成测试执行的输入,开发人员也可能会遇到缓存未命中的情况。如果这些输出已进入该开发人员计算机上的本地构建缓存,则即使清理也无法解决问题。因此,跟踪 Java 次要版本的选择是有时或从不重复使用不同 Java 次要版本之间的输出来执行测试。
Groovy 编译器也使用用于运行 Gradle 的 JVM 提供的编译器基础设施。因此,出于与上述相同的原因,您可以预期已编译的 Groovy 类的字节码会存在差异,并且适用相同的建议。 |
避免更改构建外部的输入
如果您的构建依赖于外部依赖项(例如二进制工件或网页中的动态数据),您需要确保这些输入在整个基础架构中保持一致。机器之间的任何变化都会导致缓存未命中。
永远不要重新发布具有相同版本号但不同内容的不变二进制依赖项:如果插件依赖项发生这种情况,您将永远无法解释为什么您看不到机器之间的缓存重用(这是因为它们具有不同的缓存重用)该工件的版本)。
对于依赖易失性外部资源(例如已发布版本的列表)也是如此。锁定更改的一种方法是,只要易失性资源发生更改,就将其检入源代码管理中,以便构建仅依赖于源代码管理中的状态,而不依赖于易失性资源本身。
创作构建的建议
回顾doFirst
和的用法doLast
在可缓存任务上使用构建脚本中的doFirst
and会将您与构建脚本更改联系起来,因为闭包的实现来自构建脚本。doLast
如果可能,您应该使用单独的任务。
不鼓励通过运行时 API 修改输入或输出属性,doFirst
因为最新检查和构建缓存不会检测到这些更改。更糟糕的是,当任务没有执行时,任务的配置实际上与执行时不同。不要使用doFirst
修改输入,而是考虑使用单独的任务来配置所讨论的任务 - 所谓的配置任务。例如,而不是做
tasks.jar {
val runtimeClasspath: FileCollection = configurations.runtimeClasspath.get()
doFirst {
manifest {
val classPath = runtimeClasspath.map { it.name }.joinToString(" ")
attributes("Class-Path" to classPath)
}
}
}
tasks.named('jar') {
FileCollection runtimeClasspath = configurations.runtimeClasspath
doFirst {
manifest {
def classPath = runtimeClasspath.collect { it.name }.join(" ")
attributes('Class-Path': classPath)
}
}
}
做
val configureJar = tasks.register("configureJar") {
doLast {
tasks.jar.get().manifest {
val classPath = configurations.runtimeClasspath.get().map { it.name }.joinToString(" ")
attributes("Class-Path" to classPath)
}
}
}
tasks.jar { dependsOn(configureJar) }
def configureJar = tasks.register('configureJar') {
doLast {
tasks.jar.manifest {
def classPath = configurations.runtimeClasspath.collect { it.name }.join(" ")
attributes('Class-Path': classPath)
}
}
}
tasks.named('jar') { dependsOn(configureJar) }
请注意,使用配置缓存 时不支持从其他任务配置任务。 |
根据任务的结果构建逻辑
不要将构建逻辑建立在任务是否已执行的基础上。特别是,您不应该假设任务的输出只有在实际执行时才能更改。实际上,从构建缓存加载输出也会改变它们。您应该利用 Gradle 的内置支持,声明任务的正确输入和输出,并让 Gradle 来决定是否应执行任务操作,而不是依赖自定义逻辑来处理输入或输出文件的更改。出于同样的原因,outputs.upToDateWhen
不鼓励使用,应该通过正确声明任务的输入来代替。
重叠输出
您已经看到重叠输出是任务输出缓存的一个问题。当您向构建添加新任务或重新配置内置任务时,请确保不会为可缓存任务创建重叠的输出。如果必须,您可以添加一个Sync
任务,然后将合并的输出同步到目标目录,同时原始任务保持可缓存。
Develocity 将显示在时间线和任务输入比较中为重叠输出禁用缓存的任务:
实现稳定的任务输入
对于每个可缓存任务来说,拥有稳定的任务输入至关重要。在下一节中,您将了解违反稳定任务输入的不同情况,并查看可能的解决方案。
不稳定的任务输入
如果您使用时间戳等易失性输入作为任务的输入属性,则 Gradle 无法执行任何操作来使任务可缓存。您应该认真思考易失性数据是否对输出确实至关重要,或者是否仅用于审计目的。
如果易失性输入对于输出至关重要,那么您可以尝试使使用易失性输入的任务执行起来更便宜。您可以通过将任务拆分为两个任务来完成此操作 - 第一个任务执行可缓存的昂贵工作,第二个任务将易失性数据添加到输出。通过这种方式,输出保持不变,并且可以使用构建缓存来避免执行昂贵的工作。例如,对于构建 jar 文件,昂贵的部分 - Java 编译 - 已经是一个不同的任务,而 jar 任务本身(不可缓存)很便宜。
如果它不是输出的重要部分,则不应将其声明为输入。只要易失性输入不影响输出,就无需执行其他操作。但大多数时候,输入将是输出的一部分。
不可重复的任务输出
对于相同输入生成不同输出的任务可能会对有效使用任务输出缓存构成挑战,如可重复任务输出中所示。如果不可重复任务的输出不被任何其他任务使用,则效果非常有限。这基本上意味着从缓存加载任务可能会产生与在本地执行相同任务不同的结果。如果输出之间的唯一区别是时间戳,那么您可以接受构建缓存的效果,或者决定该任务毕竟不可缓存。
一旦另一个任务依赖于不可重复的输出,不可重复的任务输出就会导致不稳定的任务输入。例如,从具有相同内容但不同修改时间的文件重新创建 jar 文件会产生不同的 jar 文件。在本地重建 jar 文件时,无法从缓存中加载依赖于此 jar 文件作为输入文件的任何其他任务。当使用的构建不是干净的构建或者可缓存任务依赖于不可缓存任务的输出时,这可能会导致难以诊断的缓存未命中。例如,在进行增量构建时,磁盘上被认为是最新的工件和构建缓存中的工件可能不同,即使它们本质上是相同的。依赖于此任务输出的任务将无法从构建缓存加载输出,因为输入不完全相同。
Gradle 支持为归档任务创建可重复的输出。对于 tar 和 zip 文件,Gradle 可以配置为创建可重现的存档。这是Zip
通过以下代码片段配置任务来完成的。
tasks.register<Zip>("createZip") {
isPreserveFileTimestamps = false
isReproducibleFileOrder = true
// ...
}
tasks.register('createZip', Zip) {
preserveFileTimestamps = false
reproducibleFileOrder = true
// ...
}
使输出可重复的另一种方法是激活具有不可重复输出的任务的缓存。如果您可以确保所有构建使用相同的构建缓存,那么通过构建缓存的设计,任务将始终针对相同的输入具有相同的输出。沿着这条路走可能会导致如上所述的增量构建的缓存未命中的不同问题。此外,尝试在构建缓存中并行存储相同输出的不同构建之间的竞争条件可能会导致难以诊断的缓存未命中。如果可能的话,您应该避免走那条路。