前言
Gradle
自定义Task
看起来非常简单,通过tasks.register
等API
就可以轻松实现。但实际上为了写出高效的,可缓存的,不拖慢编译速度的task
,还需要了解更多知识。
本文主要包括以下内容:
- 定义
Task
- 查找
Task
- 配置
Task
- 将参数传递给
Task
构造函数 Task
添加依赖Task排序
Task
添加说明- 跳过
Task
Task
支持增量编译Finalizer Task
定义Task
如上所说,自定义Task
一般可以通过register API
实现
tasks.register("hello") {
doLast {
println("hello")
}
}
tasks.register<Copy>("copy") {
from(file("srcDir"))
into(buildDir)
}
复制代码
如果是kotlin
或者kts
中,也可以通过代理来实现
val hello by tasks.registering {
doLast {
println("hello")
}
}
val copy by tasks.registering(Copy::class) {
from(file("srcDir"))
into(buildDir)
}
复制代码
register
与create
的区别
除了上面介绍的register
,其实create
也可以用于创建Task
,那么它们有什么区别呢?
- 通过
register
创建时,只有在这个task
被需要时才会真正创建与配置该Task
(被需要是指在本次构建中需要执行该Task
) - 通过
create
创建时,则会立即创建与配置该Task
总得来说,通过register
创建Task
性能更好,更推荐使用
查找Task
我们有时需要查找Task
,比如需要配置或者依赖某个Task
,我们可以通过named
方法来查找对应名字的task
tasks.register("hello")
tasks.register<Copy>("copy")
println(tasks.named("hello").get().name) // or just 'tasks.hello' if the task was added by a plugin
println(tasks.named<Copy>("copy").get().destinationDir)
复制代码
也可以使用tasks.withType()
方法来查找特定类型的Task
tasks.withType<Tar>().configureEach {
enabled = false
}
tasks.register("test") {
dependsOn(tasks.withType<Copy>())
}
复制代码
除了上述方法,也可以通过tasks.getByPath()
方法来查找task
,不过这种方式破坏了configuration avoidance
和project isolation
,因此不被推荐使用
配置Task
在创建了Task
之后,我们常常需要配置Task
我们可以在查找到Task
之后进行配置
tasks.named<Copy>("myCopy") {
from("resources")
into("target")
include("**/*.txt", "**/*.xml", "**/*.properties")
}
复制代码
我们还可以将Task
引用存储在变量中,并用于稍后在脚本中进一步配置任务。
val myCopy by tasks.existing(Copy::class) {
from("resources")
into("target")
}
myCopy {
include("**/*.txt", "**/*.xml", "**/*.properties")
}
复制代码
我们也可以在定义Task
时进行配置,这也是最常用的一种
tasks.register<Copy>("copy") {
from("resources")
into("target")
include("**/*.txt", "**/*.xml", "**/*.properties")
}
复制代码
将参数传递给Task
构造函数
除了在Task
创建后配置参数,我们也可以将参数传递给Task
的构建函数,为了实现这点,我们必须使用@Inject
注解
abstract class CustomTask @Inject constructor(
private val message: String,
private val number: Int
) : DefaultTask()
复制代码
然后,我们可以创建一个Task
,在参数列表的末尾传递构造函数参数。
tasks.register<CustomTask>("myTask", "hello", 42)
复制代码
需要注意的是,在任何情况下,作为构造函数参数传递的值都必须是非空的。如果您尝试传递一个null
值,Gradle
将抛出一个NullPointerException
指示哪个运行时值是null
.
Task
添加依赖
有几种方法可以定义Task
的依赖关系,首先我们可以通过名称定义依赖项
project("project-a") {
tasks.register("taskX") {
dependsOn(":project-b:taskY")
doLast {
println("taskX")
}
}
}
project("project-b") {
tasks.register("taskY") {
doLast {
println("taskY")
}
}
}
复制代码
其次我们也可以通过Task
对象定义依赖项
val taskX by tasks.registering {
doLast {
println("taskX")
}
}
val taskY by tasks.registering {
doLast {
println("taskY")
}
}
taskX {
dependsOn(taskY)
}
复制代码
还有一些更高端的用法,我们可以用provider
懒加载块来定义依赖项,在evaluated
阶段,provider
被传递给正在计算依赖的task
provider
块应返回单个对象Task
或Task
对象集合,然后将其视为任务的依赖项,如下所示:taskx
添加了所有以lib
开头的对象
val taskX by tasks.registering {
doLast {
println("taskX")
}
}
// Using a Gradle Provider
taskX {
dependsOn(provider {
tasks.filter { task -> task.name.startsWith("lib") }
})
}
tasks.register("lib1") {
doLast {
println("lib1")
}
}
tasks.register("lib2") {
doLast {
println("lib2")
}
}
tasks.register("notALib") {
doLast {
println("notALib")
}
}
复制代码
Task
排序
有时候,两个task
之间没有依赖关系,但是对两个task
的执行顺序却有所要求
任务排序和任务依赖之间的主要区别在于,排序规则不会影响将执行哪些任务,只会影响它们的执行顺序。
任务排序在许多场景中都很有用:
- 强制执行任务的顺序:例如,
build
永远不会在clean
之前运行。 - 在构建的早期运行构建验证:例如,在开始发布构建工作之前验证我是否拥有正确的凭据。
- 通过在长时间验证任务之前运行快速验证任务来更快地获得反馈:例如,单元测试应该在集成测试之前运行。
- 聚合特定类型的所有任务的结果的任务:例如测试报告任务组合所有已执行测试任务的输出。
gradle
提供了两个可用的排序规则:mustRunAfter
和 shouldRunAfter
当您使用mustRunAfter
排序规则时,您指定taskB
必须始终在taskA
之后运行,这表示为taskB.mustRunAfter(taskA)
而shouldRunAfter
规则理加弱化,因为在两种情况下这条规则会被忽略。一是使用这条规则会导致先后顺序成环的情况,二是当并行执行task
,并且任务的所有依赖关系都已经满足时,那么无论它的shouldRunAfter
依赖关系是否已经运行,这个任务都会运行。因此您应该在排序有帮助但不是严格要求的情况下使用shouldRunAfter
示例如下:
val taskX by tasks.registering {
doLast {
println("taskX")
}
}
val taskY by tasks.registering {
doLast {
println("taskY")
}
}
// mustRunAfter
taskY {
mustRunAfter(taskX)
}
// shouldRunAfter
taskY {
shouldRunAfter(taskX)
}
复制代码
需要注意的是,B.mustRunAfter(A)
或B.shouldRunAfter(A)
并不意味着任务之间存在任何执行依赖关系:
我们可以独立执行A
或者任务B
。排序规则仅在两个任务都计划执行时才有效。
Task
添加说明
您可以为Task
添加说明。执行时gradle tasks
时会显示此说明。
tasks.register<Copy>("copy") {
description = "Copies the resource directory to the target directory."
from("resources")
into("target")
include("**/*.txt", "**/*.xml", "**/*.properties")
}
复制代码
跳过Task
gradle
提供了多种方式来跳过task
的执行
使用onlyIf
你可以通过onlyIf
为任务的执行添加条件,如果任务应该执行,则应该返回 true
,如果应该跳过任务,则返回 false
val hello by tasks.registering {
doLast {
println("hello world")
}
}
hello {
onlyIf { !project.hasProperty("skipHello") }
}
复制代码
Output of gradle hello -PskipHello
> gradle hello -PskipHello
> Task :hello SKIPPED
复制代码
如上所示,hello
任务被跳过了
使用 StopExecutionException
如果跳过任务逻辑不能使用onlyIf
实现,您可以使用StopExecutionException
。如果某个Action
抛出此异常,则跳过该Action
的进一步执行以及该任务的任何后续Action
的执行。构建继续执行下一个任务。
val compile by tasks.registering {
doLast {
println("We are doing the compile.")
}
}
compile {
doFirst {
// Here you would put arbitrary conditions in real life.
if (true) {
throw StopExecutionException()
}
}
}
tasks.register("myTask") {
dependsOn(compile)
doLast {
println("I am not affected")
}
}
复制代码
禁用与启用Task
每个任务都有一个enabled
的标志位,默认为true
。将其设置为false
可以阻止执行任何Task
的执行。禁用的任务将被标记为 SKIPPED
。
val disableMe by tasks.registering {
doLast {
println("This should not be printed if the task is disabled.")
}
}
disableMe {
enabled = false
}
复制代码
Task
超时
每个Task
都有一个timeout
属性,可用于限制其执行时间。当一个任务达到它的超时时间时,它的任务执行线程被中断。该任务将被标记为失败。但是Finalizer Task
任务仍将运行。
如果构建时使用了--continue
参数,其他任务可以在它之后继续运行。不响应中断的task
不能超时。Gradle
的所有内置task
都会及时响应超时
Task
支持增量编译
任何构建工具的一个重要部分是避免重复工作。在编译过程中,就是在编译源文件后,除非发生了影响输出的更改(例如源文件的修改或输出文件的删除),无需重新编译它们。因为编译可能会花费大量时间,因此在不需要时跳过该步骤可以节省大量时间。
Gradle
支持增量构建,当您运行构建时,有些Task
被标记为UP-TO-DATE
,这就是增量编译生效了
那么Gradle
增量编译如何工作?自定义Task
如何支持增量编译?我们一起来看看
Task
的输入输出
Task
最基本的功能就是接受一些输入,进行一系列运算后生成输出。比如在编译过程中,Java
源文件是输入,生成的classes
文件是输出。其他输入可能包括诸如是否应包含调试信息之类的内容。
task
输入的一个重要特征是它会影响一个或多个输出,从上图中可以看出。根据源文件的内容和target jdk
版本,会生成不同的字节码。这使他们成为task
输入。
但是编译期的一些其他属性,比如编译最大可用内存,由memoryMaximumSize
属性决定,memoryMaximumSize
对生成的字节码没有影响。因此,memoryMaximumSize
不是task
输入,它只是一个内部task
属性。
作为增量构建的一部分,Gradle
会检查自上次构建以来是task
的输入或输出有没有发生变化。如果没有,Gradle
可以认为task
是最新的,因此跳过执行其action
。需要注意的是,除非task
至少有一个task
输出,否则增量构建将不起作用
总得来说:您需要告诉 Gradle
哪些task
属性是输入,哪些是输出。如果task
属性影响输出,请务必将其注册为输入,否则该任务将被认为是最新的而不是最新的。相反,如果属性不影响输出,则不要将其注册为输入,否则任务可能会在不需要时执行。还要注意可能为完全相同的输入生成不同输出的非确定性task
:不应将这些任务配置为增量构建,因为最新检查将不起作用。
接下来让我们看看如何将task
属性注册为输入和输出。
自定义task
类型
为了让自定义task
支持增量编译,只需要以下两个步骤
- 为每个
task
输入和输出创建类型化属性(通过getter
方法) - 为每个属性添加适当的注解
Gradle
支持四种主要的输入和输出类型:
- 简单值
例如字符串和数字类型。更一般地说,任何一个实现了Serializable
的类型。 - 文件系统类型
包括RegularFile
,Directory
和标准File
类,也包括Gradle
的FileCollection
类型的派生类,以及任何可以被Project.file(java.lang.Object)
和Project.files(java.lang.Object...)
方法接收的参数 - 依赖解析结果
这包括包含Artifact
元数据的ResolvedArtifactResult
类型和包含依赖图的ResolvedComponentResult
类型。请注意,它们仅支持包装在Provider
中. - 包装类型
不符合其他几个类型但具有自己的输入或输出属性的自定义类型。task
的输入或输出包装在这些自定义类型中。
接下来我们看个例子
假设您有一个task
处理不同类型的模板,例如 FreeMarker
、Velocity
、Moustache
等。它获取模板源文件并将它们与一些模型数据结合以生成不同结果。
此任务将具有三个输入和一个输出:
- 模板源文件
- 模型数据
- 模板引擎
- 输出文件的写入位置
在编写自定义task
类时,我们很容易通过注解将属性注册为输入或输出
public abstract class ProcessTemplates extends DefaultTask {
@Input
public abstract Property<TemplateEngineType> getTemplateEngine();
@InputFiles
public abstract ConfigurableFileCollection getSourceFiles();
@Nested
public abstract TemplateData getTemplateData();
@OutputDirectory
public abstract DirectoryProperty getOutputDir();
@TaskAction
public void processTemplates() {
// ...
}
}
public abstract class TemplateData {
@Input
public abstract Property<String> getName();
@Input
public abstract MapProperty<String, String> getVariables();
}
复制代码
可以看出,我们定义了3个输入,一个输出
templateEngine
,表示使用什么模板引擎,我们传入一个枚举类型,枚举类型都实现了Serializable
,因此可作为输入sourceFiles
,表示源文件,我们传入FileCollection
作为输入templateData
,表示模型数据,自定义类型,在它的内部包装了真正的输入,通过@Nested
注解表示outputDir
,表示输出目录,表示单个目录的属性需要@OutputDirectory
注解
当我们重复运行以上task
之后,就可以看到以下输出
> gradle processTemplates
> Task :processTemplates UP-TO-DATE
BUILD SUCCESSFUL in 0s
3 actionable tasks: 3 up-to-date
复制代码
如上所示,task
在执行过程中会判断输入输出有没有发生变化,由于task
的输入输出都没有发生变化,该task
可以直接跳过,展示为up-to-date
除了上述几种注解,还有其他常用注解如@Internal
,@Optional
,@Classpath
等,具体可查看文档:Incremental build property type annotations
声明输入输出的好处
一旦你声明了一个task
的正式输入和输出,Gradle
就可以推断出关于这些属性的一些事情。例如,如果一个task
的输入设置为另一个task
的输出,这意味着第一个task
依赖于第二个,gradle
可以推断出这一点并添加隐式依赖
推断task
依赖关系
想象一个归档task
,会将processTemplates task
的输出归档。可以看到归档task
显然需要processTemplates
首先运行,因此可能会添加显式的dependsOn
. 但是,如果您像这样定义归档task
:
tasks.register<Zip>("packageFiles") {
from(processTemplates.map {it.outputs })
}
复制代码
Gradle
会自动使packageFiles
依赖processTemplates
。它可以这样做是因为它知道 packageFiles
的输入之一需要 processTemplates
任务的输出。我们称之为推断的task
依赖。
上面的例子也可以写成
tasks.register<Zip>("packageFiles2") {
from(processTemplates)
}
复制代码
这是因为from()
方法可以接受task
对象作为参数。然后在幕后,from()
使用project.files()
方法包装参数,进而将task
的正式输出转化为文件集合
输入和输出验证
增量构建注解为 Gradle
提供了足够的信息来对带注解的属性执行一些基本验证。它会在task
执行之前对每个属性执行以下操作:
@InputFile
- 验证属性是否有值,并且路径是否对应于存在的文件(不是目录)。@InputDirectory
- 与@InputFile
相同,但路径必须对应于目录。@OutputDirectory
- 验证路径是否是个目录,如果该目录尚不存在,则创建该目录。
如果一个task
在某个位置产生输出,而另一个任务task
将其作为输入使用,则 Gradle
会检查消费者任务是否依赖于生产者任务。当生产者和消费者任务同时执行时,构建就会失败。
此类验证提高了构建的稳健性,使您能够快速识别与输入和输出相关的问题。
您偶尔会想要禁用某些验证,特别是当输入文件可能实际上不存在时。这就是 Gradle
提供@Optional
注释的原因:您使用它来告诉 Gradle
特定输入是可选的,因此如果相应的文件或目录不存在,则构建不应失败。
并行task
定义task
输入和输出的另一个好处是:当使用--parallel
选项时,Gradle
可以使用此信息来决定如何运行task
。
例如,Gradle
将在选择下一个要运行的任务时检查task
的输出,并避免并发执行写入同一输出目录的任务。
同样,当另一个task
正在运行消耗或创建一些文件时,Gradle
将使用有关task
销毁哪些文件的信息(例如,由Destroys
注释)来避免运行删除这些文件的task
,反之亦然。
它还可以确定创建一组文件的task
已经运行,并且使用这些文件的task
尚未运行,并且将避免在这中间运行删除这些文件的task
。
总得来说,通过以这种方式提供task
的输入和输出信息,Gradle
可以推断task
之间的创建/消费/销毁关系,并可以确保task
执行不会违反这些关系。
增量编译原理解析
上面我们介绍了如何自定义一个支持增量编译的task
,那么它的原理是什么呢?
在第一次执行task
之前,Gradle
会获取输入的指纹。该指纹包含输入文件的路径和每个文件内容的哈希值。Gradle
然后执行task
。如果任务成功完成,Gradle
会获取输出的指纹。该指纹包含一组输出文件和每个文件内容的哈希值。Gradle
会在下次执行task
时保留两个指纹。
之后每次执行task
之前,Gradle
都会获取输入和输出的新指纹。如果新指纹与之前的指纹相同,Gradle
会假定输出是最新的并跳过该task
。如果它们不相同,Gradle
将执行task
。Gradle
会在下次执行task
时保留两个指纹。
如果文件的统计信息(即lastModified
和size
)没有改变,Gradle
将重用上次运行的文件指纹。这意味着当文件的统计信息没有更改时,Gradle
不会检测到更改。
Gradle
还将task
的代码视为task
输入的一部分。当task
、其操作或其依赖项在执行之间发生变化时,Gradle
会认为该task
已过期。
Gradle
了解文件属性(例如,包含 Java classpath
的属性)是否是顺序敏感的。在比较此类属性的指纹时,即使文件顺序发生变化也会导致task
过时。
请注意,如果task
指定了输出目录,则自上次执行以来添加到该目录的任何文件都将被忽略,并且不会导致任务过期。这是因为不相关的任务可以共享一个输出目录而不会相互干扰。如果由于某种原因这不是您想要的行为,请考虑使用TaskOutputs.upToDateWhen(groovy.lang.Closure)
一些高端操作
上面介绍的内容涵盖了您将遇到的大多数用例,但有些场景需要特殊处理
将@OutputDirectory
链接到@InputFiles
当您想将一个task
的输出链接到另一个task
的输入时,类型通常匹配,例如,File
可以将输出属性分配给File
输入。
不幸的是,当您希望将一个task
的@OutputDirectory
中的文件作为另一个task
的@InputFiles
属性(类型FileCollection
)的源时,这种方法就会失效。
例如,假设您想使用 Java
编译task
的输出(通过destinationDir
属性)作为自定义task
的输入,该task
检测一组包含 Java
字节码的文件。这个自定义task
,我们称之为Instrument
,有一个使用@InputFiles
注解的classFiles
属性。您最初可能会尝试像这样配置task
:
tasks.register<Instrument>("badInstrumentClasses") {
classFiles.from(fileTree(tasks.compileJava.map { it.destinationDir }))
destinationDir.set(file(layout.buildDirectory.dir("instrumented")))
}
复制代码
这段代码没有明显的问题,但是您如果实际运行的话可以看到compileJava
并没有执行。在这种情况下,您需要通过dependsOn
在instrumentClasses
和compileJava
之间添加显式依赖。因为使用fileTree()
意味着 Gradle
无法推断task
依赖本身。
一种解决方案是使用TaskOutputs.files
属性,如以下示例所示:
tasks.register<Instrument>("instrumentClasses") {
classFiles.from(tasks.compileJava.map { it.outputs.files })
destinationDir.set(file(layout.buildDirectory.dir("instrumented")))
}
复制代码
或者,您可以使用project.files()
,project.layout.files()
,project.objects.fileCollection()
来代替project.fileTree()
tasks.register<Instrument>("instrumentClasses2") {
classFiles.from(layout.files(tasks.compileJava))
destinationDir.set(file(layout.buildDirectory.dir("instrumented")))
}
复制代码
请记住files()
,layout.files()
和objects.fileCollection()
可以将task
作为参数,而fileTree()
不能。
这种方法的缺点是源task
的所有文件输出都成为目标task
的输入文件。如果源task
只有一个基于文件的输出,就像JavaCompile
一样,那很好。但是如果你必须在多个输出属性中选择一个,那么你需要明确告诉 Gradle
哪个task
使用以下builtBy
方法生成输入文件:
tasks.register<Instrument>("instrumentClassesBuiltBy") {
classFiles.from(fileTree(tasks.compileJava.map { it.destinationDir }) {
builtBy(tasks.compileJava)
})
destinationDir.set(file(layout.buildDirectory.dir("instrumented")))
}
复制代码
当然你也可以通过dependsOn
添加明确的task
依赖,但是上面的方法提供了更多的语义,解释了为什么compileJava
必须预先运行。
禁用up-to-date
检查
Gradle
会自动处理对输出文件和目录的up-to-date
检查,但如果task
输出完全是另一回事呢?也许它是对 Web
服务或数据库表的更新。或者有时你有一个应该始终运行的task
。
这就是doNotTrackState
的作用,可以使用它来完全禁用task
的up-to-date
检查,如下所示:
tasks.register<Instrument>("alwaysInstrumentClasses") {
classFiles.from(layout.files(tasks.compileJava))
destinationDir.set(file(layout.buildDirectory.dir("instrumented")))
doNotTrackState("Instrumentation needs to re-run every time")
}
复制代码
如果你的自定义task
需要始终运行,那么您也可以在任务类上使用注解@UntrackedTaskTask
提供自定义up-to-date
检查
Gradle
会自动处理对输出文件和目录的up-to-date
检查,但如果task
输出是对 Web
服务或数据库表的更新。在这种情况下,Gradle
无法知道如何检查task
是否是up-to-date
的。
这是就是TaskOutputs.upToDateWhen
方法的作用,使用它我们就可以自定义up-to-date
检查的逻辑。例如,您可以从数据库中读取数据库模式的版本号。或者,您可以检查数据库表中的特定记录是否存在或已更改。
请注意,up-to-date
检查应该节省您的时间。不要添加比task
的标准执行花费更多时间的检查。事实上,如果一个task
经常需要运行,因为它很少是up-to-date
的,那么它可能根本不值得进行up-to-date
检查,如禁用up-to-date
中所述。
一个常见的错误是使用upToDateWhen
()而不是Task.onlyIf()
. 如果您想根据与task
输入和输出无关的某些条件跳过任务,那么您应该使用onlyIf()
. 例如,如果您想在设置或未设置特定属性时跳过task
Finalizer Task
我们常常使用dependsOn
来在一个task
之前做一些工作,但如果我们想要在task
执行之后做一些操作,该怎么实现呢?
这里我们可以用到finalizedBy
方法
val taskX by tasks.registering {
doLast {
println("taskX")
}
}
val taskY by tasks.registering {
doLast {
println("taskY")
}
}
taskX { finalizedBy(taskY) }
复制代码
如上所示,taskY
将在taskX
之后执行,需要注意的是finalizedBy
并不是依赖关系,就算taskX
执行失败,taskY
也将正常执行
Finalizer task
在构建创建的资源无论构建失败还是成功都必须清理的情况下很有用,一个示例是在集成测试任务中启动的 Web
容器,即使某些测试失败,也应该始终关闭它。这样看来finalizedBy
类似java
中的finally
要指定Finalizer task
,请使用Task.finalizedBy(java.lang.Object...)
方法。此方法接受task
实例、task
名称或Task.dependsOn(java.lang.Object...)
接受的任何其他输入
总结
到这里这篇文章已经相当长了,gradle
自定义task
上手非常简单,但实际上有非常多的细节,尤其是要支持增量编译时。总得来说,为了写出高效的,可缓存的,不拖慢编译速度的task
,还是有必要了解一下这些知识的
参考资料
作者:程序员江同学
链接:https://juejin.cn/post/7135065142768697380
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。