配置缓存对构建逻辑的要求
为了使用配置缓存捕获和重新加载任务图状态,Gradle 对任务和构建逻辑强制执行特定的要求。任何违反这些要求的行为都将报告为配置缓存“问题”,从而导致构建失败。
在大多数情况下,这些要求暴露出未声明的输入,使构建更加严格、正确和可靠。使用配置缓存实际上是选择性地使用这些改进。
以下各节描述了每个要求,并提供了解决构建中问题的指导。
某些类型不得被任务引用
某些类型不得被任务字段或任务动作(如 doFirst {}
或 doLast {}
)引用。
这些类型分为以下几类:
-
Live JVM state types(实时 JVM 状态类型)
-
Gradle 模型类型
-
依赖管理类型
存在这些限制是因为这些类型不能轻易地被配置缓存存储或重建。
Live JVM State Types(实时 JVM 状态类型)
Live JVM state types(例如 ClassLoader
、Thread
、OutputStream
、Socket
)是不允许的,因为它们不代表任务输入或输出。
Gradle 模型类型
Gradle 模型类型(例如 Gradle
、Settings
、Project
、SourceSet
、Configuration
)通常用于传递应明确声明的任务输入。
例如,与其在执行时引用 Project 以检索 project.version
,不如将项目版本声明为 Property<String>
输入。同样,与其引用 SourceSet
来获取源文件或类路径解析,不如将这些声明为 FileCollection
输入。
依赖管理类型
同样的要求也适用于依赖管理类型,但有一些细微差别。
一些依赖管理类型,如 Configuration
和 SourceDirectorySet
,不应作为任务输入使用,因为它们包含不必要的状态且不够精确。请使用提供必要功能的、不太具体的类型代替。
-
如果引用
Configuration
以获取已解析的文件,请声明一个FileCollection
输入。 -
如果引用
SourceDirectorySet
,请声明一个FileTree
输入。
此外,引用已解析的依赖结果是不允许的(例如 ArtifactResolutionQuery
、ResolvedArtifact
、ArtifactResult
)。相反,请执行以下操作:
-
使用
ResolutionResult.getRootComponent()
返回的Provider<ResolvedComponentResult>
。 -
使用
ArtifactCollection.getResolvedArtifacts()
,它返回一个Provider<Set<ResolvedArtifactResult>>
。
任务应避免引用*已解析*的结果,而是依赖于延迟规范,将依赖解析推迟到执行时。
某些类型,如 Publication
或 Dependency
,目前不可序列化,但将来可能会实现。如有必要,Gradle 可能会允许它们作为任务输入。
以下任务引用了 SourceSet
,这是不允许的:
abstract class SomeTask : DefaultTask() {
@get:Input lateinit var sourceSet: SourceSet (1)
@TaskAction
fun action() {
val classpathFiles = sourceSet.compileClasspath.files
// ...
}
}
abstract class SomeTask extends DefaultTask {
@Input SourceSet sourceSet (1)
@TaskAction
void action() {
def classpathFiles = sourceSet.compileClasspath.files
// ...
}
}
1 | 这将报告为一个问题,因为不允许引用 SourceSet 。 |
以下是修复后的版本:
abstract class SomeTask : DefaultTask() {
@get:InputFiles @get:Classpath
abstract val classpath: ConfigurableFileCollection (1)
@TaskAction
fun action() {
val classpathFiles = classpath.files
// ...
}
}
abstract class SomeTask extends DefaultTask {
@InputFiles @Classpath
abstract ConfigurableFileCollection getClasspath() (1)
@TaskAction
void action() {
def classpathFiles = classpath.files
// ...
}
}
1 | 不再报告问题,我们现在引用了支持的类型 FileCollection 。 |
如果脚本中的一个临时任务在 doLast {}
闭包中捕获了不允许的引用:
tasks.register("someTask") {
doLast {
val classpathFiles = sourceSets.main.get().compileClasspath.files (1)
}
}
tasks.register('someTask') {
doLast {
def classpathFiles = sourceSets.main.compileClasspath.files (1)
}
}
1 | 这将报告为一个问题,因为 doLast {} 闭包正在捕获对 SourceSet 的引用。 |
您仍然需要满足相同的要求,即不引用不允许的类型。
任务声明的修复方法如下:
tasks.register("someTask") {
val classpath = sourceSets.main.get().compileClasspath (1)
doLast {
val classpathFiles = classpath.files
}
}
tasks.register('someTask') {
def classpath = sourceSets.main.compileClasspath (1)
doLast {
def classpathFiles = classpath.files
}
}
1 | 不再报告问题,doLast {} 闭包现在只捕获 classpath ,它是受支持的 FileCollection 类型。 |
有时,不允许的类型会通过另一种类型间接引用。例如,一个任务可能引用一个允许的类型,而该类型又引用一个不允许的类型。HTML 问题报告中的层次结构视图可以帮助您追踪此类问题并识别违规引用。
在执行时使用 Project
对象
任务在执行期间不得使用任何 Project
对象。这包括在任务运行时调用 Task.getProject()
。
某些情况可以按照 不允许的类型 中描述的方式解决。
通常,Project
和 Task
都提供等效的功能。例如:
-
如果需要
Logger
,请使用Task.logger
而不是Project.logger
。 -
对于文件操作,请使用 注入的服务 而不是
Project
方法。
以下任务在执行时错误地引用了 Project
对象:
abstract class SomeTask : DefaultTask() {
@TaskAction
fun action() {
project.copy { (1)
from("source")
into("destination")
}
}
}
abstract class SomeTask extends DefaultTask {
@TaskAction
void action() {
project.copy { (1)
from 'source'
into 'destination'
}
}
}
1 | 这将报告为一个问题,因为任务操作在执行时使用了 Project 对象。 |
修复后的版本:
abstract class SomeTask : DefaultTask() {
@get:Inject abstract val fs: FileSystemOperations (1)
@TaskAction
fun action() {
fs.copy {
from("source")
into("destination")
}
}
}
abstract class SomeTask extends DefaultTask {
@Inject abstract FileSystemOperations getFs() (1)
@TaskAction
void action() {
fs.copy {
from 'source'
into 'destination'
}
}
}
1 | 不再报告问题,注入的 FileSystemOperations 服务被支持作为 project.copy {} 的替代方案。 |
如果脚本中的临时任务出现相同问题:
tasks.register("someTask") {
doLast {
project.copy { (1)
from("source")
into("destination")
}
}
}
tasks.register('someTask') {
doLast {
project.copy { (1)
from 'source'
into 'destination'
}
}
}
1 | 这将报告为一个问题,因为任务操作在执行时使用了 Project 对象。 |
修复后的版本:
interface Injected {
@get:Inject val fs: FileSystemOperations (1)
}
tasks.register("someTask") {
val injected = project.objects.newInstance<Injected>() (2)
doLast {
injected.fs.copy { (3)
from("source")
into("destination")
}
}
}
interface Injected {
@Inject FileSystemOperations getFs() (1)
}
tasks.register('someTask') {
def injected = project.objects.newInstance(Injected) (2)
doLast {
injected.fs.copy { (3)
from 'source'
into 'destination'
}
}
}
1 | 服务无法直接在脚本中注入,我们需要一个额外的类型来传递注入点。 |
2 | 在任务操作外部使用 project.object 创建额外类型的实例。 |
3 | 不再报告问题,任务操作引用了 injected ,它提供了 FileSystemOperations 服务,支持作为 project.copy {} 的替代。 |
修复脚本中的临时任务需要额外的努力,这使其成为将其重构为正确的任务类的良好机会。
下表列出了常用 Project
方法的推荐替代方案:
代替 | 使用 |
---|---|
|
一个任务输入或输出属性,或一个脚本变量,用于捕获使用 |
|
一个任务输入或输出属性,或一个脚本变量,用于捕获使用 |
|
|
|
一个任务输入或输出属性,或一个脚本变量,用于捕获使用 |
|
一个任务输入或输出属性,或一个脚本变量,用于捕获使用 |
|
一个任务输入或输出属性,或一个脚本变量,用于捕获使用 |
|
一个任务输入或输出属性,或一个脚本变量,用于捕获使用 |
|
|
|
|
|
|
|
一个任务输入或输出属性,或一个脚本变量,用于捕获使用 |
|
一个任务输入或输出属性,或一个脚本变量,用于捕获使用 |
|
|
|
|
|
|
|
|
|
|
|
一个任务输入或输出属性,或一个脚本变量,用于捕获使用 |
|
|
|
|
|
|
|
|
|
构建逻辑中可用的 Kotlin、Groovy 或 Java API。 |
|
|
|
|
|
|
|
从另一个实例访问任务实例
任务不得直接访问另一个任务实例的状态。相反,它们应该使用 输入和输出关系 进行连接。
此要求确保任务保持隔离并可正确缓存。因此,不支持编写在执行时配置其他任务的任务。
共享可变对象
将任务存储在配置缓存中时,通过任务字段引用的所有对象都将序列化。
在大多数情况下,反序列化会保留引用相等性——如果在配置时两个字段 a
和 b
引用同一实例,则在反序列化后它们仍将引用同一实例(在 Groovy/Kotlin 语法中,a == b
或 a === b
)。
然而,出于性能原因,某些类(例如 java.lang.String
、java.io.File
和许多 java.util.Collection
实现)在序列化时不会保留引用相等性。反序列化后,引用这些对象的字段可能会引用不同但相等的实例。
考虑一个任务,它将用户定义的对象和 ArrayList
存储为任务字段。
class StateObject {
// ...
}
abstract class StatefulTask : DefaultTask() {
@get:Internal
var stateObject: StateObject? = null
@get:Internal
var strings: List<String>? = null
}
tasks.register<StatefulTask>("checkEquality") {
val objectValue = StateObject()
val stringsValue = arrayListOf("a", "b")
stateObject = objectValue
strings = stringsValue
doLast { (1)
println("POJO reference equality: ${stateObject === objectValue}") (2)
println("Collection reference equality: ${strings === stringsValue}") (3)
println("Collection equality: ${strings == stringsValue}") (4)
}
}
class StateObject {
// ...
}
abstract class StatefulTask extends DefaultTask {
@Internal
StateObject stateObject
@Internal
List<String> strings
}
tasks.register("checkEquality", StatefulTask) {
def objectValue = new StateObject()
def stringsValue = ["a", "b"] as ArrayList<String>
stateObject = objectValue
strings = stringsValue
doLast { (1)
println("POJO reference equality: ${stateObject === objectValue}") (2)
println("Collection reference equality: ${strings === stringsValue}") (3)
println("Collection equality: ${strings == stringsValue}") (4)
}
}
1 | doLast 动作捕获封闭范围的引用。这些被捕获的引用也被序列化到配置缓存中。 |
2 | 比较存储在任务字段中的用户定义类对象的引用和在 doLast 动作中捕获的引用。 |
3 | 比较存储在任务字段中的 ArrayList 实例的引用和在 doLast 动作中捕获的引用。 |
4 | 检查存储列表和捕获列表的相等性。 |
在没有配置缓存的情况下,引用相等性在两种情况下都保留:
❯ ./gradlew --no-configuration-cache checkEquality > Task :checkEquality POJO reference equality: true Collection reference equality: true Collection equality: true
启用配置缓存后,只有用户定义的对象的引用保持相同。列表引用不同,尽管列表本身保持相等。
❯ ./gradlew --configuration-cache checkEquality > Task :checkEquality POJO reference equality: true Collection reference equality: false Collection equality: true
最佳实践
-
避免在配置和执行阶段之间共享可变对象。
-
如果必须共享状态,请将其包装在用户定义的类中。
-
不要依赖标准 Java、Groovy、Kotlin 或 Gradle 定义类型的引用相等性。
任务之间永远不会保留引用相等性——每个任务都是一个独立的“领域”。要在任务之间共享对象,请使用 构建服务 来包装共享状态。
访问任务扩展或约定
任务在执行时**不得**访问约定、扩展或额外属性。
相反,任何与任务执行相关的值都应明确地建模为任务属性,以确保适当的缓存和可重现性。
运行外部进程
插件和构建脚本应避免在配置时运行外部进程。 |
您应该避免在配置期间使用这些 API 来运行进程:
-
Java/Kotlin:
ProcessBuilder
、Runtime.exec(…)
等… -
Groovy:
*.execute()
等… -
Gradle:
ExecOperations.exec
、ExecOperations.javaexec
等…
这些方法的灵活性使得 Gradle 无法确定调用如何影响构建配置,从而难以确保可以安全地重用配置缓存条目。
但是,如果需要在配置时运行进程,您可以使用下面详细介绍的配置缓存兼容 API。
对于更简单的情况,当获取进程的输出就足够时,可以使用 providers.exec()
和 providers.javaexec()
。
val gitVersion = providers.exec {
commandLine("git", "--version")
}.standardOutput.asText.get()
def gitVersion = providers.exec {
commandLine("git", "--version")
}.standardOutput.asText.get()
对于更复杂的情况,可以使用注入了 ExecOperations
的自定义 ValueSource
实现。这个 ExecOperations
实例可以在配置时不受限制地使用。
abstract class GitVersionValueSource : ValueSource<String, ValueSourceParameters.None> {
@get:Inject
abstract val execOperations: ExecOperations
override fun obtain(): String {
val output = ByteArrayOutputStream()
execOperations.exec {
commandLine("git", "--version")
standardOutput = output
}
return String(output.toByteArray(), Charset.defaultCharset())
}
}
abstract class GitVersionValueSource implements ValueSource<String, ValueSourceParameters.None> {
@Inject
abstract ExecOperations getExecOperations()
String obtain() {
ByteArrayOutputStream output = new ByteArrayOutputStream()
execOperations.exec {
it.commandLine "git", "--version"
it.standardOutput = output
}
return new String(output.toByteArray(), Charset.defaultCharset())
}
}
您还可以在 ValueSource
中使用标准 Java/Kotlin/Groovy 进程 API,例如 java.lang.ProcessBuilder
。
然后,可以使用 ValueSource
实现通过 providers.of
创建一个提供者。
val gitVersionProvider = providers.of(GitVersionValueSource::class) {}
val gitVersion = gitVersionProvider.get()
def gitVersionProvider = providers.of(GitVersionValueSource.class) {}
def gitVersion = gitVersionProvider.get()
在这两种方法中,如果提供者的值在配置时使用,那么它将成为构建配置输入。外部进程将为每次构建执行,以确定配置缓存是否为 UP-TO-DATE
,因此建议仅在配置时调用快速运行的进程。如果值发生变化,则缓存将失效,并且该进程将在本次构建期间作为配置阶段的一部分再次运行。
读取系统属性和环境变量
插件和构建脚本可以在配置时直接使用标准 Java、Groovy 或 Kotlin API 或值供应商 API 读取系统属性和环境变量。这样做会将此类变量或属性作为构建配置输入。因此,更改其值会使配置缓存失效。
配置缓存报告包含这些构建配置输入的列表,以帮助跟踪它们。
通常,您应该避免在配置时读取系统属性和环境变量的值,以避免当这些值发生变化时导致缓存未命中。相反,您可以将 providers.systemProperty()
或 providers.environmentVariable()
返回的 Provider
连接到任务属性。
某些可能枚举所有环境变量或系统属性的访问模式(例如,调用 System.getenv().forEach()
或使用其 keySet()
的迭代器)是不鼓励的。在这种情况下,Gradle 无法找出哪些属性是实际的构建配置输入,因此每个可用的属性都成为一个输入。即使添加一个新属性,如果使用此模式,也会使缓存失效。
使用自定义谓词过滤环境变量是这种不鼓励模式的一个例子:
val jdkLocations = System.getenv().filterKeys {
it.startsWith("JDK_")
}
def jdkLocations = System.getenv().findAll {
key, _ -> key.startsWith("JDK_")
}
谓词中的逻辑对配置缓存来说是不透明的,因此所有环境变量都被视为输入。减少输入数量的一种方法是始终使用查询具体变量名的方法,例如 getenv(String)
或 getenv().get()
。
val jdkVariables = listOf("JDK_8", "JDK_11", "JDK_17")
val jdkLocations = jdkVariables.filter { v ->
System.getenv(v) != null
}.associate { v ->
v to System.getenv(v)
}
def jdkVariables = ["JDK_8", "JDK_11", "JDK_17"]
def jdkLocations = jdkVariables.findAll { v ->
System.getenv(v) != null
}.collectEntries { v ->
[v, System.getenv(v)]
}
val jdkLocationsProvider = providers.environmentVariablesPrefixedBy("JDK_")
def jdkLocationsProvider = providers.environmentVariablesPrefixedBy("JDK_")
请注意,配置缓存不仅在变量值更改或变量被删除时失效,而且在环境中添加另一个具有匹配前缀的变量时也会失效。
对于更复杂的用例,可以使用自定义 ValueSource
实现。在 ValueSource
代码中引用的系统属性和环境变量不会成为构建配置输入,因此可以应用任何处理。相反,ValueSource
的值在每次构建运行时都会重新计算,并且只有当值发生变化时,配置缓存才会失效。例如,可以使用 ValueSource
获取所有名称中包含子字符串 JDK
的环境变量。
abstract class EnvVarsWithSubstringValueSource : ValueSource<Map<String, String>, EnvVarsWithSubstringValueSource.Parameters> {
interface Parameters : ValueSourceParameters {
val substring: Property<String>
}
override fun obtain(): Map<String, String> {
return System.getenv().filterKeys { key ->
key.contains(parameters.substring.get())
}
}
}
val jdkLocationsProvider = providers.of(EnvVarsWithSubstringValueSource::class) {
parameters {
substring = "JDK"
}
}
abstract class EnvVarsWithSubstringValueSource implements ValueSource<Map<String, String>, Parameters> {
interface Parameters extends ValueSourceParameters {
Property<String> getSubstring()
}
Map<String, String> obtain() {
return System.getenv().findAll { key, _ ->
key.contains(parameters.substring.get())
}
}
}
def jdkLocationsProvider = providers.of(EnvVarsWithSubstringValueSource.class) {
parameters {
substring = "JDK"
}
}
未声明的文件读取
插件和构建脚本不应在配置时直接使用 Java、Groovy 或 Kotlin API 读取文件。相反,应使用值供应商 API 将文件声明为潜在的构建配置输入。
这个问题是由类似的构建逻辑引起的:
val config = file("some.conf").readText()
def config = file('some.conf').text
要解决此问题,请改用 providers.fileContents()
读取文件。
val config = providers.fileContents(layout.projectDirectory.file("some.conf"))
.asText
def config = providers.fileContents(layout.projectDirectory.file('some.conf'))
.asText
一般来说,您应该避免在配置时读取文件,以免在文件内容发生变化时导致配置缓存条目失效。相反,您可以将 providers.fileContents()
返回的 Provider
连接到任务属性。
字节码修改和 Java Agent
为了检测配置输入,Gradle 会修改构建脚本类路径上的类的字节码,例如插件及其依赖项。Gradle 使用 Java Agent 来修改字节码。一些库的完整性自检可能会因为修改后的字节码或 Agent 的存在而失败。
为了解决这个问题,您可以使用 Worker API,通过类加载器或进程隔离来封装库代码。Worker 的类路径的字节码不会被修改,因此自检应该通过。当使用进程隔离时,Worker 动作会在一个独立的 Worker 进程中执行,该进程没有安装 Gradle Java Agent。
在简单的情况下,如果库还提供命令行入口点(public static void main()
方法),您也可以使用 JavaExec
任务来隔离库。
凭据和秘密的处理
目前,配置缓存没有内置机制来阻止存储用作输入的秘密。因此,秘密可能会最终存储在序列化的配置缓存条目中,默认情况下,这些条目存储在项目目录中的 .gradle/configuration-cache
下。
为了减轻意外泄露的风险,Gradle 会对配置缓存进行加密。当需要时,Gradle 会透明地生成一个机器特定的密钥,将其缓存到 GRADLE_USER_HOME
目录中,并使用它来加密项目特定的缓存中的数据。
为了进一步增强安全性,请遵循以下建议:
-
安全访问配置缓存条目。
-
使用
GRADLE_USER_HOME/gradle.properties
存储秘密。此文件的内容**不**包含在配置缓存中——只有其指纹。如果在此文件中存储秘密,请确保访问**受到适当限制**。
请参阅 gradle/gradle#22618。
使用 GRADLE_ENCRYPTION_KEY
环境变量提供加密密钥
默认情况下,Gradle 会自动生成并管理加密密钥,将其作为 Java 密钥库存储在 GRADLE_USER_HOME
目录下。
对于不希望这种行为的环境(例如,当 GRADLE_USER_HOME
目录在多台机器之间共享时),您可以使用 GRADLE_ENCRYPTION_KEY
环境变量显式提供加密密钥。
在多次 Gradle 运行中**必须始终提供**相同的加密密钥;否则,Gradle 将无法重用现有的缓存配置。 |
生成与 GRADLE_ENCRYPTION_KEY
兼容的加密密钥
为了使用用户指定的加密密钥加密配置缓存,Gradle 要求将 GRADLE_ENCRYPTION_KEY
环境变量设置为有效的 AES 密钥,并编码为 Base64 字符串。
您可以使用以下命令生成 Base64 编码的 AES 兼容密钥:
❯ openssl rand -base64 16
此命令适用于 Linux 和 macOS,如果使用 Cygwin 等工具,也适用于 Windows。
生成后,将 Base64 编码的密钥设置为 GRADLE_ENCRYPTION_KEY
环境变量的值:
❯ export GRADLE_ENCRYPTION_KEY="your-generated-key-here"