gradle学习(二十三)——自定义任务类

时间:2022-01-13 13:12:14

title: “Gradle学习(二十三)——自定义任务类”
date: “2018-03-21”
description: “Gradle提供两种类型的任务,一种是简单的任务,它在action的闭包中定义。对于这种任务,action闭包就决定了任务的行为。这类任务适合在构建脚本中实现一次性的任务。另一种任务就是增强型的任务,行为被构建到任务中,任务提供了一些行为,你可以通过这些属性来配置任务。

tags:
- gradle
categories:
- 架构设计

image: img/201801/xuejing6.jpg

Gradle提供两种类型的任务,一种是简单的任务,它在action的闭包中定义。对于这种任务,action闭包就决定了任务的行为。这类任务适合在构建脚本中实现一次性的任务。

另一种任务就是增强型的任务,行为被构建到任务中,任务提供了一些行为,你可以通过这些属性来配置任务。在增强任务中,你不需要像简单任务那样实现任务的行为,你仅仅需要定义任务并且通过属性配置任务即可。也就是说增强任务可以让你在不同的地方实现重用任务的行为,还可以跨越不同的构建。

增强任务的行为和属性是由任务的类定义的。当你定义一个增强任务时,你需要指定任务的类型或者任务的类。

在Gradle中实现你的自定义任务类是非常简单的,可以用任何jvm类型的语言,比如java,groovy,kotlin,scala等。在我们的例子中我们使用Groovy作为实现语言。

包装任务类

有多种方法可以来放置任务类的源码

  • 构建脚本
    你可以在构建脚本中直接包含任务类,你不需要做任何事情,任务类就会自动编译并且放到构建脚本的classpath中。这些类在构建脚本之外是不可见的。因此你不能在定义这些任务的构建脚本之外来重用这些类。
  • buildSrc项目
    你也可以吧任务类的源码放在rootProjectDir/buildSrc/src/main/groovy目录,Gradle会负责编译和测试这些类,并且保证在构建脚本的classpath中这些类是可获得的。当然和上面一样,它在构建之外也是不可见的,你不能在构建之外的地方重用它。使用buildSrc项目可以将任务进行分离,任务做什么由脚本去定义,而任务怎么做由buildSrc项目中的任务类去定义。
  • 单独的项目
    你也可以为你的任务类单独创建一个项目。这个项目生成jar并且发布出去,你可以在多个构建中重用它。通常这个jar包含几个自定义插件,并且几个相关的任务,或者兼而有之

第一种和第二种其实差不多,只是放置的地方不一样,我们例子相对很简单,只说第一种好第三种就OK了

编写自定义任务

为了实现自定义任务,你需要继承DefaultTask

build.gradle

class GreetingTask extends DefaultTask {
}

这个任务没有实现任何有用的事情,我们来个添加一个方法并且加上@TaskAction注解,当任务执行的时候Gradle将会调用这个方法,你并不需要使用方法来给任务添加行为。

build.gradle

class GreetingTask extends DefaultTask {
    @TaskAction
    def greet() {
        println "hello from GreetingTask"
    }
}

task hello(type: GreetingTask)

执行任务

± % gradle -q hello
hello from GreetingTask

我们给任务类增加一个属性然后配置它。任务仅仅是个POGOs,当你定义任务时你就可以设置这个属性并且调用任务对象的方法。我们这里增加一个greeting属性,并且定义greeting任务时设置它的值

build.gradle

class GreetingTask extends DefaultTask {
    def greeting = "hello from GreetingTask"

    @TaskAction
    def greet() {
        println greeting
    }
}

task hello(type: GreetingTask)

task greeting(type: GreetingTask) {
    greeting = "greeting from GreetingTask"
}

然后执行任务

± % gradle -q hello greeting                                                          
hello from GreetingTask
greeting from GreetingTask

独立的项目

现在我们把任务转移到一个独立的项目中,然后将其发布,让其他项目可以使用它。这个项目是个简单的生成jar包的groovy项目,可以用gradle init --type=groovy-library -x wrapper来做初始化。下面的构建脚本是引入groovy插件和GradleApi的类库,并且通过maven-publish发布到maven库。

plugins { id 'groovy' id 'maven-publish' }

dependencies { compile gradleApi() compile localGroovy() }

publishing { publications { maven(MavenPublication) { groupId 'com.lastsweetop' artifactId 'custom-plugin' version '1.4' from components.java }
    }
    repositories { maven { url "$buildDir/repo" }
    }
}

然后把我们之前的代码放在groovy的源码目录

package com.lastsweetop.tasks

import org.gradle.api.DefaultTask
import org.gradle.api.tasks.TaskAction

class GreetingTask extends DefaultTask {
    def greeting = "hello from GreetingTask"

    @TaskAction
    def greet() {
        println greeting
    }
}

在另一个项目使用你的任务类

为了在构建脚本中使用这个类,你需要将类放到构建脚本的classpath中,这时候你需要buildscript { }块。下面的例子演示了如何把本地库中的包含了任务类的jar文件引入到构建脚本的classpath中,并使用它。

build.gradle

buildscript {
    repositories {
        maven {
            url '../customPlugin/build/repo'
        }
    }
    dependencies {
        classpath group:'com.lastsweetop',name:'custom-plugin',version:'1.4'
    }
}

task hello(type: com.lastsweetop.tasks.GreetingTask)

task greeting(type: com.lastsweetop.tasks.GreetingTask) {
    greeting = "greeting from GreetingTask"
}

增量任务

在Gradle中,当输入输出都是最新的时候直接跳过任务执行是非常简单的。但是有些时候从上次执行之后只有少量的输入发生了变化,你可能会想避免重新处理所有的未更改的输入文件,对一些转换任务特别有用,这些转换任务输入文件和输出文件通常是一对一的。

如果你想优化你的任务,只处理那些更改的输入文件,那么就可以使用增量任务来完成了。

增量任务的实现

想让一个任务处理增量的输入,任务就需要包含支持增量的任务的action,这个action的方法需要有个单独的IncrementalTaskInputs参数,它告诉Gradle仅仅处理那些更改的输入

增量任务action提供了IncrementalTaskInputs.outOfDate(org.gradle.api.Action)action来处理过期的输入,IncrementalTaskInputs.removed(org.gradle.api.Action)action来处理上次执行之后被删除的输入

build.gradle

class IncrementalReverseTask extends DefaultTask {
    @InputDirectory
    def File inputDir

    @OutputDirectory
    def File outputDir

    @Input
    def inputProperty

    @TaskAction
    def execute(IncrementalTaskInputs inputs) {
        println inputs.incremental ? "CHANGED inputs considered out of date" : "ALL inputs considered out of date"
        if (!inputs.incremental) {
            project.delete(outputDir.listFiles())
        }
        inputs.outOfDate { change ->
            println "out of date: ${change.file.name}"
            def targetFile = new File(outputDir, change.file.name)
            targetFile.text = change.file.text.reverse()
        }
        inputs.removed { change ->
            println "removed: ${change.file.name}"
            def targetFile = new File(outputDir, change.file.name)
            targetFile.delete()
        }
    }
}

有些情况下任务不总是增量运行的,比如增加了--rerun-tasks选项,仅仅只有outOfDate的action才会执行,即使删除了输入文件。已在编写增量任务的时候一定要考虑到这种情况,就像上面的例子那样。

这个转换任务的例子比较简单,任务的action只处理过期的输入,并且判断输入被删除时同时也删除输出文件。

一个任务仅仅只可以包含一个增量任务action

判断输入过期的依据

Gradle有之前任务执行的历史,还有会影响任务执行上下文的更改,那么就可以决定哪些输入需要被任务再次处理,在这个例子中,IncrementalTaskInputs.outOfDate(org.gradle.api.Action)action来处理修改或者新增的输入文件,而IncrementalTaskInputs.outOfDate(org.gradle.api.Action)action来处理被删除的输入文件

然而,很多情况下Gradle无法决定哪些输入文件需要被再次执行,比如以下几种情况;

  • Gradle没有之前任务执行的历史
  • 你构建时使用了不同版本的Gradle,当然不同版本的Gradle是不共享任务执行历史的
  • upToDateWhen条件总是返回false
  • 上次执行之后有个输入属性改变了
  • 上次执行之后一个或者多个输出文件改变了

在这种情况下,Gradle会把所有的输入文件当做是outOfDate的。IncrementalTaskInputs.outOfDate(org.gradle.api.Action)action会去处理每一个输入文件,而IncrementalTaskInputs.removed(org.gradle.api.Action)action却总是不会执行

你还可以使用IncrementalTaskInputs.isIncremental()来检查Gradle是否可以判断输出文件是否改变。

增量任务演示

根据上面的增量任务,我们来添加一些其他的用于测试的任务来方便演示增量任务的各种情况,

首先,先声明一个增量任务,因为是第一次执行所以所有的输入都被认为是过期的

build.gradle

task incrementalReverse(type: com.lastsweetop.tasks.IncrementalReverseTask) {
    inputDir = file('inputs')
    outputDir = file("$buildDir/outputs")
    inputProperty = project.properties['taskInputProperty'] ?: 'original'
}

构建布局

├── build.gradle
├── inputs
│   ├── 1.txt
│   ├── 2.txt
│   └── 3.txt
└── settings.gradle

然后执行任务

± % gradle -q incrementalReverse
ALL inputs considered out of date
out of date: 3.txt
out of date: 2.txt
out of date: 1.txt

当没有做任何更改,任务再次执行,那么任务就被认为是最新的,没有输入会调用到增量任务的action

± % gradle -q incrementalReverse 

当输入文件被更改或者增加了新的输入文件,这些输入文件就会传递给增量任务的actionIncrementalTaskInputs.outOfDate(org.gradle.api.Action)

build.gradle

task updateInputs() {
    doLast {
        file('inputs/1.txt').text='ajjjjjjjdjddjdjd'
        file('inputs/4.txt').text='djdjdjjdjaaaaaaa'
    }
}

然后执行任务

± % gradle -q updateInputs incrementalReverse                                        
CHANGED inputs considered out of date
out of date: 1.txt
out of date: 4.txt

当存在的输入文件被删除,再次执行增量任务,被删除的的文件就会传递给IncrementalTaskInputs.removed(org.gradle.api.Action)

task removeInputs() {
    doLast {
        file('inputs/3.txt').delete()
    }
}

然后执行任务

± % gradle -q removeInputs incrementalReverse                                        
CHANGED inputs considered out of date
removed: 3.txt

当输出文件被更改或者删除时,Gradle无法知道哪些输入是过期的,在这种情况下,所有的输入都被当做是过期的而传入到IncrementalTaskInputs.outOfDate(org.gradle.api.Action)action

task removeOutputs() {
    doLast {
        file("$buildDir/outputs/1.txt").delete()
    }
}

然后执行任务

± % gradle -q removeOutputs incrementalReverse                                     
ALL inputs considered out of date
out of date: 4.txt
out of date: 2.txt
out of date: 1.txt

当输入属性更改,Gradle不能知道这个属性会怎么样影响到输出,那么所有的输入都被当做是过期的,就像输出文件被更改一样,所有的输入文件传递给IncrementalTaskInputs.outOfDate(org.gradle.api.Action)action
执行任务

± % gradle -q -PtaskInputProperty=changed incrementalReverse                       
ALL inputs considered out of date
out of date: 4.txt
out of date: 2.txt
out of date: 1.txt

为缓存任务保存增量状态

使用Gradle的IncrementalTaskInputs属性并不是创建增量任务的唯一方法,比如Kotlin的编译器就有增量的内置特性,这种实现的典型的方法就是工具去存储之前执行状态的分析数据到一些文件中,如果这些文件可以重新定位,那么他们也会被当做任务的输出。这样当任务的结果需要从缓存中加载数据时,下次执行也可以使用这些换成的分析数据

如果这些状态文件是不可重新定位的,它们就不能通过构建缓存共享。实际上,当从构建缓存中加载任务时,这些状态文件就会被清理掉,以防旧的状态会影响到下一次的任务执行。当这些文件通过task.localState.register()方法注册或者作为属性被注解@LocalState标识时,Gradle来保证他们的删除操作

定义和使用命令行选项

有时候用户可能需要在命令行下而不是脚本中来设置暴露出来的任务的属性,比如这些属性需要频繁更改那么在命令行下来传入这些值就特别方便。GradleApi提供了一种机制可以让属性自动生成相应的可以接受参数的命令行选项。

定义命令行选项

将任务的属性暴露成命令行参数是非常简单的,你仅仅需要在属性相应的setter方法上增加@Option注解,你需要增加选项的标识符和描述。一个任务可以有多个命令行选项来对应任务中的属性。

让我通过一个例子来理解一下。自定义任务UrlVerify是用来校验给定的URL。被校验的URL通过属性url来配置,url属性的setter方法增加了@Option注解

class UrlVerify extends DefaultTask {

    private String url;

    String getUrl() {
        return url
    }

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

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

使用命令行选项

命令行选项的使用遵循以下规则:

  • 选项以双破折号开始,比如 --url,单破折号对于任务的action是不管用的
  • 选项紧跟在任务定义之后,比如 verifyUrl --url=http://www.baidu.com
  • 多个选项直接的顺序不重要,都紧跟着定义的任务就可以了

返回之前的示例,在构建脚本中定义了一个UrlVerify类型的任务

task verifyUrl(type: com.lastsweetop.tasks.UrlVerify)

然后使用命令行参数url执行任务:

± % gradle  -q verifyUrl --url=http://www.baidu.com 
Verifying URL 'http://www.baidu.com'

选项支持的数据类型

Gradle限制了作为命令行选项的数据类型,每种类型的作用都不同:

  • boolean, Boolean
    用在值是true或者false的选项,添加这个选项不需要赋值,比如--enabled相当于true,当没有选项时,就采用属性的默认值,boolean的默认值是false,就像复杂类型的默认值是null一样
  • String
    用在值是可以任意字符串的选项,添加这个选项需要用等号将选项和值分开的键值对,例如--url=http://www.baidu.com
  • enum
    用在值是枚举的选项,添加这个选项需要用等号将选项和值分开的键值对,例如--log-level=DEBUG,值不区分大小写
  • List<String>, List<enum>
    用在可以接收给定类型多个值的选项,选项的值必须显式的声明,例如--imageId=123 --imageId=456,中间不能有其他分割符,比如逗号

记录选项可用值

属性是String类型或者List<String>类型的选项理论上可以接受任意值,在@OptionValues注解的帮助下,选项的期望值可以用编程的方式用文档记录下来。这个注解可以附加到任何返回选项支持的数据类型的列表方法上,此外你还需要选项的标识符,指明选项和可用值的对应关系

要注意的是声明的可用值并不是强制的,即使输入了可用值之外的其他值,也不会报错,其中的逻辑需要用户自己去处理。

下面的例子示范了单个任务多个选项,任务还为output-type提供了可用值列表

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

    @Option(option = "url", description = "Configures the URL to be write to the output.")
    public void setUrl(String url) {
        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;
    }

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

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

        // retrieve content from URL and write to output
    }

    private static enum OutputType {
        CONSOLE, FILE
    }
}

列出命令行选项

带有Option注解和OptionValues注解的命令行选项可以自己生成文档。在help任务的控制台输出中你可以看到声明的选项和其可用值,输出的呈现顺序是以字母排序的。

± % gradle -q help --task processUrl                                                
Detailed task information for processUrl

Path
     :processUrl

Type
     UrlProcess (UrlProcess)

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

     --url     Configures the URL to be write to the output.

Description
     -

Group
     -

局限性

对命令行选项的定义的支持目前有一些限制:

  • 命令行选项目前只能通过注解实现,没有等效的编程代码可以实现
  • 选项不能定义成全局的,比如作为插件的一部分定义成项目级别
  • 给命令行选项赋值时,暴露选项的任务必须明确的声明出来,比如即使check任务依赖于test任务,gradle check --tests abc也不会执行

Worker API

从增量任务的探讨中,我们看到执行任务的工作可以看做是离散的单元(输出子集转换成输入子集),很多时候这些工作单元相互高度独立,这意味着它们可以按任意顺序执行,并且以整体的action形式简单的聚合在一起。在单线程中这些工作将会顺序执行,但是如果我们有多核处理器,那么这些独立的单元并行执行就非常爽了。如果做到这一点,我们可以更充分的利用构建时的资源,更快的完成构建任务。

Worker API提供了一种机制来完成上述工作,安全且并发的完成一个任务action内的多个工作。Worker API的好处不仅仅在于可以将action中的工作并行化,而且你还可以配置隔离的级别,这些工作不仅可以在隔离的类加载器中执行,还可以在隔离的进程中执行。通过Worker API,Gradle可以在默认情况下开始并行执行任务,也就是说一旦Gradle提交了需要异步执行的工作,退出了任务的action时,Gradle就可以开始并行的执行其他独立的任务,即使这些任务在同一个项目中

使用Worker API

为了向Worker API提交工作,必须做两件事:工作单元的实现,工作单元的配置。实现非常简单,就是实现java.lang.Runnable接口,但是这个类的构造器要加上javax.inject.Inject注解,并且接受参数配置这个类成为一个工作单元。当工作单元被提交给javax.inject.Inject时,这个类的实例就会被创建配置工作单元的参数就会被传入到构造器中。

class ReverseFile implements Runnable {
    File fileToReverse
    File destinationFile

    @Inject
    ReverseFile(File fileToReverse, File destinationFile) {
        this.fileToReverse = fileToReverse
        this.destinationFile = destinationFile
    }

    @Override
    void run() {
        destinationFile.text = fileToReverse.text.reverse()
    }
}

为了提交工作单元你需要先获得WorkerExecutor实例,这就需要接受WorkerExecutor参数的构造器,并且有附加javax.inject.Inject注解,Gradle在任务创建的时候就会注入WorkerExecutor实例。

工作单元的配置是由WorkerConfiguration来实现的,在工作单元被提交的时候配置一个对象的实例来实现对工作单元的配置。

class ReverseFiles extends SourceTask {
    final WorkerExecutor workerExecutor

    @OutputDirectory
    File outputDir

    @Inject
    ReverseFiles(WorkerExecutor workerExecutor) {
        this.workerExecutor = workerExecutor
    }


    @TaskAction
    void reverseFiles() {
        source.files.each { file ->
            workerExecutor.submit(ReverseFile.class) { WorkerConfiguration config ->
                config.isolationMode = IsolationMode.NONE
                config.params file, project.file("${outputDir}/${file.name}")
            }
        }
    }

}

WorkerConfiguration有个params的属性,这些参数会被会被传给提交的工作单元的构造器,提交给工作单元的任意参数都必须是实现了java.io.Serializable

一旦任务action的所有工作被提交,那么任务action就可以退出了。这些工作将会被异步且并行的执行。当然依赖于这个任务的其他任务,或者任务的其他action都不会开始执行,直到这个任务的action完成。其他没有关系的独立任务就会立刻执行。

如果异步工作执行失败,那么这个任务就会失败并且抛出WorkerExecutionException异常,详细记录了失败的工作单元的异常信息。和其他任务失败一样被处理,并且依赖于此任务的任务也会被终止执行

在有些情况下,需要在任务的action退出时,等待该action的所有工作异步完成,那么可以使用WorkerExecutor.await()方法,在这种情况下,任务执行失败的异常将由WorkerExecutor.await()方法抛出

隔离模式

Gradle提供了三种隔离模式来配置工作单元,可以用枚举IsolationMode来指定

  • IsolationMode.NONE
    这种模式下的工作运行在最小隔离级别的线程中,它将共享任务加载使用的相同的类加载器,它是最快速的隔离级别
  • IsolationMode.CLASSLOADER
    这种模式下的工作运行在类加载器隔离的线程中,类加载器的classpath可以和任务单元实现类相同类加载器的classpath相同,也可以通过WorkerConfiguration.classpath(java.lang.Iterable)来使用其他的classpath
  • IsolationMode.PROCESS
    这种模式下的是最大隔离级别,工作运行在单独的进程中。进程的类加载器的classpath可以和任务单元实现类相同类加载器的classpath相同,也可以通过WorkerConfiguration.classpath(java.lang.Iterable)来使用其他的classpath。此外这个进程是个Worker的守护进程,一直保持存活以便可以让相同需要的工作单元重用,这个进程可以通过WorkerConfiguration.forkOptions(org.gradle.api.Action)配置成和Gradle不同的JVM设置。

Worker守护进程

当使用IsolationMode.PROCESS模式时,Gradle就会开启一个长期的守护进程,以便之后的工作单元重用。

workerExecutor.submit(ReverseFile.class) { WorkerConfiguration config ->
    config.isolationMode = IsolationMode.NONE
    config.forkOptions {  JavaForkOptions options ->
        options.maxHeapSize = '512m'
        options.systemProperty "org.gradle.sample.showFileSize", "true"
    }
    config.params file, project.file("${outputDir}/${file.name}")
}

当Worker守护进程的工作单元时,Gradle会先找下是否有兼容的空闲的守护进程,如果有它就把工作单元提交给空闲的守护进程,如果没有它就开启一个新的守护进程。判断是否兼容主要看一下几个指标,他们都可以通过WorkerConfiguration.forkOptions(org.gradle.api.Action)来配置

  • executable
    只有使用相同的java可执行文件,他们才会被认为是兼容的
  • classpath
    如果守护进程的classpath包含工作单元所需要的classpath,守护进程才会被认为是兼容的。
  • heap settings
    如果守护进程的堆设置比工作单元所需的堆设置配置还高时,那么就会被认为是兼容的
  • jvm arguments
    如果手机进程的JVM的参数包含了工作单元所需要的JVM参数时,则会被认为是兼容的
  • system properties
    如果守护进程的系统属性包含了工作单元相同的属性和值时,则会被认为是兼容的
  • environment variables
    如果守护进程的系统属性包含了工作单元相同的环境变量和值时,则会被认为是兼容的
  • bootstrap classpath
    如果守护进程的系统属性包含了工作单元相同的bootstrap classpath和值时,则会被认为是兼容的
  • debug
    当debug的值相同,则会被认为是兼容的
  • enable assertions
    当启用断言的值相同,则会被认为是兼容的
  • default character encoding
    当默认的字符编码值相同,则会被认为是兼容的

守护进程将会一直保持运行,直到开启他们的构建守护进程终止了或者系统内存不足的情况下才会关闭。

重用任务类之间的逻辑

重用任务类之间的逻辑有很多种不同的方法。最简单的方法是把你想要分享的逻辑提取成一个方法或者一个类,然后在任务中重用提取的代码段,比如Copy任务重用Project.copy(org.gradle.api.Action)方法的逻辑。还有一种方法就是把重用的逻辑做成任务依赖,新写的任务依赖于这个任务的输出,其他的方法还有使用我们在任务详解一章讲到的任务规则或者Worker API。