1. 程式人生 > >gradle學習(二十三)——自定義任務類

gradle學習(二十三)——自定義任務類

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。