你是否对gradle如何处理task间的依赖感到好奇,创建task的方式有很多种,建立依赖的方式也很多,gradle是如何确定最终task的执行顺序的,下面我们就来探究一下
作者:近地小行星
链接:https://juejin.cn/post/7241492186919239717
先用一张图来展示task相关的概念
Creation
Task的创建
先来张图帮助理解
task的创建主要可以分为2种方式
create
register
create
会立即创建taskregister
只是注册了一个task provider
(后面再解释这个概念),此时并没有立即创建task实例,这也是官方目前推荐的task创建方式,官方plugin中task的创建方式都已修改为了register
task会在build script脚本或者plugins中,通过调用tasks.create/regster
的方式被添加到task container
中
tasks.create('hello') {
doLast {
println 'greeting'
}
}
tasks.register('hello') {
doLast {
println 'greeting'
}
}
TaskContainer
我们知道gradle对每个Project都会创建一个Project
对象与之关联,且我们在build script使用到的Task相关的方法,都会被定向到Project
对象上来,而Project
对象关于Task的处理都是委托给TaskContainer
的,可以简单的将它理解为一个存放Task的容器
从2者的签名可以看出,create
的configureClosure
是Closure类型,这个Closure是groovy.lang.Closure
,而register
是Action,2者并存,是因为早期重度使用groovy导致,前者会通过ConfigureUtil.configureUsing(configureClosure)
将closure转为action
TaskContainer
可以简单分为2部分,一个map
,一个是pendingMap
,create
创建的task是添加到map中的,register
注册的task provider放在pendingMap
中,pendingMap
中的task provider,在其task被创建时会主动添加到map
中,并从pendingMap
中移除自己
最终的task实例
是通过反射创建的,如果没有指定其Task类型,那么默认会生成DefautTask
的类型,可以在create/register
时传入构造器参数,也可以通过configure action
的方式传参
懒加载
create和register的区别
简单的可以理解为create对task的创建是eager的,而register是懒加载
gradle执行有3个阶段,initialization、configuration、execution,而不管执行哪个Task,configuration阶段都是一定存在的,在这一阶段会执行build script
如果是create
方式,那Task就会被立即创建,这其实隐含了一个问题--被创建的Task可能并不会被运行,例如在我们想要运行compileJava
这个task,build script
在eval
过程中,将test
相关的Task也都创建了
使用register
就可以规避这个问题,Task并没有立即创建,而是在需要的时候创建
这里你可能还会有疑问,虽然create
创建了Task,但是register
也是会创建Task Provider
的呀,而大部分Task在其构造器中可能并没有额外操作,register
有好在哪呢?
其实register
相比于create
,不仅是Task本身创建的时机延迟,还体现在对configuration action
的执行时机上,create
在创建完Task后是会立即对其进行configure的,而register
方式注册的Task,是在其需要时才被创建,也在那时才进行configure
官方称为task configuration avoidance,用以规避不必要的Task的创建、配置
例如使用register
替代create
使用named
替代getByName
等等
理想的task创建时间是在Task Graph calculation期间,build scan提供了可视化的数据帮助定位过早创建task的问题
可以参考官方文档task_configuration_avoidance
Lazy Properties
除了Task本身创建的lazy化外,Task的property也是可以lazy的,Task属性的lazy化主要解决的问题是,在对Task进行配置时,有些属性不一定能立刻得它的值,它可能要通过复杂的计算或者是依赖其他Task运行的结果,随着构建复杂性的增加,手动维护这些依赖关系会变得复杂,而将这些属性lazy化后,不立刻求值,等到需要的时候再去评估其值,来降低构建脚本的维护成本
Lazy Properties可以通过2种类型进行配置
Provider
Property
区别在于Property
是可变的,Provider
值是不可变的。Property
实际上是Provider
的子类register
方法返回的Task Provider
正是Provider
的子类
Property
有get/set方法设置和获取值Provider
只能get获取值
属性也可以通过Extension
设置
interface CompileExtension {
Property<String> getClasspath()
}
abstract class Compile extends DefaultTask {
@Input
abstract Property<String> getJdkVersion()
@Input
abstract Property<String> getClasspath()
}
project.extensions.create('compile', CompileExtension)
def a = tasks.register('a', Compile) {
classpath = compile.classpath
jdkVersion = '11'
doLast {
println classpath.get()
println jdkVersion.get()
}
}
compile {
classpath = 'src/main/java'
}
./gradlew a
输出
src/main/java
11
Property泛型不是对所有类型都能使用,files
和collections
比较特殊,有单独的Property
对于文件file和directory还有区分
RegularFileProperty
DirectoryProperty
ListProperty
SetProperty
MapProperty
对于属性如果使用错误,gradle会有报错提示,例如给RegularFileProperty
设置了文件目录,或者文件不存在,都会有相应的报错提示
Property
必须用input/output注解标记(例如上面代码中的@Input
),否则会报错,Property
和task依赖,task up-to-date检查都有关系,下面在依赖关系处理中会介绍inputs/outputs
Property
不用手动进行初始化,上面的例子中可以看出都是abstract
的,gradle在创建task实例时会默认去创建好,我们在使用时只需考虑赋值,而且在配置时必须赋值否则会报错,或者标注@Optional
来表示此Property
非必须
更多内容请参考官方文档lazy_configuration
NamedDomainObjectCollection
TaskContainer
实现了NamedDomainObjectCollection
接口,这个概念需要提一下,gradle中有很多东西用到
例如tasks
,extensions
实际都是NamedDomainObjectCollection
可以直观地从名字来理解它
Named 具名的
Domain 用于某一域的
ObjectCollection 对象集合
NamedDomainObjectCollection
实现了java的集合Collection
接口
因为它的具名属性,实际上可以简单地将其简单地看作一个Map
,实际最终的逻辑也确实是交给map处理的
它还有一个namer
方法需要重写,这个作用就是用来给添加进来的元素进行命名的
Task Graph
整体流程
在build script
执行完之后,Task的创建和注册也就完成了,所有的Task都被添加到了Project的TaskContainer
中,之后就是构建所有要执行的Task的有向无环图了,这个图是以我们在运行gradle命令时输入的entry tasks
为起点开始构建起来的,例如./gradlew build
中的build,entry task
可以存在多个
ExecutionPlan
是存放Task的容器,所有的Task都会被添加到中,在entry tasks
被添加进来之后,会触发对Task依赖的探索,循环执行直到所有的Task依赖关系都明晰
之后求到entry tasks
的拓扑排序,确定最终的执行计划
这里包含了2个大体的工作
task依赖的resolve
task执行顺序的确定
以下图举例,在执行./gradlew D
时
以D作为entry task
D依赖C
C依赖B和A
B依赖A
整个执行流程就是A -> B -> C -> D
这样的顺序
Task Relationship
在说具体的依赖处理前,我们先需要明白有多少种建立依赖关系的方式
Task之间有以下几种方式建立关联的方式
task inputs依赖
dependsOn
finalizedBy
mustRunAfter
shoulRunAfter
dependsOn
是最常见的,这里就不说了,简单介绍下其他的方式
Task inputs
property方式
abstract class A extends DefaultTask {
@OutputFile
abstract RegularFileProperty getOutputFile()
}
def a = tasks.register('a', A) {
outputFile = layout.buildDirectory.file('build/a')
}
tasks.register('b') {
inputs.property('a.outputFile', a.flatMap { it.outputFile })
doLast {
println inputs.properties['a.outputFile']
}
}
task b
通过property和task a
建立依赖关系
files方式
def a = tasks.register('a') {
outputs.files('build/a')
}
tasks.register('b') {
inputs.files(a)
}
task b
的inputs和task a
的outputs建立了依赖关系
finalizedBy
finalizedBy
顾名思义,会把依赖的Task放在entry task
之后执行, 例如
def c = tasks.regsiter('c')
tasks.regsiter('d') {
finalizedBy c
}
执行./gradlew d
,会先执行d
,然后执行c
mustRunAfter/shouldRunAfter
mustRunAfter
和shouldRunAfter
相比于其他几种偏弱,实际上并不是依赖,而是设置执行顺序,这2种方式引入的task依赖,如果在task graph中没有的话是不会被执行的
def c = tasks.regsiter('c')
tasks.regsiter('d') {
mustRunAfter c
}
例如执行./gradlew d
命令,只执行d
task,c
不会执行 执行./gradlew d c
命令,会先执行c
,再执行d
mustRunAfter/shouldRunAfter
只是用来设置task执行的优先级,并不会给task添加强依赖shouldRunAfter
相比mustRunAfter
更弱一些,执行的优先级不一定能够完全保证,例如在parallel模式下或者task有因它而成环的问题时
每种relationship都有自己对应的TaskDependency
,TaskDependency
本质上是一个存放依赖的容器。调用上面对应的方法,就是在往对应的容器中添加元素,同一容器内保存依赖的顺序是按照其name的排序来的
依赖的类型没有限定,例如dependsOn
字符串(Task的name),create
的Task实例,register
的Task Provider
实例都可以,也就是说TaskDependency
这个容器内存放的元素成分很复杂,接下来看看gradle如何resolve
这些依赖
Task Dependency Resolve
ExecutionPlan
ExecutionPlan
是用来处理整个Task Graph的入口,Task依赖resolve
及执行拓扑序的确定都是由这处理的
先以一张整体的流程图来帮助理解
在entry tasks
被添加到ExecutionPlan
后则会触发对task依赖的探索,对应于DefaultExecutionPlan
的discoverNodeRelationships
DefaultExecutionPlan
以下代码有删改,这里保留了大体逻辑
public void addEntryTasks(Collection<? extends Task> tasks) {
LinkedList<Node> queue = new LinkedList<>(tasks);
discoverNodeRelationships(queue);
}
private void discoverNodeRelationships(LinkedList<Node> queue) {
Set<Node> visiting = new HashSet<>();
while (!queue.isEmpty()) {
Node node = queue.getFirst();
if (visiting.add(node)) {
node.resolveDependencies(dependencyResolver);
for (Node successor : node.getDependencySuccessors()) {
if (!visiting.contains(successor)) {
queue.addFirst(successor);
}
}
} else {
queue.removeFirst();
visiting.remove(node);
for (Node finalizer : node.getFinalizers()) {
finalizers.add(finalizer);
if (!visiting.contains(finalizer)) {
queue.addFirst(finalizer);
}
}
}
}
}
总体上是一个DFS,node的DependencySuccessors
是上面介绍过的Task Relationship中inputs
和dependsOn
建立的依赖。在node的依赖全部处理完后,会将它的finalizer task
添加到自己后边
Task的依赖关系保存在多个TaskDependency
中,对于Task依赖的resolve
就是去遍历这些TaskDependency
,代码逻辑入口处是在LocalTaskNode
中的,也就是由entry task
开始,将整个依赖关系进行处理,见下图(有删减)
LocalTaskNode
是一个封装了task
的Node
,Node
有多种类型,这里的算法是可以针对所有类型的Node
的
对Task依赖的resolve
是通过TaskDependencyResolver
来完成的,而TaskDependencyResolver
对依赖的处理最终是交给CachingDirectedGraphWalker
来处理的
CachingDirectedGraphWalker
里面使用的是tarjan强连通图算法的变体,它有2个功能
findValues
查找从start node
可达的nodes
findCycles
查找图中存在的环
熟悉强连通图算法Tarjan's strongly connected components algorithm - Wikipedia的同学应该知道它可以用来查找图中的环,强连通的概念本身就是节点间俩俩都能互达,而在有向无环图中是不可能存在的,所以是对算法进行了修改,以便可以找到依赖节点
更多关于强连通图算法的知识大家可以自行搜索了解,这里不做更多说明了。
这里目前是用findValues
去寻找依赖的节点,实际上这里并不是把Task的依赖及其间接依赖完全确定下来,只是将start node
的直接依赖确定下来。
还是以上图举例,从D
出发只是先找到C
,然后C
只找到B
、A
,B
找到A
并非是这个类能力缺失导致不能一次将所有依赖都搜索完,这里是因为graph
给出node
的方式导致的。不确定是否是故意如此设计的,但是会产生大量的中间节点,配合缓存导致空间的浪费
另外从名字中的Caching可以看出它是带有缓存功能的,也就是探索过的node,下次再探索到的时候可以直接复用缓存结果
CachingDirectedGraphWalker
在搜索的过程中会调用graph.getNodeValues
去获取节点,
getNodeValues
有3个参数,node
是当前节点,values
是node对应的值,connectedNodes
是关联的节点,例如task d
依赖于task c
的话,那么task c
就是task d
的connectedNodes
TaskGraphImpl
实现了DirectedGraph
接口,它主要负责2件事情
调用
DefaultTaskDependency.visitDependencies
去resolve task的依赖调用
WorkDependencyResolver
将Task
转化为LocalTaskNode
这一步当前的目的是为了将Task的依赖图Graph厘清,并没有确定其执行顺序
依赖resolve
visitDependencies
这里用到了Visitor设计模式,很多对象实现了TaskDependencyContainer
接口,而且大多都是作为容器使用,使用Visitor模式的好处就是可以不修改这些类的实现来增加功能,Visitor对这些类进行遍历访问后,逻辑在自己内部处理
Task依赖可以有很多种类型,这里分析几种主要的情况
Task
依赖create
方式创建的Task
def a = tasks.create('a')
tasks.register('b') {
dependsOn a
}
Provider
依赖register
方式创建的Task,register
的Task会返回Task Provider
对象
def a = tasks.register('a')
tasks.register('b') {
dependsOn a
}
TaskDependencyContainer
inputs
的引入的依赖
这里需要先了解一下inputs
概念
input analysis
概念
一般来说,Task都会有inputs
和outputs
,inputs
可以有文件或者属性,而outputs
就是文件
task将输入输出属性的定义主要分为4个类别
Simple values
基本类型,字符串等实现了Serializable的类型Filesystem types
File,或者用Project.file()
等gradle文件操作生成的对象Dependency resolution results
依赖裁决的结果,实质上也是文件Nested values
以上类型的嵌套组合
以compileJava
task为例,在编译java代码时inputs
可以有很多,例如source files
,target jvm version
,还可以指定编译时可用最大内存,outputs
就是class文件
自定义Task的属性必须用注解标注,如果没有标注的话,运行时会报错。 这里的属性是指JavaBeans的带有getter/setter方法的public字段,和上面提到的用于lazy configuration的Property不一样
Task的属性分析会解析父类的,有些方法例如继承自DefaultTask
或者Object
的方法不会被解析
作用
标记上注解有2个主要的作用
inputs/outputs
相关的依赖分析Incremental Build中
up-to-date
check
如何给属性标注注解
gradle提供的注解有很多
Input 用以标注一个普通类型
InputFiles 用以标注是一个输入的文件相关类型
Nested 用以标注潜套类型
OutputFiles 用以标注是一个输出的文件相关类型
Internal 用以标注一个属性是内部使用
...
等等,具体参考task_input_output_annotations@Internal
这个注解值得多说一句
例如上面提到的编译时可用最大内存。source files
,target jvm version
的改变都会影响到class文件的编译结果,但是运行时可用最大内存对编译结果无影响。这种和输入输出无关的属性,对Incremental Build缓存结果不产生影响的结果,可以用这个进行标注
这也表明@Input
,@InputFiles
等这些注解标注的属性是对缓存结果有影响的
例如
class SimpleTask extends DefaultTask {
@Input String inputString
@InputFiles File inputFiles
@OutputFiles Set<File> outputFiles
@Internal Object internal
}
inputs/outputs
有2个来源
通过给属性加注解的方式
调用
inputs
的api添加
例如
abstract class Compile extends DefaultTask {
@Input
abstract Property<String> getClasspath()
}
tasks.register('compile', Compile) {
classpath = 'src/main'// 1. 属性注解方式
inputs.property('name', 'compile')// 2. inputs添加属性
inputs.files(project.files('libs'))// 3. inputs添加文件
}
2者不同之处在于,注解方式能力更强,inputs
api是注解方式的子集,它可以提供@Input
,@InputFiles
等注解的部分能力,但是其他的注解类似@Internal
等它没有对应的方法
提供inputs的目的是我们在创建三方库提供的Task时,可以简单的提供一些额外参数,而不用通过继承的方式,在定义自己的Task时,注解方式还是首选
以下将注解方式标注的属性称为AnnotatedProperties
,inputs
加入的属性称为RegisteredProperties
gradle如何分析inputs建立的依赖
具体执行逻辑是由PropertyWalker
处理的,对于每个属性的处理,也使用到了Visitor模式
来源有2种,所以对不同的来源都要进行分析
AnnotatedProperties
要分析注解的属性,首先要把注解的属性都解析出来,gradle把解析出来的数据封装为metadata
,保存有属性的名称,所标注的注解的类型,以及Method
本身
这里同时会对属性进行有效性校验,每种注解都有对应的annotation handler
去处理,所有的handler
都保存在map中,通过annotation
的类型去获取。例如@InputFiles
会校验属性返回值为文件相关类型,如果是其他类型会进行报错
注解的属性解析完后会对每个属性进行遍历,对其进行visit,每种注解的处理方式也不尽相同,所以也是交给handler
去处理的,对于inputs来说主要分为2种,一种是普通的属性,一种是文件属性,对应上面的PropertyVisitor
的2个方法
RegisteredProperties
通过inputs
api方式添加的属性会根据自身情况被加入到2个容器中,一个用于存放文件相关类型的,一个用于存放其他类型的,在visitor分析时会对2者分别进行
不同的Task之间又是如何通过这些属性建立的关联呢,让我们从一个具体的例子入手
def e = tasks.register('e', CustomTask) {
inputs.property('prop1', a.flatMap { it.outputFile })
inputs.files(b)
prop2 = c.flatMap { it.outputFile }
prop3 = d.files
}
上面截取了部分代码,总共有5个Task,task e
对task a,b,c,d
都有依赖关系。a,b,c
都是register
的Task,d是create
的Task
prop1
通过inputs.property
的方式依赖task a
,a.flatMap
返回的是Provider保存了task a
的信息,task a
本身也是Provider
,gradle通过反射调用Task属性的getter的方式可以拿到task a
,将其作为依赖inputs.files
直接依赖了task b,inputs.files(b)
实际上是对task b
的outputs文件的依赖,和FileCollection
处理一致prop2
依赖了task c
,处理方式同prop1
prop3
依赖了task d
,d.files
返回的是FileCollection
,在创建时也保存了task d
的信息
因为可以作为依赖添加的对象很多,差别也很大,所以gradle使用了visitor模式,具体的对象在visit方法中处理自己的依赖方式,最后visitor将所有的依赖进行收集
对于具体属性分析的逻辑最终收拢到了PropertyVisitor
中,TaskInputs
会将这些依赖添加到connectedNodes
,让图的搜索工作继续进行
这里只对inputs相关做了说明,实际属性的处理还有与增量构建相关的逻辑,在之后缓存的文章中再进行说明
Task的依赖resolve
完后,依赖会被保存在多个容器中,dependencyNodes
和dependentNodes
分别表示此Task依赖的Task和依赖此Task的Task,mustRunAfter
、shouldRunAfter
等也会有独立的容器存放
Project依赖导致的Task依赖
inputs
依赖方式还有一种特殊的情况,就是project间的依赖关系 假设有2个project,libA和libB,libB依赖libA
libA/build.gradle
plugins {
id 'java'
}
libB/build.gradle
plugins {
id 'java'
}
dependencies {
implementation(project(':libA'))
}
通过dependencies
的方式2者就建立了依赖关系,在执行./gradlew libB:compileJava
时会先执行libA:jar
task,这又是如何做到的呢?
也就是说因为implementation(project(':libA'))
的关系,libB:compileJava
对libA:jar
产生了依赖
libA apply
了java plugin
,java plugin
中将PublishArtifact
和Jar
task建立关联,并将 PublishArtifact
作为 libA Configuration 的一部分
简单地理解就是libA的输出产物是PublishArtifact
,而PublishArtifact
是由Jar
task生成的 (Configuration是gradle Dependency的一个概念,之后在依赖处理中详细说明,这里将它简单理解为一堆文件就可以了)
CompileJava
task有一个属性classpath,libB compileJava
时,classpath通过project(':libA')
对libA产生了依赖,classpath是CompileJava
task inputs的一部分,它对应的也是一堆文件,有一部分是来自于libA
的输出产物
在处理Task的依赖时,通过Configuration查找到了libA的PublishArtifact
,之后顺理成章地和libA的Jar
task建立了依赖关系,本质上也是通过TaskInput
处理的依赖关系
执行顺序
Task的依赖关系图即 Task Graph,正常情况下是一个有向无环图(DAG),在它被resolve
之后,此时就可以开始对Task Graph进行拓扑序的求解了,得到最后执行的顺序
拓扑排(Topological Order) 实质上是将DAG图的顶点按照其指向关系排成一个线性序列
如果graph有环,那拓扑序求解会失败,这个时候会调用CachingDirectedGraphWalker
,也就是使用tarjan强连通图算法去找环,目的是为了报错信息能够让使用者直观地看出是哪些task有相互依赖的情况,便于修改。顺带一提,强连通算法通过查找环来进行报错信息优化,在代码编译中也有很多使用场景,例如如果把正常的继承关系看作一个有向无环图,那么循环继承这种情况就可以使用这种算法找到是哪些类的发生了循环继承
求拓扑序的方式有很多,且拓扑序并不唯一,有可能有多种解,gradle使用的是DFS方式,将entry nodes
作为起点添加到队列中,来进行搜索,同时了用来遍历查找task的Queue,用来保存最终结果的Set,用来保存标记是否visit过的visitingNodes
这几个数据结构entry nodes
可能为多个,这里以最简单一个的情况说明一下总体步骤
判断队列是否为空
如果为空则结束,保存结果的set的顺序就是排序结果
如果不为空,取队列中第一个node
node是否已经存在结果set中了,存在的话直接移除队列中的node,重复步骤1
node状态是否为“搜索中”,如果搜索过node则将其保存到结果的set中,并移除队列中的node,重复步骤1,否则标记当前node
node的直接依赖结点successors
如果node的successors中存在状态为“搜索中”,那么表示DAG图有环,进行报错提示
将node的successors全部添加到队列中,回到步骤1判断队列是否为空
这里的successors表示的是当前node通过上面介绍过的几种建立依赖的方式关联起来的所有Task
流程图如下
是
否
是
否
是
否
是
否
开始
结束
queue是否为空
第一个node是否已经在结果中了
第一个node是否被visit过
node的succussors存在被visit过
将第一个node加入到结果中
报错
标记node状态为visit
将node的successor加到queue中
移除第一个node
大致代码如下
void processNodeQueue() {
while (!queue.isEmpty()) {
final Node node = queue.peekFirst();
if (result.contains(node)) {
queue.removeFirst();
visitingNodes.remove(node);
continue;
}
if (visitingNodes.put(node)) {
ListIterator<Node> insertPoint = queue.listIterator();
for (Node successor : node.getAllSuccessors()) {
if (visitingNodes.containsEntry(successor)) {
onOrderingCycle(successor, node);
}
insertPoint.add(successor);
}
} else {
queue.removeFirst();
visitingNodes.remove(node);
result.add(node);
}
}
}
以上面图示的依赖关系来举例,大概过一下整体流程
这里还省略了很多细节的处理,比较重要的有以下几点
finalizedBy
引入的依赖,会被加到对应Task的刚好后面一个,例如 a.finalizedBy(b) c.dependsOn(a) 那么b
会位于queue中a
,c
的中间,也就保证了执行的顺序如果Task是由
mustRunAfter/shouldRunAfter
添加的,且没有其他强依赖的方式引用到,是不会被加到结果中的成环的判断那里,如果是由于
shouldRunAfter
造成的会忽略掉entry nodes
可以是多个,处理多个entry nodes
时,每个entry nodes
会对应一个segment
将不同的node
区分开来
参考文档
关注我获取更多知识或者投稿