构建配置输入跟踪的改进

目录

引言

配置缓存是一项通过缓存配置阶段的结果并在后续构建中重用它来显著提高构建性能的功能。使用配置缓存,当没有影响构建配置的因素(例如构建脚本)发生更改时,Gradle 构建工具可以完全跳过配置阶段。

在 Gradle 8.1 中,配置缓存变得稳定并推荐采用。在这种情况下,稳定性意味着行为已最终确定,所有重大更改都遵循 Gradle 弃用流程。一般来说,如果现在可以正常工作,它将继续以相同的方式工作——除非该行为存在错误并可能导致不正确的构建。虽然某些功能尚未实现,但大多数用户已经可以从配置缓存带来的速度提升中受益。

构建配置输入 #

在编写与配置缓存兼容的构建逻辑或插件时,重要的是要了解 Gradle 将哪些内容视为构建配置输入以及它们如何影响缓存失效。

这个概念类似于 任务缓存

  1. 所有有助于构建逻辑执行的内容都是输入。
  2. 配置阶段是一个动作。
  3. 生成的任务执行图是输出。

Task inputs and outputs compared to the configuration phase inputs and outputs

构建配置输入的示例包括

  • 构建脚本及其依赖项(插件和库)。
  • 在配置时读取的环境变量和系统属性。
  • 构建逻辑或插件在配置时读取的文件。
  • 在配置时获取的文件系统信息(文件的存在性、目录结构)。
  • 在配置时执行的外部进程的输出。

当构建逻辑或插件在配置时访问某些“环境”内容时,获得的数据将成为配置缓存指纹的一部分。下次构建运行时,Gradle 会检查输入的 values 是否仍然相同——它会重新读取环境变量、系统属性、文件并重新运行外部进程。然后,它将它们与第一次运行(存储缓存条目时)看到的内容进行比较。如果一切都匹配,则重用缓存条目,并且任务图从缓存加载和执行,而无需运行配置阶段。

正确检测所有输入非常重要,否则,使用缓存的构建可能不正确——其结果将与非缓存的结果不匹配。例如,如果构建逻辑在配置时检查文件的存在性,并根据此以不同方式配置任务,则如果文件出现或消失,则应使配置缓存失效。

为了确保正确性,Gradle 采用了多种技术

  • 为构建作者提供 API 以显式指定来自环境的值。
  • 当启用配置缓存时,禁止使用不兼容的 API。
  • 重写配置逻辑字节码以拦截环境访问。

我们不断改进每个版本中的构建输入跟踪,以便 Gradle 可以可靠地检测更多类型的输入。这些新发现的输入通常不会对利用配置缓存的现有构建产生负面影响。当新输入保持不变时,构建仍然会重用缓存。从这个意义上讲,检测新类型的输入不是一个重大更改,而更像是一个错误修复,它消除了错误的缓存命中。但是,某些模式可能会导致略微不同的用户体验,在所有后续构建中使用缓存的配置之前,需要进行一些额外的不可缓存运行。例如,初始构建可能会在记录其不存在后创建一些文件,从而使缓存条目在下次运行时无效。这种行为对于短暂的环境(如临时 CI 构建)来说可能会有问题。

临时忽略构建配置输入 #

调整插件和构建逻辑以避免不必要的缓存失效需要时间。但是,在某些情况下,可以忽略输入,而不会影响缓存的正确性。作为临时解决方案,除了新引入的输入检测外,Gradle 还为构建用户提供了一种通过 Gradle 属性(在可能的情况下,按每个输入)来抑制它的方法。

插件作者可以在更新1时记录推荐的抑制方法,但无法从插件代码应用抑制。与一般的 Gradle 弃用流程一样,忽略输入的选项将至少在下一个主要版本之前可用。

下面我们讨论两个主要的配置输入检查示例,这些检查改进了配置缓存的正确性,如何临时禁用它们,以及如何正确修复构建逻辑或插件代码以使用它们。

用例 1:文件 API 跟踪 #

Gradle 8.1 引入了 File.existsFile.isDirectoryFile.list 和类似的查询文件系统调用的跟踪。这有助于确保在任务图的形状取决于文件、目录等的存在的情况下,构建结果的正确性。

但是,新的检查对于使用“检查然后写入”模式的插件可能会有问题

if (!someFile.exists()) {
    someFile.createNewFile();
}

这是因为当检查的文件不存在时,Gradle 会将其不存在记录为配置输入并将其存储在缓存中。在下次构建运行时,Gradle 会发现该文件现在存在,因此必须使缓存失效,即使构建配置的结果可能相同。输入检测无法推断检查文件的存在性如何影响构建逻辑。在这种情况下,构建或插件作者必须通过使用适当的 Gradle API 显式声明他们的意图。

临时禁用文件 API 跟踪 #

在等待更新后的插件时,构建用户可以使用 org.gradle.configuration-cache.inputs.unsafe.ignore.file-system-checks 属性为选定的文件禁用这些检查。我们不建议在没有首先评估影响的情况下这样做。请注意,该检查是在 Gradle 8.1 中添加的,并且仅在 Gradle 8.3 及更高版本中可以禁用。

修复插件或构建逻辑以使用 API 跟踪 #

让我们看看如何以与配置缓存兼容的方式重写文件 API 的一些用法。

读取和写入可选的配置文件 #

假设所讨论的插件读取配置文件。如果文件不存在,插件会创建一个具有默认配置的文件。用户可以根据需要修改它。然后,文件中的一个值用于有条件地注册任务

fun loadConfiguration(configFile: File): Properties {
    val result = Properties()
    if (!configFile.exists()) {
        result["some.property"] = "default value"
        configFile.bufferedWriter().use {
            result.store(it, "")
        }
        return result
    }
    configFile.bufferedReader().use { result.load(it) }
    return result
}

if (loadConfiguration().getProperty("should.register.task").toBoolean()) {
    tasks.register("someTask") {
        // ...
    }
}

在这种情况下,下一次构建运行将不会重用配置缓存条目,因为在检查时不存在的配置文件现在存在。这是 ValueSource 的完美用例

abstract class PropertiesValueSource
    : ValueSource<Properties, PropertiesValueSource.Params> {

    interface Params : ValueSourceParameters {
        val configFile: RegularFileProperty
    }

    override fun obtain(): Properties {
        val configFile = parameters.configFile.asFile.get()
        // The remaining code of creating/loading a file can be left intact.
        val result = Properties()
        if (!configFile.exists()) {
            result["some.property"] = "default value"
            configFile.bufferedWriter().use {
                result.store(it, "")
            }
            return result
        }
        configFile.bufferedReader().use { result.load(it) }
        return result
    }
}

val propsProvider = providers.of(PropertiesValueSource::class) {
    parameters.configFile = layout.projectDirectory.file("config.properties")
}

if (propsProvider.get().getProperty("should.register.task").toBoolean()) {
    tasks.register("someTask") {
        // ...
    }
}

在 ValueSource 实现内部进行的文件检查、读取和写入不会记录为配置输入。相反,在检查缓存指纹时,将重新计算整个 ValueSource,并且只有当返回值发生更改时,缓存条目才会失效。

除非用户从不修改配置文件,否则抑制这种类型的输入可能是危险的。

确保文件在执行阶段之前存在 #

有时,插件或构建逻辑会在配置阶段创建文件,以确保这些文件在执行阶段可供任务使用。

val someFile = file("createMe.txt")
if (!someFile.exists()) {
    someFile.writeText("Some initial text")
}

abstract class Consumer : DefaultTask() {
    @get:InputFile
    @get:PathSensitive(PathSensitivity.NONE)
    abstract val inputEnsured: RegularFileProperty

    @TaskAction
    fun action() {
        println(inputEnsured.asFile.get().readText())
    }
}

tasks.register<Consumer>("consumer") {
    inputEnsured = someFile
}

在配置时创建文件在配置缓存中没有得到很好的支持,特别是当该文件随后在执行时使用时。Gradle 不会跟踪文件是否在配置时创建、删除或写入,因此对此类文件的更改可能不会使配置缓存失效。使用其他任务依赖的任务创建文件是更好的解决方案。

abstract class EnsureFile : DefaultTask() {
    @get:OutputFile
    abstract val ensured: RegularFileProperty

    @TaskAction
    fun action() {
        val file = ensured.asFile.get()
        if (!file.exists()) {
            file.writeText("Some initial text")
        }
    }
}

val ensureFile by tasks.registering(EnsureFile::class) {
    ensured = layout.buildDirectory.file("createMeTask.txt")
}

tasks.register<Consumer>("consumer") {
    inputEnsured = ensureFile.flatMap { it.ensured }
}

使用文件进行跨进程锁 #

文件可以用作跨进程锁定协议。例如,为了确保并发构建不会覆盖某些共享资源。当在配置时使用锁时,锁文件可以成为配置的输入。

inline fun withFileLock(lockFile: File, block: () -> Unit) {
    if (!lockFile.exists()) {
        lockFile.createNewFile()
    }

    val channel = FileChannel.open(lockFile.toPath(),
        StandardOpenOption.READ,
        StandardOpenOption.WRITE,
        StandardOpenOption.CREATE
    )
    channel.use {
        channel.tryLock()?.use {
            block()
        }
    }
}

检查文件是否存在以及打开 FileChannel 进行读取(通过使用 open(..., StandardOpenOption.READ, ...))都会使锁文件成为输入。第二次运行不会获得缓存命中,因为 exists 检查的结果发生了变化。

在实现锁定协议时,锁文件的内容通常无关紧要,因此无需打开文件进行读取。如果单独完成(如上所示),则在检查文件是否存在和创建文件之间存在竞争是不可避免的,因此也不需要检查。在删除存在性检查和 READ 选项后,该文件不再是输入

inline fun withFileLockNoInput(lockFile: File, block: () -> Unit) {
    val channel = FileChannel.open(lockFile.toPath(),
        StandardOpenOption.WRITE,
        StandardOpenOption.CREATE
    )
    channel.use {
        channel.tryLock()?.use {
            block()
        }
    }
}

抑制这种类型的输入通常是良性的。

第三方库中的文件访问 #

插件实现中使用的第三方库也可能访问文件。这些库通常不依赖 Gradle API,这使得有针对性的修复不太可能。

插件作者可以将此类库的用法包装在 ValueSources 中(如果必须在配置时获取库提供的值),或者使用 Worker API 与类加载器或进程隔离来运行库代码(如果不需要传回除成功/失败之外的任何内容)。但是,后者更适合任务操作而不是配置阶段。

用例 2:在存储任务图时跟踪输入 #

计划在 Gradle 8.4 中对构建配置输入检测进行另一项更改。

在缓存中存储计算的任务图时,Gradle 会解析配置并展平 provider 链,如果可能,将其替换为计算的值。用户代码,特别是依赖项解析回调和 provider {} 计算,在执行此操作时运行。在 Gradle 8.4 之前,在存储计算的任务图时访问的输入被忽略,这可能导致错误的缓存命中。

例如,考虑一个仅在文件存在时运行的任务

tasks.register("onlyIfFileExists") {
    val fileExistsProvider = provider { file("runTask.txt").exists() }

    onlyIf { fileExistsProvider.get() }

    doLast {
        println("File exists!")
    }
}

在 Gradle 8.3 上运行时,“runTask.txt”文件的缺失被缓存在配置缓存中,因此即使稍后添加了该文件,缓存也不会失效,并且任务仍然被跳过。Gradle 8.4 修复了此问题,并且当添加文件时,配置缓存会正确失效。

临时禁用存储时输入 #

与文件 API 跟踪一样,用户可以通过将 Gradle 属性 org.gradle.configuration-cache.inputs.unsafe.ignore.in-serialization 设置为 true 来暂时选择退出新行为。请注意,无法仅忽略以这种方式访问的输入子集。

修复插件或构建逻辑以使用存储时输入 #

为了避免在存储时出现不必要的输入,构建和插件作者可以使用与其他配置时代码相同的技术。另一种选择是用基于 ValueSource 或内置的 provider 替换基于 Callable 的 provider,如果仅在执行时查询 provider 的值,这可以提高配置缓存命中率。

上面的示例可以重写以完全避免使配置缓存失效

tasks.register("onlyIfFileExists") {
    val fileExistsProvider = providers.fileContents(layout.projectDirectory.file("runTask.txt")).asBytes

    // fileContents provider has value present only if the file exists.
    onlyIf { fileExistsProvider.isPresent }

    doLast {
        println("File exists!")
    }
}

结论 #

检测构建配置输入对于使用配置缓存的构建的正确性至关重要。Gradle 在这方面不断改进,但允许现有构建暂时选择退出新行为(如果它导致“良性”缓存未命中),直到构建逻辑和插件能够得到充分修复。

如果您认为 Gradle 未能识别构建配置输入或缺少 API 来以与配置缓存兼容的方式支持您的用例,请通过打开 Gradle 问题告知我们。您也可以在我们的论坛Gradle 社区 Slack#configuration-cache 频道中联系我们。

  1. 例如,您可以在 文档 中找到 Android Gradle 插件的最新建议。 

讨论