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

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

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

系统文件编码

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

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

设置 Gradle daemon 的文件编码可以通过确保跨构建的编码一致来缓解上述两个问题。你可以在 gradle.properties 文件中这样做。

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

环境变量跟踪

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

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

例如,如果你在名为 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 不会将链接本身存储在构建缓存中,而是存储链接目标的实际文件内容。因此,当你尝试重用大量使用符号链接的输出时,可能会遇到困难。目前对此行为没有变通方法。

对于支持符号链接的操作系统,符号链接目标的内容将被添加为输入。如果操作系统不支持符号链接,则实际的符号链接文件将被添加为输入。因此,将符号链接作为输入文件的任务(例如运行时 classpath 中包含符号链接的 Test 任务)将无法在 Windows 和 Linux 之间缓存。如果需要在操作系统之间进行缓存,则不应将符号链接提交到版本控制中。

Java 版本跟踪

Gradle 仅跟踪 Java 的主版本作为编译和测试执行的输入。目前,它 跟踪供应商或次版本。然而,供应商和次版本可能会影响编译生成的字节码。

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

如果你使用不同的 JVM 供应商来编译或运行 Java,我们强烈建议你将供应商添加为相应任务的输入。这可以通过使用 runtime 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 有时会产生不同的字节码。但是,字节码仍然应该导致相同的运行时行为。

Java 编译避免 将会同样处理此字节码,因为它提取了 ABI。

将次版本号作为输入会降低开发者构建的缓存命中率。根据你的团队开发环境的标准化程度,通常会使用许多不同的 Java 次版本。

即使不跟踪 Java 次版本,开发者也可能因为构成测试执行输入的某些本地编译的类文件而导致缓存未命中。如果这些输出进入了开发者机器的本地构建缓存,即使 clean 也无法解决问题。因此,跟踪 Java 次版本的选择是在测试执行时有时或永不重用不同 Java 次版本之间的输出。

用于运行 Gradle 的 JVM 提供的编译器基础架构也用于 Groovy 编译器。因此,你可以预期编译的 Groovy 类的字节码会因上述相同原因而存在差异,并且相同的建议也适用。

避免更改构建外部的输入

如果你的构建依赖于外部依赖项,例如二进制 artifact 或网页中的动态数据,你需要确保这些输入在整个基础架构中保持一致。跨机器的任何变化都会导致缓存未命中。

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

在构建中设计上使用 SNAPSHOT 或其他可变依赖项违反了 稳定任务输入 原则。为了有效利用构建缓存,你应该依赖于固定依赖项。你可以考虑 依赖锁定 或改用 复合构建

依赖易变的外部资源(例如已发布版本列表)也是如此。锁定更改的一种方法是在易变资源更改时将其提交到源代码控制中,这样构建只依赖于源代码控制中的状态,而不是易变资源本身。

编写构建的建议

审查 doFirstdoLast 的用法

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

不鼓励在 doFirst 中通过 runtime API 修改输入或输出属性,因为这些更改无法被 up-to-date 检查和构建缓存检测到。更糟糕的是,当任务不执行时,任务的配置实际上与执行时不同。与其使用 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 构建,或者当可缓存任务依赖于不可缓存任务的输出时。例如,在执行增量构建时,磁盘上被认为是 up-to-date 的 artifact 和构建缓存中的 artifact 可能不同,即使它们本质上是相同的。依赖于此任务输出的任务将无法从构建缓存中加载输出,因为输入并不完全相同。

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

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 配置可能就会产生相当大的影响。

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

自定义任务和第三方任务

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

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