构建中的小问题,例如忘记将配置文件声明为任务的输入,很容易被忽视。配置文件可能不经常更改,或者仅在某些其他(正确跟踪的)输入也更改时才更改。可能发生的最坏情况是您的任务在应该执行时不执行。开发人员始终可以使用 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 次要版本,您也可能由于某些本地编译的类文件(构成测试执行的输入)而导致开发人员缓存未命中。如果这些输出进入了此开发人员机器上的本地构建缓存,即使是 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 文件作为输入文件的任务都无法从缓存加载。当使用的构建不是干净构建或可缓存任务依赖于不可缓存任务的输出时,这可能会导致难以诊断的缓存未命中。例如,在执行增量构建时,磁盘上被视为最新的工件和构建缓存中的工件可能不同,即使它们本质上是相同的。依赖于此任务输出的任务然后将无法从构建缓存加载输出,因为输入不完全相同。

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

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 时,这可能会导致不正确的构建。