构建配置输入跟踪的改进

配置缓存是一个显著提高构建性能的功能,它通过缓存配置阶段的结果并在后续构建中重用它来实现。使用配置缓存,Gradle 构建工具可以完全跳过配置阶段,前提是没有任何影响构建配置的内容(例如构建脚本)发生变化。

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

构建配置输入

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

这个概念类似于 任务缓存

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

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

构建配置输入的示例包括:

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

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

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

为了确保正确性,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 中(如果必须在配置时获取库提供的 value),或者使用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 来暂时选择退出新行为。请注意,无法仅忽略以这种方式访问的输入子集。

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

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

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

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 插件的 文档 中找到最新的建议。 

讨论