构建中的小问题,例如忘记声明配置文件作为任务的输入,很容易被忽视。配置文件可能很少更改,或者只在其他(正确追踪的)输入也更改时才更改。最坏的情况是任务没有在应该执行时执行。开发人员总是可以通过运行 clean 来重新运行构建,并以缓慢的重新构建为代价“修复”他们的构建。最终没有人被工作阻塞,事件被归结为“Gradle 又出问题了”。

对于可缓存任务,不正确的结果会永久存储,并且以后可能会困扰您;在这种情况下,即使重新运行 clean 也无济于事。当使用共享缓存时,这些问题甚至会跨越机器边界。在上面的示例中,Gradle 最终可能会加载您的任务的结果,该结果是使用不同的配置生成的。因此,当启用任务输出缓存时,解决这些构建问题变得更加重要。

构建中的其他问题不会导致它产生不正确的结果,但会导致不必要的缓存未命中。在本章中,您将了解一些典型问题以及避免它们的方法。解决这些问题还将带来额外的好处,即您的构建将停止“出问题”,并且开发人员可以完全忘记使用 clean 运行构建。

系统文件编码

大多数 Java 工具在未指定特定编码时使用系统文件编码。这意味着在具有不同文件编码的机器上运行相同的构建可能会产生不同的输出。目前 Gradle 仅在每个任务的基础上追踪未指定文件编码的情况,但它不追踪所用 JVM 的系统编码。这可能导致不正确的构建。您应该始终设置文件系统编码以避免此类问题。

构建脚本是使用 Gradle 守护进程的文件编码编译的。默认情况下,守护进程也使用系统文件编码。

为 Gradle 守护进程设置文件编码可以通过确保编码在不同构建之间相同来缓解上述两个问题。您可以在 gradle.properties 中进行设置。

gradle.properties
org.gradle.jvmargs=-Dfile.encoding=UTF-8

环境变量追踪

Gradle 不追踪任务的环境变量变化。例如,对于 Test 任务,结果完全可能取决于一些环境变量。为了确保构建之间只重用正确的工件,您需要将环境变量作为输入添加到依赖它们的任务中。

绝对路径也经常作为环境变量传递。在这种情况下,您需要注意将什么作为输入添加到任务中。您需要确保绝对路径在不同机器之间是相同的。大多数时候,追踪文件或绝对路径指向的目录内容是有意义的。如果绝对路径表示正在使用的工具,那么追踪工具版本作为输入可能更有意义。

例如,如果您在 Test 任务中使用名为 integTest 的工具,并且这些工具依赖于 LANG 变量的内容,您应该这样做:

build.gradle.kts
tasks.integTest {
    inputs.property("langEnvironment") {
        System.getenv("LANG")
    }
}
build.gradle
tasks.named('integTest') {
    inputs.property("langEnvironment") {
        System.getenv("LANG")
    }
}

如果您添加条件逻辑来区分 CI 构建和本地开发构建,您必须确保这不会破坏将 CI 的任务输出加载到开发人员机器上。例如,以下设置将破坏 Test 任务的缓存,因为 Gradle 总是会检测到自定义任务操作中的差异。

build.gradle.kts
if ("CI" in System.getenv()) {
    tasks.withType<Test>().configureEach {
        doFirst {
            println("Running test on CI")
        }
    }
}
build.gradle
if (System.getenv().containsKey("CI")) {
    tasks.withType(Test).configureEach {
        doFirst {
            println "Running test on CI"
        }
    }
}

您应该始终无条件地添加操作

build.gradle.kts
tasks.withType<Test>().configureEach {
    doFirst {
        if ("CI" in System.getenv()) {
            println("Running test on CI")
        }
    }
}
build.gradle
tasks.withType(Test).configureEach {
    doFirst {
        if (System.getenv().containsKey("CI")) {
            println "Running test on CI"
        }
    }
}

这样,任务在 CI 和开发人员构建中具有相同的自定义操作,并且如果其余输入相同,则可以重用其输出。

行尾符

如果您在不同的操作系统上构建,请注意某些版本控制系统在检出时会转换行尾符。例如,Windows 上的 Git 默认使用 autocrlf=true,它将所有行尾符转换为 \r\n。因此,编译输出无法在 Windows 上重复使用,因为输入源不同。如果共享构建缓存跨多个操作系统在您的环境中很重要,那么在您的构建机器上设置 autocrlf=false 对于优化构建缓存使用至关重要。

使用符号链接时,Gradle 不会将链接存储在构建缓存中,而是存储链接目标的实际文件内容。因此,当您尝试重用大量使用符号链接的输出时,可能会遇到困难。目前没有针对此行为的解决方案。

对于支持符号链接的操作系统,符号链接目标的内容将作为输入添加。如果操作系统不支持符号链接,则实际的符号链接文件将作为输入添加。因此,将符号链接作为输入文件的任务(例如 Test 任务将其运行时类路径的一部分作为符号链接)将不会在 Windows 和 Linux 之间缓存。如果需要跨操作系统缓存,则不应将符号链接检入版本控制。

Java 版本追踪

Gradle 仅将 Java 的主要版本作为编译和测试执行的输入进行追踪。目前,它**不**追踪供应商和次要版本。然而,供应商和次要版本可能会影响编译产生的字节码。

如果您正在使用Java 工具链,Java 主要版本、供应商(如果指定)和实现(如果指定)将自动作为编译和测试执行的输入进行追踪。

如果您使用不同的 JVM 供应商来编译或运行 Java,我们强烈建议您将供应商作为输入添加到相应的任务中。这可以通过使用运行时 API 来实现,如下面的片段所示。

build.gradle.kts
tasks.withType<AbstractCompile>().configureEach {
    inputs.property("java.vendor") {
        System.getProperty("java.vendor")
    }
}

tasks.withType<Test>().configureEach {
    inputs.property("java.vendor") {
        System.getProperty("java.vendor")
    }
}
build.gradle
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 次要版本,开发人员也可能因为一些构成测试执行输入的本地编译类文件而出现缓存未命中。如果这些输出进入了该开发人员机器上的本地构建缓存,即使是 clean 也无法解决这种情况。因此,追踪 Java 次要版本的选择是在不同 Java 次要版本之间有时或从不重用测试执行输出之间做出选择。

用于运行 Gradle 的 JVM 提供的编译器基础设施也被 Groovy 编译器使用。因此,您可以预期由于上述相同原因,编译后的 Groovy 类的字节码会存在差异,并且适用相同的建议。

避免更改构建外部输入

如果您的构建依赖于外部依赖项,如二进制工件或来自网页的动态数据,则需要确保这些输入在整个基础设施中保持一致。机器之间的任何差异都将导致缓存未命中。

切勿重新发布版本号相同但内容不同的不变二进制依赖项:如果插件依赖项发生这种情况,您将永远无法解释为什么机器之间看不到缓存重用(这是因为它们具有该工件的不同版本)。

在您的构建中使用 SNAPSHOT 或其他动态依赖项从设计上就违反了稳定任务输入原则。为了有效使用构建缓存,您应该依赖固定依赖项。您可能需要考虑依赖锁定或转而使用复合构建

依赖于不稳定的外部资源(例如已发布版本的列表)也是如此。锁定更改的一种方法是在每次更改时将不稳定资源检入源代码控制,以便构建仅依赖于源代码控制中的状态,而不依赖于不稳定资源本身。

构建编写建议

审查 doFirstdoLast 的用法

在可缓存任务上从构建脚本中使用 doFirstdoLast 会将您与构建脚本更改绑定,因为闭包的实现来自构建脚本。如果可能,您应该使用单独的任务。

不建议通过 doFirst 中的运行时 API 修改输入或输出属性,因为这些更改不会被检测到以进行最新检查和构建缓存。更糟糕的是,当任务不执行时,任务的配置实际上与执行时不同。与其使用 doFirst 修改输入,不如考虑使用单独的任务来配置所讨论的任务——所谓的配置任务。例如,与其这样做:

build.gradle.kts
tasks.jar {
    val runtimeClasspath: FileCollection = configurations.runtimeClasspath.get()
    doFirst {
        manifest {
            val classPath = runtimeClasspath.map { it.name }.joinToString(" ")
            attributes("Class-Path" to classPath)
        }
    }
}
build.gradle
tasks.named('jar') {
    FileCollection runtimeClasspath = configurations.runtimeClasspath
    doFirst {
        manifest {
            def classPath = runtimeClasspath.collect { it.name }.join(" ")
            attributes('Class-Path': classPath)
        }
    }
}

执行

build.gradle.kts
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) }
build.gradle
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 将在时间线和任务输入比较中显示因重叠输出而禁用缓存的任务

overlapping outputs input comparison

实现稳定的任务输入

对于每个可缓存任务来说,拥有稳定的任务输入至关重要。在以下部分中,您将了解违反稳定任务输入的不同情况并探讨可能的解决方案。

易失性任务输入

如果您使用像时间戳这样的易失性输入作为任务的输入属性,那么 Gradle 无法使任务可缓存。您应该认真思考易失性数据是否对输出真的至关重要,或者它是否只是出于例如审计目的而存在。

如果易失性输入对输出至关重要,那么您可以尝试使使用易失性输入的任务执行成本更低。您可以通过将任务拆分为两个任务来实现此目的——第一个任务执行昂贵的可缓存工作,第二个任务将易失性数据添加到输出中。通过这种方式,输出保持不变,并且可以使用构建缓存来避免执行昂贵的工作。例如,对于构建 jar 文件,昂贵的部分——Java 编译——已经是不同的任务,而 jar 任务本身(不可缓存)则很便宜。

如果它不是输出的必要部分,那么您不应该将其声明为输入。只要易失性输入不影响输出,就没有其他可做的。但大多数时候,输入将是输出的一部分。

不可重复的任务输出

正如可重复的任务输出中所述,具有为相同输入生成不同输出的任务可能会对任务输出缓存的有效使用构成挑战。如果不可重复的任务输出未被任何其他任务使用,则影响非常有限。它基本上意味着从缓存加载任务可能会产生与本地执行相同任务不同的结果。如果输出之间唯一的区别是时间戳,那么您可以接受构建缓存的效果,或者决定该任务毕竟不可缓存。

一旦另一个任务依赖于不可重复的输出,不可重复的任务输出就会导致不稳定的任务输入。例如,从内容相同但修改时间不同的文件重新创建 jar 文件会产生不同的 jar 文件。任何依赖此 jar 文件作为输入文件的其他任务,当 jar 文件在本地重建时,都无法从缓存中加载。这可能导致难以诊断的缓存未命中,当消费构建不是 clean 构建时,或者当可缓存任务依赖于不可缓存任务的输出时。例如,当执行增量构建时,磁盘上被认为是最新状态的工件和构建缓存中的工件可能不同,即使它们本质上是相同的。依赖此任务输出的任务将无法从构建缓存加载输出,因为输入不完全相同。

稳定任务输入部分所述,您可以使任务输出可重复或使用输入规范化。您已经了解了可配置输入规范化的可能性。

从 Gradle 9.0 开始,生成归档(tarzip)的任务默认是可重现的。对于早期 Gradle 版本或如果您想要显式配置,您可以按以下方式配置任务以使归档可重现:

build.gradle.kts
tasks.register<Zip>("createZip") {
    isPreserveFileTimestamps = false
    isReproducibleFileOrder = true
    dirPermissions { unix("755") }
    filePermissions { unix("644") }
    // ...
}
build.gradle
tasks.register('createZip', Zip) {
    preserveFileTimestamps = false
    reproducibleFileOrder = true
    dirPermissions { unix("755") }
    filePermissions { unix("644") }
    // ...
}

使输出可重复的另一种方法是为具有不可重复输出的任务激活缓存。如果您能确保所有构建都使用相同的构建缓存,那么由于构建缓存的设计,该任务将始终为相同的输入提供相同的输出。走这条路可能会导致增量构建中出现不同的缓存未命中问题,如上所述。此外,不同构建之间并行尝试将相同输出存储在构建缓存中的竞态条件可能导致难以诊断的缓存未命中。如果可能,您应该避免走这条路。

限制易失性数据的影响

如果上述处理易失性数据的解决方案对您不起作用,您仍然应该能够限制易失性数据对构建缓存有效使用的影响。这可以通过稍后将易失性数据添加到输出中来实现,如易失性任务输入部分所述。另一个选择是移动易失性数据,使其影响更少的任务。例如,将依赖项从 compile 移动到 runtime 配置可能已经产生相当大的影响。

有时,也可以构建两个工件,一个包含易失数据,另一个包含易失数据的常量表示。非易失输出将用于例如测试,而易失输出将发布到外部仓库。虽然这与持续交付的“一次构建工件”原则相冲突,但有时它可能是唯一的选择。

自定义和第三方任务

如果您的构建包含自定义或第三方任务,您应该特别注意它们不会影响构建缓存的有效性。对于代码生成任务也应特别小心,它们可能没有可重复的任务输出。如果代码生成器在生成的文件中包含例如时间戳或依赖于输入文件的顺序,则可能会发生这种情况。其他陷阱可能是任务代码中使用 HashMap 或其他没有顺序保证的数据结构。

一些第三方插件甚至可以影响 Gradle 内置任务的可缓存性。如果它们通过运行时 API 向任务添加绝对路径或易失数据等输入,则可能会发生这种情况。在最坏的情况下,当插件尝试依赖任务的结果并且不考虑 FROM-CACHE 时,这可能导致不正确的构建。