增量任务

在 Gradle 中,实现一个任务,当其输入和输出已经是 UP-TO-DATE 时跳过执行,这非常简单且高效,这要归功于 增量构建 功能。

但是,有时自上次执行以来只有少数输入文件发生了更改,最好避免重新处理所有未更改的输入。在将输入文件一对一地转换为输出文件时,这种情况很常见。

要优化构建过程,可以使用增量任务。此方法确保仅处理过期的输入文件,从而提高构建性能。

实现增量任务

要使任务增量处理输入,该任务必须包含一个增量任务操作。

这是一个任务操作方法,它有一个 InputChanges 参数。该参数告诉 Gradle 该操作只想处理已更改的输入。

此外,任务需要使用 @Incremental@SkipWhenEmpty 声明至少一个增量文件输入属性

build.gradle.kts
public class IncrementalReverseTask : DefaultTask() {

    @get:Incremental
    @get:InputDirectory
    val inputDir: DirectoryProperty = project.objects.directoryProperty()

    @get:OutputDirectory
    val outputDir: DirectoryProperty = project.objects.directoryProperty()

    @get:Input
    val inputProperty: RegularFileProperty = project.objects.fileProperty() // File input property

    @TaskAction
    fun execute(inputs: InputChanges) { // InputChanges parameter
        val msg = if (inputs.isIncremental) "CHANGED inputs are out of date"
                  else "ALL inputs are out of date"
        println(msg)
    }
}
build.gradle
class IncrementalReverseTask extends DefaultTask {

    @Incremental
    @InputDirectory
    def File inputDir

    @OutputDirectory
    def File outputDir

    @Input
    def inputProperty // File input property

    @TaskAction
    void execute(InputChanges inputs) { // InputChanges parameter
        println inputs.incremental ? "CHANGED inputs are out of date"
                                   : "ALL inputs are out of date"
    }
}

要查询输入文件属性的增量更改,该属性必须始终返回相同的实例。实现此目的的最简单方法是使用以下属性类型之一:RegularFilePropertyDirectoryPropertyConfigurableFileCollection

您可以在 延迟配置 中了解有关 RegularFilePropertyDirectoryProperty 的更多信息。

增量任务操作可以使用 InputChanges.getFileChanges() 找出基于文件的输入属性(类型为 RegularFilePropertyDirectoryPropertyConfigurableFileCollection)中哪些文件已更改。

该方法返回类型为 FileChangesIterable,该 Iterable 反过来可以查询以下内容

  • 受影响的文件

  • 更改类型 ADDEDREMOVEDMODIFIED

  • 已更改文件的 规范化路径

  • 已更改文件的 文件类型

以下示例演示了一个具有目录输入的增量任务。它假定该目录包含一组文本文件,并将它们复制到输出目录,并反转每个文件中的文本

build.gradle.kts
abstract class IncrementalReverseTask : DefaultTask() {
    @get:Incremental
    @get:PathSensitive(PathSensitivity.NAME_ONLY)
    @get:InputDirectory
    abstract val inputDir: DirectoryProperty

    @get:OutputDirectory
    abstract val outputDir: DirectoryProperty

    @get:Input
    abstract val inputProperty: Property<String>

    @TaskAction
    fun execute(inputChanges: InputChanges) {
        println(
            if (inputChanges.isIncremental) "Executing incrementally"
            else "Executing non-incrementally"
        )

        inputChanges.getFileChanges(inputDir).forEach { change ->
            if (change.fileType == FileType.DIRECTORY) return@forEach

            println("${change.changeType}: ${change.normalizedPath}")
            val targetFile = outputDir.file(change.normalizedPath).get().asFile
            if (change.changeType == ChangeType.REMOVED) {
                targetFile.delete()
            } else {
                targetFile.writeText(change.file.readText().reversed())
            }
        }
    }
}
build.gradle
abstract class IncrementalReverseTask extends DefaultTask {
    @Incremental
    @PathSensitive(PathSensitivity.NAME_ONLY)
    @InputDirectory
    abstract DirectoryProperty getInputDir()

    @OutputDirectory
    abstract DirectoryProperty getOutputDir()

    @Input
    abstract Property<String> getInputProperty()

    @TaskAction
    void execute(InputChanges inputChanges) {
        println(inputChanges.incremental
            ? 'Executing incrementally'
            : 'Executing non-incrementally'
        )

        inputChanges.getFileChanges(inputDir).each { change ->
            if (change.fileType == FileType.DIRECTORY) return

            println "${change.changeType}: ${change.normalizedPath}"
            def targetFile = outputDir.file(change.normalizedPath).get().asFile
            if (change.changeType == ChangeType.REMOVED) {
                targetFile.delete()
            } else {
                targetFile.text = change.file.text.reverse()
            }
        }
    }
}
inputDir 属性的类型、其注释和 execute() 操作使用 getFileChanges() 处理自上次构建以来已更改的文件子集。如果相应的输入文件已删除,则该操作会删除目标文件。

如果由于某种原因,任务以非增量方式执行(例如通过使用 --rerun-tasks 运行),则所有文件都会报告为 ADDED,而不管以前的状态如何。在这种情况下,Gradle 会自动删除以前的输出,因此增量任务只需处理给定的文件。

对于像上述示例这样的简单转换器任务,任务操作必须为任何过时的输入生成输出文件,并为任何已删除的输入删除输出文件。

一个任务只能包含一个增量任务操作。

哪些输入被认为已过时?

当一个任务之前已执行,并且自该执行以来唯一的更改是对增量输入文件属性的更改时,Gradle 可以智能地确定哪些输入文件需要处理,这个概念称为增量执行。

在此场景中,org.gradle.work.InputChanges 类中提供的 InputChanges.getFileChanges() 方法提供与给定属性关联的所有输入文件的详细信息,这些文件已被 ADDEDREMOVEDMODIFIED

但是,在许多情况下,Gradle 无法确定哪些输入文件需要处理(即非增量执行)。示例包括

  • 没有以前执行的历史记录。

  • 您正在使用不同版本的 Gradle 进行构建。目前,Gradle 不会使用不同版本的任务历史记录。

  • 添加到任务的 upToDateWhen 标准返回 false

  • 自上次执行以来,输入属性已更改。

  • 自上次执行以来,非增量输入文件属性已更改。

  • 自上次执行以来,一个或多个输出文件已更改。

在这些情况下,Gradle 会将所有输入文件报告为 ADDED,并且 getFileChanges() 方法将返回构成给定输入属性的所有文件的详细信息。

您可以使用 InputChanges.isIncremental() 方法检查任务执行是否为增量执行。

增量任务操作

考虑第一次针对一组输入执行 IncrementalReverseTask 的实例。

在这种情况下,所有输入都将被视为 ADDED,如下所示

build.gradle.kts
tasks.register<IncrementalReverseTask>("incrementalReverse") {
    inputDir = file("inputs")
    outputDir = layout.buildDirectory.dir("outputs")
    inputProperty = project.findProperty("taskInputProperty") as String? ?: "original"
}
build.gradle
tasks.register('incrementalReverse', IncrementalReverseTask) {
    inputDir = file('inputs')
    outputDir = layout.buildDirectory.dir("outputs")
    inputProperty = project.properties['taskInputProperty'] ?: 'original'
}

构建布局

.
├── build.gradle
└── inputs
    ├── 1.txt
    ├── 2.txt
    └── 3.txt
$ gradle -q incrementalReverse
Executing non-incrementally
ADDED: 1.txt
ADDED: 2.txt
ADDED: 3.txt

当然,当任务再次执行且没有任何更改时,整个任务将为 UP-TO-DATE,并且不会执行任务操作

$ gradle incrementalReverse
> Task :incrementalReverse UP-TO-DATE

BUILD SUCCESSFUL in 0s
1 actionable task: 1 up-to-date

当输入文件以某种方式修改或添加了新的输入文件时,重新执行任务会导致 InputChanges.getFileChanges() 返回这些文件。

以下示例修改了一个文件的内容,并在运行增量任务之前添加了另一个文件

build.gradle.kts
tasks.register("updateInputs") {
    val inputsDir = layout.projectDirectory.dir("inputs")
    outputs.dir(inputsDir)
    doLast {
        inputsDir.file("1.txt").asFile.writeText("Changed content for existing file 1.")
        inputsDir.file("4.txt").asFile.writeText("Content for new file 4.")
    }
}
build.gradle
tasks.register('updateInputs') {
    def inputsDir = layout.projectDirectory.dir('inputs')
    outputs.dir(inputsDir)
    doLast {
        inputsDir.file('1.txt').asFile.text = 'Changed content for existing file 1.'
        inputsDir.file('4.txt').asFile.text = 'Content for new file 4.'
    }
}
$ gradle -q updateInputs incrementalReverse
Executing incrementally
MODIFIED: 1.txt
ADDED: 4.txt
各种变异任务(updateInputsremoveInput 等)仅用于演示增量任务的行为。您不应该将它们视为您自己的构建脚本中应有的任务或任务实现。

当现有输入文件被删除时,重新执行任务会导致 InputChanges.getFileChanges() 将该文件作为 REMOVED 返回。

以下示例在执行增量任务之前删除了其中一个现有文件

build.gradle.kts
tasks.register<Delete>("removeInput") {
    delete("inputs/3.txt")
}
build.gradle
tasks.register('removeInput', Delete) {
    delete 'inputs/3.txt'
}
$ gradle -q removeInput incrementalReverse
Executing incrementally
REMOVED: 3.txt

输出文件被删除(或修改)时,Gradle 无法确定哪些输入文件已过期。在这种情况下,InputChanges.getFileChanges() 会返回给定属性的所有输入文件的详细信息。

以下示例从构建目录中删除了一个输出文件。然而,所有输入文件都被认为是ADDED

build.gradle.kts
tasks.register<Delete>("removeOutput") {
    delete(layout.buildDirectory.file("outputs/1.txt"))
}
build.gradle
tasks.register('removeOutput', Delete) {
    delete layout.buildDirectory.file("outputs/1.txt")
}
$ gradle -q removeOutput incrementalReverse
Executing non-incrementally
ADDED: 1.txt
ADDED: 2.txt
ADDED: 3.txt

我们想要介绍的最后一个场景涉及当基于非文件输入属性被修改时会发生什么。在这样的情况下,Gradle 无法确定该属性如何影响任务输出,因此任务以非增量方式执行。这意味着给定属性的所有输入文件都由 InputChanges.getFileChanges() 返回,并且它们都被视为 ADDED

以下示例在运行 incrementalReverse 任务时将项目属性 taskInputProperty 设置为新值。该项目属性用于初始化任务的 inputProperty 属性,如您在 本节的第一个示例 中看到的。

以下是这种情况下预期的输出

$ gradle -q -PtaskInputProperty=changed incrementalReverse
Executing non-incrementally
ADDED: 1.txt
ADDED: 2.txt
ADDED: 3.txt

命令行选项

有时,用户希望在命令行而不是构建脚本中声明公开的任务属性的值。如果属性值更频繁地更改,则在命令行中传递属性值特别有用。

任务 API 支持一种机制,用于标记属性以在运行时自动生成具有特定名称的相应命令行参数。

步骤 1. 声明命令行选项

要为任务属性公开新的命令行选项,请使用 Option 注释属性的相应 setter 方法

@Option(option = "flag", description = "Sets the flag")

选项需要一个强制标识符。您可以提供一个可选描述。

一个任务可以公开与类中可用的属性一样多的命令行选项。

选项也可以在任务类的超接口中声明。如果多个接口声明了相同的属性但具有不同的选项标志,它们都将用于设置属性。

在下面的示例中,自定义任务 UrlVerify 通过进行 HTTP 调用并检查响应代码来验证 URL 是否可以解析。要验证的 URL 可通过属性 url 进行配置。该属性的 setter 方法使用 @Option 进行注释

UrlVerify.java
import org.gradle.api.tasks.options.Option;

public class UrlVerify extends DefaultTask {
    private String url;

    @Option(option = "url", description = "Configures the URL to be verified.")
    public void setUrl(String url) {
        this.url = url;
    }

    @Input
    public String getUrl() {
        return url;
    }

    @TaskAction
    public void verify() {
        getLogger().quiet("Verifying URL '{}'", url);

        // verify URL by making a HTTP call
    }
}

可以通过运行 help 任务和 --task 选项将为任务声明的所有选项 呈现为控制台输出

步骤 2. 在命令行中使用选项

命令行上的选项有一些规则

  • 该选项使用双破折号作为前缀,例如,--url。单个破折号不符合任务选项的有效语法。

  • 选项参数紧跟在任务声明之后,例如,verifyUrl --url=http://www.google.com/

  • 可以在命令行中以任何顺序声明多个任务选项,紧跟在任务名称之后。

基于前面的示例,构建脚本创建了类型为 UrlVerify 的任务实例,并通过公开的选项从命令行提供了一个值

build.gradle.kts
tasks.register<UrlVerify>("verifyUrl")
build.gradle
tasks.register('verifyUrl', UrlVerify)
$ gradle -q verifyUrl --url=http://www.google.com/
Verifying URL 'http://www.google.com/'

选项支持的数据类型

Gradle 限制了可用于声明命令行选项的数据类型。

每种类型的命令行使用方式不同

booleanBooleanProperty<Boolean>

描述值为 truefalse 的选项。
在命令行中传递该选项会将该值视为 true。例如,--foo 等于 true
如果没有该选项,则使用属性的默认值。对于每个布尔选项,都会自动创建一个相反的选项。例如,为提供的选项 --foo 创建 --no-foo,为 --bar 创建 --no-bar。名称以 --no 开头的选项是已禁用的选项,并将选项值设置为 false。仅当任务不存在具有相同名称的其他选项时,才会创建相反的选项。

DoubleProperty<Double>

描述具有双精度值的选项。
在命令行中传递该选项还需要一个值,例如,--factor=2.2--factor 2.2

IntegerProperty<Integer>

描述具有整数值的选项。
在命令行中传递该选项还需要一个值,例如,--network-timeout=5000--network-timeout 5000

LongProperty<Long>

描述具有长整数值的选项。
在命令行中传递该选项还需要一个值,例如,--threshold=2147483648--threshold 2147483648

StringProperty<String>

描述具有任意字符串值的选项。
在命令行中传递该选项还需要一个值,例如,--container-id=2x94held--container-id 2x94held

enumProperty<enum>

将选项描述为枚举类型。
在命令行中传递选项时还需要一个值,例如 --log-level=DEBUG--log-level debug
值不区分大小写。

List<T>,其中 TDoubleIntegerLongStringenum

描述一个选项,该选项可以采用给定类型的多个值。
选项的值必须作为多个声明提供,例如 --image-id=123 --image-id=456
目前不支持其他符号,例如逗号分隔的列表或用空格字符分隔的多个值。

ListProperty<T>SetProperty<T>,其中 TDoubleIntegerLongStringenum

描述一个选项,该选项可以采用给定类型的多个值。
选项的值必须作为多个声明提供,例如 --image-id=123 --image-id=456
目前不支持其他符号,例如逗号分隔的列表或用空格字符分隔的多个值。

DirectoryPropertyRegularFileProperty

描述具有文件系统元素的选项。
在命令行中传递选项时还需要一个表示路径的值,例如 --output-file=file.txt--output-dir outputDir
相对路径相对于拥有此属性实例的项目的项目目录进行解析。请参阅 FileSystemLocationProperty.set()

记录选项的可用值

从理论上讲,属性类型为 StringList<String> 的选项可以接受任何任意值。可以使用注释 OptionValues 以编程方式记录此类选项的接受值

@OptionValues('file')

此注释可以分配给返回受支持数据类型之一的 List 的任何方法。您需要指定选项标识符以指示选项与可用值之间的关系。

在命令行中传递选项不支持的值不会导致构建失败或引发异常。您必须在任务操作中实现此类行为的自定义逻辑。

以下示例演示了如何针对单项任务使用多个选项。任务实现提供了一个可用值列表,用于选项 output-type

UrlProcess.java
import org.gradle.api.tasks.options.Option;
import org.gradle.api.tasks.options.OptionValues;

public abstract class UrlProcess extends DefaultTask {
    private String url;
    private OutputType outputType;

    @Input
    @Option(option = "http", description = "Configures the http protocol to be allowed.")
    public abstract Property<Boolean> getHttp();

    @Option(option = "url", description = "Configures the URL to send the request to.")
    public void setUrl(String url) {
        if (!getHttp().getOrElse(true) && url.startsWith("http://")) {
            throw new IllegalArgumentException("HTTP is not allowed");
        } else {
            this.url = url;
        }
    }

    @Input
    public String getUrl() {
        return url;
    }

    @Option(option = "output-type", description = "Configures the output type.")
    public void setOutputType(OutputType outputType) {
        this.outputType = outputType;
    }

    @OptionValues("output-type")
    public List<OutputType> getAvailableOutputTypes() {
        return new ArrayList<OutputType>(Arrays.asList(OutputType.values()));
    }

    @Input
    public OutputType getOutputType() {
        return outputType;
    }

    @TaskAction
    public void process() {
        getLogger().quiet("Writing out the URL response from '{}' to '{}'", url, outputType);

        // retrieve content from URL and write to output
    }

    private static enum OutputType {
        CONSOLE, FILE
    }
}

列出命令行选项

使用注释 OptionOptionValues 的命令行选项是自文档化的。

您将在 help 任务的控制台输出中看到 已声明选项 及其 可用值。输出按字母顺序呈现选项,布尔禁用选项除外,它们出现在启用选项之后

$ gradle -q help --task processUrl
Detailed task information for processUrl

Path
     :processUrl

Type
     UrlProcess (UrlProcess)

Options
     --http     Configures the http protocol to be allowed.

     --no-http     Disables option --http.

     --output-type     Configures the output type.
                       Available values are:
                            CONSOLE
                            FILE

     --url     Configures the URL to send the request to.

     --rerun     Causes the task to be re-run even if up-to-date.

Description
     -

Group
     -

限制

目前,声明命令行选项的支持有一些限制。

  • 只能通过注释为自定义任务声明命令行选项。没有用于定义选项的编程等效项。

  • 无法全局声明选项,例如,在项目级别或作为插件的一部分。

  • 在命令行上分配选项时,公开该选项的任务需要明确说明,例如,gradle check --tests abc 不起作用,即使 check 任务依赖于 test 任务。

  • 如果您指定与内置 Gradle 选项的名称冲突的任务选项名称,请在调用任务之前使用 -- 分隔符来引用该选项。有关更多信息,请参阅 区分任务选项与内置选项

验证失败

通常,任务执行期间引发的异常会导致立即终止构建的失败。任务的结果将为 FAILED,构建的结果将为 FAILED,并且不会执行进一步的任务。在 使用 --continue 标志运行 时,Gradle 将在遇到任务失败后继续运行构建中其他请求的任务。但是,任何依赖于失败任务的任务都不会执行。

当下游任务仅依赖于失败任务的输出时,有一种特殊类型的异常表现不同。任务可以抛出 VerificationException 的子类型,以指示它已受控地失败,以便其输出仍然对使用者有效。当任务使用 dependsOn 直接依赖于另一个任务时,它依赖于另一个任务的结果。当使用 --continue 运行 Gradle 时,依赖于生产者任务输出(通过任务输入和输出之间的关系)的使用者任务仍可以在使用者失败后运行。

例如,失败的单元测试将导致测试任务的失败结果。但是,这并不会阻止其他任务读取和处理任务产生的(有效的)测试结果。验证失败正是以这种方式被 测试报告聚合插件 使用的。

验证失败对于需要在产生其他任务可使用的有用输出后报告失败的任务也很有用。

build.gradle.kts
val process = tasks.register("process") {
    val outputFile = layout.buildDirectory.file("processed.log")
    outputs.files(outputFile) (1)

    doLast {
        val logFile = outputFile.get().asFile
        logFile.appendText("Step 1 Complete.") (2)
        throw VerificationException("Process failed!") (3)
        logFile.appendText("Step 2 Complete.") (4)
    }
}

tasks.register("postProcess") {
    inputs.files(process) (5)

    doLast {
        println("Results: ${inputs.files.singleFile.readText()}") (6)
    }
}
build.gradle
tasks.register("process") {
    def outputFile = layout.buildDirectory.file("processed.log")
    outputs.files(outputFile) (1)

    doLast {
        def logFile = outputFile.get().asFile
        logFile << "Step 1 Complete." (2)
        throw new VerificationException("Process failed!") (3)
        logFile << "Step 2 Complete." (4)
    }
}

tasks.register("postProcess") {
    inputs.files(tasks.named("process")) (5)

    doLast {
        println("Results: ${inputs.files.singleFile.text}") (6)
    }
}
$ gradle postProcess --continue
> Task :process FAILED

> Task :postProcess
Results: Step 1 Complete.
2 actionable tasks: 2 executed

FAILURE: Build failed with an exception.
1 注册输出process 任务将它的输出写入日志文件。
2 修改输出:任务在执行时写入它的输出文件。
3 任务失败:任务抛出 VerificationException 并在此处失败。
4 继续修改输出:由于异常停止了任务,此行永远不会运行。
5 使用输出postProcess 任务依赖于 process 任务的输出,因为它使用该任务的输出作为它自己的输入。
6 使用部分结果:设置 --continue 标志后,尽管 process 任务失败,Gradle 仍会运行请求的 postProcess 任务。postProcess 可以读取和显示部分(但仍然有效)结果。