构建中的一些小问题,比如忘记将配置文件声明为任务的输入,很容易被忽视。配置文件可能很少更改,或者只在某些其他(正确跟踪的)输入更改时才会更改。最糟糕的情况是,您的任务在应该执行时没有执行。开发人员始终可以使用 `clean` 重新运行构建,并以缓慢重建为代价“修复”他们的构建。最终,没有人会被阻塞在他们的工作中,而事件会被归咎于“Gradle 又在搞事情”。

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

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

系统文件编码

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

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

通过确保编码在所有构建中保持一致,为 Gradle 守护进程设置文件编码可以缓解上述两个问题。您可以在您的 `gradle.properties` 中执行此操作

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

环境变量跟踪

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

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

例如,如果您在名为 integTestTest 任务中使用依赖于 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 次要版本,您也可能会因为某些本地编译的类文件(构成测试执行的输入)而导致开发人员出现缓存未命中。如果这些输出进入了此开发人员机器上的本地构建缓存,即使是清理也无法解决这种情况。因此,跟踪 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)
        }
    }
}

do

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 文件在本地重建时。这会导致难以诊断的缓存未命中,当使用构建不是干净构建或可缓存任务依赖于不可缓存任务的输出时。例如,在进行增量构建时,磁盘上的被认为是最新的工件和构建缓存中的工件可能不同,即使它们本质上是相同的。依赖于此任务输出的任务将无法从构建缓存中加载输出,因为输入不完全相同。

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

Gradle 包含一些支持为存档任务创建可重复输出的功能。对于 tar 和 zip 文件,Gradle 可以配置为创建可重现的存档。这是通过以下代码段配置例如 Zip 任务来完成的。

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

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

限制易变数据的影响

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

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

自定义和第三方任务

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

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