构建配置输入跟踪的改进

目录

引言

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

在 Gradle 8.1 中,配置缓存已稳定并推荐采用。稳定性在此意味着行为已定型,所有重大更改都遵循 Gradle 的 弃用流程。总的来说,如果某项功能现在有效,它将继续以相同的方式运行——除非该功能存在 bug 并可能导致构建不正确。虽然某些功能 尚未实现,但大多数用户已经可以从配置缓存带来的速度提升中受益。

构建配置输入 #

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

这个概念类似于 任务缓存

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

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

构建配置输入的示例包括

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

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

正确检测所有输入很重要,否则,使用缓存的构建可能会不正确——其结果将与非缓存构建不匹配。例如,如果构建逻辑在配置时检查文件是否存在,并根据此文件以不同的方式配置任务,那么如果文件出现或消失,配置缓存应该被使失效。对于短期环境(如临时 CI 构建)来说,这种行为可能会带来问题。

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

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

我们在每个版本中不断改进构建输入跟踪,以便 Gradle 能够可靠地检测更多类型的输入。这些新发现的输入通常不会对利用配置缓存的现有构建产生负面影响。当新输入保持不变时,构建仍然会重用缓存。从这个意义上说,检测新类型的输入不是破坏性更改,而更像是修复了导致错误缓存命中的 bug。但是,某些模式可能导致略有不同的用户体验,需要几次额外的不可缓存运行,然后才能在所有后续构建中使用缓存的配置。例如,初始构建可能会在记录了其缺失后创建一些文件,从而导致下一个运行的缓存条目无效。这种行为对于像临时 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 issue 告诉我们。您也可以通过 我们的论坛Gradle Community Slack#configuration-cache 频道与我们联系。

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

讨论