前2篇文章探究了gradle是如何处理Task Graph和Task调度的,至此Task的前期工作就已经完成了
下面就该执行Task了,如果观察过Task执行的话,会留意到console输出中Task后面有的带有执行结果的标识,如SKIPPED
,UP-TO-DATE
等
除了不带标识的和带有EXECUTED
标识的表示是真正执行过Task的action的,其他的要么是从缓存中读取的结果,要么是不需要执行,这是gradle做的一个task执行的优化,下面也会针对gradle是通过什么判定出这些执行结果的
我们会看到gradle是如何将inputs/outputs
的状态进行记录的,如何进行up-to-date
的检测的,又是如何利用build cache
来加速构建的完整流程
Task执行
先上一张图来对整体概念有所了解
Task的执行入口在LocalTaskNodeExecutor
内,它从LocalTaskNode
拿出Task,交给TaskExecuter来执行
在继续探究执行前,我们先看一下Task执行结果Outcome的类型
Task Outcome Task结果标识有5种,从名字上能大概看出它们的含义,在下面的执行过程中会看到这些结果产生的具体情况
SKIPPED
NO-SOURCE
UP-TO-DATE
FROM-CACHE
EXECUTED
TaskExecuter
TaskExecutor
从名字上可以看出是用来执行Task的,它使用代理模式,将不同职责划分给了多个子类,我们看一下主要的几个
SkipOnlyIfTaskExecuter
Task可以通过api控制在某些条件下才执行
tasks.register('customTask') {
onlyIf {
}
enabled = false
}
onlyIf
和enabled
都可以控制Task执行条件,如果其结果是false,那这个Task就不需要被执行,SkipOnlyIfTaskExecuter
就是用来判断这个的
如果在控制台看到有Task执行结果后面带有SKIPPED
标识,那么通常在这一步处理掉的
还有一种特殊情况,我们可以在Task的action中抛出StopExecutionException
异常,这种情况输出结果后面不会带有SKIPPED
标识,不是由SkipOnlyIfTaskExecuter
处理,这种情况和上面有相同之处,在Task执行失败之后,依赖于它的Task依旧能够执行
SkipTaskWithNoActionsExecuter
如果Task没有action
,那它就不需要执行,通常这些都是lifecycle tasks
- lifecycle tasks
gradle的LifecycleBasePlugin
有一些内置的lifecycle tasks
,例如build
,test
,clean
等
这些Task都没有action,它们代表了构建过程通用的一些逻辑,通常会让它们依赖actionable tasks
,例如java项目通过java plugin
,让build
依赖compileJava
,kotlin项目会让其依赖compileKotlin
- actionable tasks
这些就是真正干活的Task了,它们都有action,有真正可以执行的逻辑在
这类Task的执行结果需要看其所依赖的Task的执行结果,如果依赖的Task都不是EXECUTED
,那它的执行结果是UP-TO-DATE
,否则为EXECUTED
ResolveTaskExecutionModeExecuter
这一步是通过分析Task的属性得出其执行模式,对后续步骤最主要的影响是其是否可以支持增量构建
Task的执行模式分5种
INCREMENTAL
NO_OUTPUTS
RERUN_TASKS_ENABLED
UP_TO_DATE_WHEN_FALSE
UNTRACKED
- UNTRACKED 当Task被注解上了
@UntrackedTask
时 - RERUN_TASKS_ENABLED 执行gradle命令时,后面加了
--rerun-tasks
时 - NO_OUTPUTS Task outputs可以设置
upToDateWhen
来决定其是否复用之前的结果,如果Task既没有声明任何outputs属性,也没有设置upToDateWhen
的话,为此执行模式 - UP_TO_DATE_WHEN_FALSE 当Task的
upToDateWhen
返回false时 - INCREMENTAL 其他情况时的执行模式,但Task是否能够真正增量执行还有很多因素影响
这些类型主要是对下面3种属性的封装,这3种属性对Task的增量build有影响。从名称上比较容易理解,在后面的分析中会了解到它们如何影响构建过程
rebuildReason
taskHistoryMaintained
allowedToUseCachedResults
ExecutionMode | rebuildReason | taskHistoryMaintained | allowedToUseCachedResults |
---|---|---|---|
INCREMENTAL | null | true | true |
NO_OUTPUTS | Task has not declared any outputs despite executing actions | false | false |
RERUN_TASKS_ENABLED | Executed with ‘–rerun-tasks’ | true | false |
UP_TO_DATE_WHEN_FALSE | Task.upToDateWhen is false | true | false |
UNTRACKED | Task state is not tracked | false | false |
FinalizePropertiesTaskExecuter
将Property都finalize
以确定下来,不再接受对属性的改动
Property在Task Graph篇章中有介绍过,这里就不再介绍了
以compileJava
举例,就是classpath
,destinationDirectory
,sourceCompatibility
,targetCompatibility
等等一些参数
ExecuteActionsTaskExecuter
Task真正执行的地方,它会将Task转化为Unit Of Work
去执行
Unit Of Work
UnitOfWork
,从名字上理解,它是work的最小单元,gradle抽象的一个更细粒度的用来描述Work的接口,先来一张图以对其有个大致的理解
UnitOfWork
的execute
方法入参为ExecutionRequest
,返回结果为WorkOutput
ExecutionRequest
受InputVisitor
和ExecutionBehavior
的影响WorkOutput
也会被OutputVisitor
和WorkResult
进行分析Identity
是用来为UnitOfWork
提供唯一标识用的
这些几乎都是接口,gradle制定了UnitOfWork
的整体框架,剩下的实现部分并没有约束
Step
UnitOfWork
的执行是由一系列的Step去执行的,Step
和TaskExecuter
一样使用了代理模式,它的实现更加复杂
从接口能看出几个概念
Step
Context
Result
UnitOfWork
大概的示意图如下
Context
是Step
执行Work
时的上下文Step
可以通过wrap的方式给Context
添加一些参数给到下一个Step
,比如workspace,上一次build的结果等
并且可以对上一个Step
的执行结果Result
进行一些操作,例如将上一步的执行结果保存到缓存中
Task的build cache
、增量build等处理逻辑就是在这里处理的,下面来逐一分析Task的执行Step
Step
的涉及到的流程很长,先用一张图来总览整体逻辑
IdentifyStep
IdentifyStep
包了一层来提供自己的IdentityContext
IdentityContext
主要负责提供标识,Task使用的是project的全路径加自身Task名字作为唯一标识
AssignWorkspaceStep
为Step
的运行提供workspace,实际就是文件
UnitOfWork.getWorkspaceProvider
方法会返回一个WorkspaceProvider
,它用来提供work执行所需的workspace
withWorkspace
表示会在某个工作目录下执行action
,action
会收到2个参数,workspace
和history
,history
就是上一次构建的结果
一般是在withWorkspace
中调用action
的executeInWorkspace
,Task的逻辑
public <T> T withWorkspace(String path, WorkspaceAction<T> action) {
return action.executeInWorkspace(null, context.getTaskExecutionMode().isTaskHistoryMaintained()
? executionHistoryStore
: null);
}
可以看到Task不会提供workspace,而history和Task执行模式有关,如果是taskHistoryMaintained=true
的情况才会使用,否则为空,根据上面提到过的Task执行模式,也就是说NO_OUTPUTS,UNTRACKED这2种是不支持history
history具体的加载逻辑在后面LoadPreviousExecutionStateStep中
CleanupStaleOutputsStep
这是用来清理一些腐坏的build文件用的,主要是用于处理gradle版本更新场景的
也只针对能支持history的Task,因为这些Task才可能会产生输出,而这些输出的文件可能由于各种原因不正常了
这一步会删除outputs输出的文件中ownedByBuild且非gradle生成的文件,2个条件
- ownedByBuild
- 非generatedByGradle
2者其实都是通过文件路径来判断的,而判断ownedByBuild
和generatedByGradle
所通过的文件路径的集合是不一样的
- ownedByBuild
通过调用BuildOutputCleanupRegistry.registerOutputs
来将build目录添加进来clean
task将build目录添加进来了java plugin
将sourcSets的output文件目录加进来了
只要是位于BuildOutputCleanupRegistry
中文件目录的文件,都属于是ownedByBuild
的 - generatedByGradle
RecordOutputStep
会将输出的文件路径都保存下来,结果保存在 当前项目根目录/.gradle/buildOutputCleanup/outputFiles.bin 中
例如compileJava
task会将build/classes/java/main、build/generated/sources/annotationProcessor/java/main等文件路径保存下来jar
task会将build/libs/xxx.jar路径保存下来
保存的是outputs属性指定的文件路径,其目录下的文件的路径是不会被保存的。所以compileJava
task保存的是classes/java/main路径,这里面编译出来的classes文件路径是不处理的
虽然感觉它像是每次构建前会去删除不属于上次构建的文件
但实际如果没有历史构建记录的话,手动在build目录下新建一个文件确实会被删除掉,但是如果有历史构建记录outputFiles.bin,其判断方法是没法将新建的文件删掉的
它只会判断task outputs的文件路径,比如task a
的outputs文件是build/output
,那么只会check build/output
,像build/output/other
这样的是不会被check的
LoadPreviousExecutionStateStep
AssignWorkSpaceStep已经提供了ExecutionHistoryStore
,这一步就是从history还原出上次的build执行状态
ExecutionHistoryStore
接口也很简单,就3个方法,分别用来加载、保存和删除历史
这里需要重点看下这个PreviousExecutionState
这里的key是identity.getUniqueId()
,对于task来说就是它的完整路径,如:lib:compileJava
这里的实现中会有一个keySerializer
和一个valueSerializer
,分别负责key和value的序列化和反序列化工作,这里的key为字符串所以不需要特别处理,valueSerializer
会将缓存反序列化为PreviousExecutionState
这里序列化/反序列化具体实现使用的是之前在gradle脚本篇章中提到过的kryo
三方库
使用到了内存、文件双缓存
保存的路径为 当前项目根目录/.gradle/8.0(gradle版本)/executionHistory/executionHistory.bin
PreviousExecutionState
ExecutionState
的属性比较多,先看看脑图好有个整体印象
originMetadata
buildInvocationId
每次build都会生成一个uuid,此次build所有的task使用的都是这个idexecutionTime
执行耗时
taskImplementation
分为2种Class和Lambda,一般都是Class类型的
Task的类型信息,包括class identifier
(class全路径名)classLoaderHash
根据加载Task的classloader计算出来的hash值,gradle有很多classloader,用于加载gradle-api的,用于加载plugin的等等
Lambda还额外包括其实现的方法签名,实现类类型等信息taskActionImplementations
与taskImplementation
相似,记录的是Task内action的类型信息inputProperties
是一个key是属性名,value的类型为ValueSnapshot
的map,ValueSnapshot
有很多子类,是对各种原生类型,file,list,set,serializable等等的封装类inputFileProperties
从input file属性指定的文件提取的指纹信息,或者称为inputFilesFingerprints
实际也是一个map
key为属性名,value的类型为FileCollectionFingerprint
的mapFileCollectionFingerprint
,这是对FileSystemSnapshot
,也就是从文件类型的快照提取的指纹信息,这也是一个map,key是文件absolutePath
,value包含absolutePath
fileType
(RegularFile,Directory,Missing)contentHash
– RegularFile是其内容的hash,Directory和Missing类型是常量,重要的就是这个hash值了normalizedPath
根据normalization策略而来的path,具体在后续说明
rootHashes
基于子文件hash值计算出的hashstrategyConfigurationHash
采用的normalization策略本身的hash值
outputFilesProducedByWork
outputs属性指定的输出文件的快照,返回值类型为FileSystemSnapshots
记录下整个outputs文件树结构的快照FileSystemSnapshot
,本身包含absolutePath
,name
(文件名)属性,会遵守文件的顺序和文件的树形结构,分为3种类型
目录为DirectorySnapshot
children
子文件contentHash
基于子文件hash值计算出的hash
文件为RegularFileSnapshot
,包含contentHash
文件内容的hashlastModified
length
缺失情况为MissingFileSnapshot
successful
: Boolean
是否执行成功
有3个ExecutionState,3者所包含的信息基本一致
PreviousExecutionState
上一次task执行后的状态BeforeExecutionState
本次task执行前的状态AfterExecutionState
本次task执行后的状态
ExecutionState记录了Task本身以及inputs/outputs
的所有信息,这些信息有几个主要的作用
- 是用于和task上次执行的结果进行比较,如果属性全部没有改变过,那它符合
up-to-date
- 用于找出增量构建时发生改变的属性、文件等,具体在后面的ResolveChangesStep会详细说明
- build cache key的计算
MarkSnapshottingInputsStartedStep
标记一下input snapshot开始
RemoveUntrackedExecutionStateStep
这是执行善后工作的,它会先让后续Step执行完,执行的Result可能会带有一个
AfterExecutionState
,用来记录本次执行的状态,和PreviousExecutionState
对应
如果有PreviousExecutionState
,那就会有AfterExecutionState
如果PreviousExecutionState
没有,那AfterExecutionState
也没有
而PreviousExecutionState
是取决于是否支持history的,也就是说NO_OUTPUT, UNTRACKED这2种执行方式,在后续Step
中有可能产生缓存,而在这一步会将其缓存清除掉
SkipEmptyWorkStep
gradle Task执行结果后面带有的NO_SOURCE
标识,就是在这一步处理掉的
@SkipWhenEmpty
的文件属性或者调用了skipWhenEmpty
给属性强制设置不能为空,如果没有对应的inputs文件存在的话,会跳过它的执行,返回NO_SOURCE
结果
不止如此,如果这个Task有上一次构建的历史文件存在,而这次没有inputs文件存在的话,会将上次的缓存清楚,此时执行结果是EXECUTED
的
例如 Copy
task,可以通过from
和to
来设置待复制的文件和目标路径from
最终是给Copy
task添加一个source路径,而它给inputs设置了skipWhenEmpty
导致如果没有传入要拷贝的文件时,它实际不会执行
tasks.register('copy', Copy) {
}
./gradlew copy
结果为
Task :copy NO-SOURCE
Skipping task ‘:copy’ as it has no source files and no previous output files.
CaptureStateBeforeExecutionStep
在LoadPreviousExecutionStateStep中我们对ExecutionState
有了一定的了解,但是那里是从缓存中反序列化的数据,而在这里我们将会看到BeforeExecutionState
是如何生成的
BeforeExecutionState
记录的信息和PreviousExecutionState
差不多,主要是记录当前的inputs/outputs情况,依旧是使用Visitor模式
Task和Action类型信息提取比较简单,这里不展开了,原生类型的属性也好处理,对于不同类型有相对应的ValueSnapshotter
处理
重点是文件类型的InputFilesFingerprint
的生成,和OverlappingOutputs
的侦测
InputFilesFingerprints
前面有提到过fingerprints
是从snapshot
生成的,snapshot
也有文件路径,文件hash相关的信息,那为什么还要有fingerprints
呢?
这还得回到记录inputs属性信息的目的上来,inputs信息的记录是为了对比2次构建,比较看是否有发生变化,已经发生了什么变化。
那我们现在通过对文件进行snapshot
操作,得到了目录的路径和hash,得到了目录内文件的路径和hash,记录着它们保存的顺序,看上去已经能够通过这些信息的对比来得到我们想要的东西了
那我们从几个case入手看看是否这就够了
- 我们会记录文件路径,那该记录什么路径呢?
文件路径有绝对路径和相对路径,我们该记录哪个?
看上去相对路径更合理,但是否这样就满足所有需求了呢?
比如有一个对jar包进行transform的action,只要jar包名称没变,内容没变,我就认为它是没有变化的,但是如果它生成的目录层级变化了,如果使用相对路径记录,就会认为它发生了变化
还有些情况,目录下面有空目录,这些路径是否需要记录,比如编译java代码的时候,这些空目录文件不会对结果产生任何影响,我们可以忽略掉,但如果记录了它们,那删除空目录会导致前后2次构建的inputs不同,而重新构建
- 文件的内容hash不变是否能等同于表示文件没有变?
文件内容hash没有变,文件内容肯定是一样的
那么反过来呢,有没有什么场景是我们虽然修改了文件,但我们这种改动对文件是没有影响的呢
比如properties
文件,里面可以添加多个配置,如果加一行注释,该影响Task up-to-date
的检测吗,如果将2个属性位置换一下又如何呢?
再比如class path,我们在编译java代码的时候通常需要依赖,这些依赖都是通过jar包或者目录的方式添加到class path中的,这些jar包内添加了一些资源文件,又或者是某个private方法改了,需要我们的代码重新编译吗?
所以针对这些问题,gradle需要对文件的snapshot
进行fingerprint
操作,这个过程也叫做normalization
Normalization
normalization有标准化,归一化的意思,影响normalization的主要有3个方面FileNormalizer、DirectorySensitivity和LineEndingSensitivity,下面我们来看看它们究竟都做了些什么
FileNormalizer
FileNormalizer
主要影响normalizationPath
和文件内容hash的生成,结合上面对文件路径的讨论,normalizationPath
就是用来标准化文件路径的
PathSensitivity
- ABSOLUTE
normalizationPath
为绝对路径,这会对build cache
的共享有影响,绝对路径不同会导致hash不同,缓存没法复用
默认是这个,所以自定义Task想要使用build cache
时需要注意这点⚠️ - RELATIVE
一般想要有缓存复用的属性尽量使用这个,这样就不会受项目目录的影响,也可以和其他机器共用缓存
例如compileJava
task的stableSources
,也就是sourceSet定义的目录,默认是src/main
位于根目录的文件,normalizedPath
取文件名
目录内的文件,normalizedPath
取和根目录路径的相对路径 - NAME_ONLY
normalizedPath
为文件名,文件名不变,层级改变也没关系Transformation
,和@InputArtifact
一起用的情况比较多,只要artifact的文件名、内容没变,outputs没变,层级变了不影响up-to-date
的check - NONE
只对文件类型计算hashnormalizedPath
为空字符串
文件路径不重要,只关心文件内容
例如Pmd
plugin的ruleSetFiles
使用的就是PathSensitivity.NONE
,ruleSetFiles
是xml文件,里面是对issue的一些自定义操作,比如排除掉对某些目录的检测等等,不关心xml的名称,只关心里面的内容是否发生了变化
但有一点值得注意,使用PathSensitivity.NONE
时,如果你改了脚本文件的文件路径,但是没有改动文件内容,虽然文件本身的hash没有改变,但是Action实现的hash可能因此改变,所以还是有变动的
所以这个属性用在目录上更适合,其内部文件层级变动、名称改变不会产生影响,或者使用通配符的方式
CompileClasspath
classpath
情况比较复杂,需要单拎出来说,甚至要区分runtime
和compile
用@CompileClasspath
注解的属性,其normalization使用的即是CompileClasspath
指纹提取的逻辑在CompileClasspathFingerprinter
中compile classpath
可能有目录和jar包,里面除了class文件外可能还有其他文件
它只关心class文件,文件的顺序也不关心,其中对class文件hash的工作是交由AbiExtractingClasspathResourceHasher
处理的AbiExtractingClasspathResourceHasher
使用org.objectweb.asm
库来从类字节码提取信息,对于private
类会忽略,其他访问修饰符声明的类,将它们的public
、protect
、default
声明的方法的方法名、返回值、入参类型、注解、异常抛出等等信息进行记录,还有对字段的相关信息的记录
这里有一个ABI的概念
ABI(application binary interface)
ABI是二进制程序模块间的接口,通常是用machine code定义的数据结构、计算流程的访问,使用偏底层的,硬件依赖的格式
API是源码定义的,相对高级的,不依赖硬件且一般是可读的格式
实际上这里的ABI和API基本内容是一样的,这里说的API是指定义的外部可用的方法,字段等,通常是public的
因为拿到一个jar包,它里面public声明的类,方法等,其实我们都能够使用,就相当于是其暴露出来的API。只不过因为是从class字节码提取的信息,所以这里的ABI可以简单看作是API的字节码版本
下面这些case对于compile classpath没有影响,也就是说下面这些情况不会导致项目重新编译
- jar或者根目录路径的变动
- jar包内的时间戳、entry的顺序变化
- jar包内
resources
和manifest
的变动,包括添加删除resources
- class内
private
元素的改动,比如私有方法、私有fields、内部类等 - 对方法体、静态初始化代码块,fields的初始化代码块等代码的改动(除了常量)
- debug信息的改动,例如删除一行注释导致了debug信息行号变动
- 对jar包内directories,包括directories内的entries的改动
简单概括一下就是,声明了@CompileClasspath
的属性,只会对其中的class文件进行hash,使用的是相对路径,对非private修饰的类,其中的非private的方法或者字段,其方法签名的改动,像是返回类型,参数增删,异常抛出等的修改会影响对改属性是否变动的判断
RuntimeClasspath
用@Classpath
注解的属性,其normalization使用的即是RuntimeClasspath
RuntimeClasspath的hash策略并不像CompileClasspath会对class文件进行ABI信息提取,只是单纯的对文件内容进行hash
但RuntimeClasspath有一定的灵活性,可以通过脚本进行一些配置,比如忽略某些文件,使它们不对最终的hash造成影响,可以从properties
、metaInf
、resources
3个方面进行自定义,相关API的使用可以参考configure_input_normalization
比如下面这个例子,忽略所有.properties
文件中时间戳属性,它不会参与到hash的计算中去,如果timestamp
变了,也不会影响到Task up-to-date
的check
normalization {
runtimeClasspath {
properties {
ignoreProperty 'timestamp'
}
}
}
javaDoc
就使用到了@Classpath
注解
这里的CompileClasspath和RuntimeClasspath不是完全和java编译、执行过程的术语等同,更偏向于对这些类型的class path的通常的处理方式
这也能给缓存优化提供一些思路,java定义依赖有2种基础的方式api和implementation
其区别大家肯定都知道,api建立的依赖关系,它会导致transitive dependencies影响到项目的compile classpath
比如projectA
api 依赖了libA
,libA
又依赖了libB
,不管libA
是用什么方式依赖的libB
projectA
的compile classpath
都会有libB
,那如果libB
修改了影响ABI的代码,则会导致projectA
rebuild,即使projectA
内没有使用到任何libB
的代码
这也是官方建议尽量使用implementation的原因
DirectorySensitivity
是否忽略目录,默认是不忽略的
使用注解@IgnoreEmptyDirectories
,也有对应的api可以调用
compileJava
task的source
属性就标记上了这个注解,这个比较容易理解,源代码只要记录了相对路径就可以区分了,至于目录不需要我们是不关心的,而且可以排除掉空目录的影响
LineEndingSensitivity
换行符的处理
使用注解@NormalizeLineEndings
,也有对应的api可以调用
有2种逻辑
- 复用
snapshot
的hash - 替换换行符的hash
由于不同操作系统之间对换行的处理有可能是不一致的,这就导致如果使用了文件的原始内容,那么得到的hash值是没办法和其他操作系统的进行比较的
使用@NormalizeLineEndings
注解,gradle计算hash时会将碰到的 \r
,\r\n
换行符都替换为\n
,当然这些都是针对文本文件的,二进制文件的hash和snapshot的一样
gradle默认是不会使用替换换行符来计算hash的,所以另一种做法是让项目强制统一的换行符
总的来看,FileNormalizer有6种,DirectorySensitivity有2种,LineEndingSensitivity有2种
这几种是可以组合使用的,一共有24种排列组合方式
但实际没有那么多,因为3者关系不是完全正交的
例如PathSensitivity.NONE
这种情况本身就忽略了所有文件名称,所以本身也就对文件目录不敏感
OverlappingOutputs
gradle是期望不同Task的outputs目录都是不同的,没有重合,相互之间互不影响,但实际上可能会出现这种不同Task outputs目录重合的case,gradle将这种情况称为OverlappingOutput`
OverlappingOutputs对增量构建、stale output的清除都会有影响,如果一个Task在另一个Task的outputs目录中也生成了文件,那么无法判断这个文件是否是stale的
找到OverlappingOutputs也比较简单,首先对Task当前的outputs文件进行snapshot,再和PreviousExecutionState
的进行对比,previous
和before
的对比多出来的部分就是OverlappingOutputs,还能区分具体是哪个属性的
理解起来也比较简单,排除人为干扰的情况,正常在当前Task执行前,outputs的文件应该和PreviousExecutionState
所记录的一致,如果有多出来的部分,那应该就是有后续Task的输出占用了同样的路径
ValidateStep
这一步主要验证@CacheableTask
注解标注的Task的问题
如果其Task有标注@CacheableTask
,那么其相应的@InputFile
等注解需要额外标注上normalization相关注解
normalization在CaptureStateBeforeExecutionStep中有详细说明,这些会影响到缓存的有效性
Task默认都是@DisableCachingByDefault
的
ResolveCachingStateStep
这一步是判断Task能否使用缓存,并生成BuildCacheKey
- 如果build_cache没有开启(
org.gradle.caching
为false)不能够使用缓存
如果开启了,但是Task有验证问题也不行,因为只有error的问题会打断构建,warning的不会,所以需要先将所有error、warning等报错修复,才能使用caching - 如果Task被注解上了
@DisableCachingByDefault
的话那也不支持caching - 如果Task没有outputs文件,缓存就是针对输出的文件做的,没有输出自然不需要缓存。不止如此,如果outputs存在属性返回类型为
FileTree
,也是不支持caching
的,返回File
、FileCollection
可以 - 有OverlappingOutputs的情况
cacheIf
、doNotCacheIf
中的判断条件也会有影响
FileTree
和FileCollection
的区别是FileTree
有层级,而FileCollection
是展平的
详细可以参阅官方文档Working With Files
BuildCacheKey的生成逻辑基本上就是用BeforeExecutionState
所记录的所有信息计算出一个hash值,具体有哪些信息在LoadPreviousExecutionStateStep中已经列出了
这里只提下inputs/outputs的几个
input属性: 属性name和具体的值都会参与key的计算,属性一般为原生类型,hash的计算比较容易
inputFilesFingerprint: 属性name和fingerprint的hash(也就是文件内容的hash)会参与key的计算
outputs属性: 只有属性name会参与key的计算
MarkSnapshottingInputsFinishedStep
标记一下input snapshot结束
ResolveChangesStep
这一步是用来区分增量和非增量构建的,如果是增量构建,还需将此时的文件和上一次构建时的进行对比来生成InputChanges
影响增量的因素主要有
- Task执行模式的属性
rebuildReason
- Task的
ExecutionBehavior
- Task是否存在验证问题,有warning的task不支持
- 是否存在上次构建结果的记录,如果没有也不支持
RebuildReason
5种执行模式中,只有INCREMENTAL没有rebuildReason
,其他情况都有,也就是说其他的执行模式都是全量构建的
ResolveTaskExecutionModeExecuter小节中有列出不同执行模式rebuildReason
只有增量构建才可以使用构建缓存,其他几种类型的执行模式走到这意味着Task一定会被EXECUTED
ExecutionBehavior
ExecutionBehavior
有2种
NON_INCREMENTAL
INCREMENTAL
Task是根据自己是否有以InputChanges
作为参数action来区别是否支持增量的InputChanges
有所有变动过的文件,以及它们变动的类型changeType
,changeType
可以区分是新增,删除还是修改,还有fileType
可以区分目录和普通文件
例如
abstract class IncrementalReverseTask extends DefaultTask {
@Incremental
@InputDirectory
abstract DirectoryProperty getInputDir()
@OutputDirectory
abstract DirectoryProperty getOutputDir()
@TaskAction
void execute(InputChanges inputChanges) {
inputChanges.getFileChanges(inputDir).each { change ->
def fileType = change.fileType == FileType.DIRECTORY
def targetFile = outputDir.file(change.normalizedPath).get().asFile
def changeType = change.changeType == ChangeType.REMOVED
}
}
}
InputBehavior
NON_INCREMENTAL
INCREMENTAL
PRIMARY
这几种类型也是通过注解区分的,注解了@SkipWhenEmpty
的是PRIMARY,注解了@Incremental
是
INCREMENTAL,其他情况都是NON_INCREMENTAL,这些注解都是针对input files
的
根据每个input files
的属性注解的不同,其InputBehavior
也可能不同
PRIMARY和INCREMENTAL都是支持增量的,它们的区别是在对inputs文件不存在时的处理上。
PRIMARY是@SkipWhenEmpty
的,所以会删除掉上次的构建记录
这里会将Task所有支持增量的input files
属性进行搜集,后续InputChanges
的生成需要用到
Detect Inputs Changes
确定是增量构建的情况下,gradle会去找出此次构建和上次构建input files
间的区别,根据
LoadPreviousExecutionStateStep加载的PreviousExecutionState上一次构建、
CaptureStateBeforeExecutionStep记录的BeforeExecutionState本次构建、
和收集到的incrementalInputProperties
去找出inputs
文件的改动
具体逻辑交由ExecutionStateChangeDetector.detectChanges
处理
根据上面对LoadPreviousExecutionStateStep部分ExecutionState
的描述,我们知道它包含了很多task的信息,基于这些信息来和本次的进行对比就可以得到2次构建input是否发生了改变,具体比较下面几个方面
- Task实现和Action实现是否有变动
比较本次和上次构建的Task及Actions的classIdentifier
和classLoaderHash
- input属性(非文件部分的属性)是否有变化
一方面需要对比是否有属性新增或减少,一方面需要比较具体的值是否发生了变化 - input files属性是否有变化
input files属性是否有新增或减少
非增量input files属性部分的文件是否有变动,这里通过比较fingerprint
信息判断的 - output files是否发生了变化
上面对input files的fingerprint
做了详细的解释,但是output没提,其实output也有fingerprint
,但是比inputs的简单太多了,只是用相对路径作为normalizationPath
,因为它的变化主要是来自inputs,所以对于它指纹的提取没有必要那么复杂
output files只用比较快照中的文件顺序、文件名、文件hash是否有区别就可以了
如果上诉的情况有变化,就只能走非增量方式走full rebuild
如果没有变化,说明可以走增量构建,那么就需要对增量input file属性文件的变化信息进行收集
SkipToDateStep
上一步ResolveChangesStep
已经让我们知道了Task是否增量构建,以及Input Changes
如果是支持增量构建的且input files
没有变动,那么也就不需要执行了,这种情况的执行结果后面会打上UP-TO-DATE
的标记,它会复用上一次的缓存结果
ResolveInputChangesStep
InputChanges
核心部分已经在ResolveChangesStep处理完了,这里只是封装一下
StoreExecutionStateStep
这里是将执行结果状态AfterExecutionState
进行保存,AfterExecutionState
是由后续步骤生成的
只有执行成功,且outputs files
有所变动才进行保存outputs files
的变动是通过对比AfterExecutionState
和PreviousExecutionState
得到的,比较过程和InputChanges
中对outputs的对比一样
RecordOutputsStep
CaptureStateAfterExecutionStep会将Task的outputs快照记录下来,添加到AfterExecutionState
中
这里就是将这些outputs文件路径保存下来,存在 当前项目根目录/.gradle/buildOutputCleanup/outputFiles.bin 里
也是CleanupStaleOutputsStep中用来判断文件是否由gradle生成的依据
BuildCacheStep
在ResolveCachingStateStep提到它是来判断是否可以使用缓存以及生成BuildCacheKey的
这里就是使用那一步得到的结果的地方了
不能使用缓存的话就继续往下走,如果可以使用缓存的话,就从缓存中读取结果
Task执行Execution中,其实只有INCREMENTAL支持缓存读取,其他的几个都不支持,这个在ResolveCachingStateStep是没有处理的,因为虽然它不能读取缓存,但是它的执行结果可以被存到缓存中
缓存优先读取本地local的,如果本地没有就从读取远程缓存
本地缓存保存的目录为 ~/.gradle/caches/build-cache-1
远程缓存的读取会先请求服务端,实际就是以BuildCacheKey和服务器地址构造一个GET请求,如果结果返回正常,会将其保存到本地缓存中
缓存文件找到后需要对其进行解压,本质它是gzip压缩的文件,文件名就是key
以compileJava
task为例,看看build cache文件缓存格式是什么样子的
METADATA
tree-destinationDirectory
tree-options.generatedSourceOutputDirectory
tree-options.headerOutputDirectory
tree-previousCompilationData
METADATA是记录一些元数据,里面有buildInvocationId、gradle版本、执行耗时、task名称等信息
然后每个output属性都会对应有以tree-属性名
为名称的目录,里面保存着当时执行Task时生成的文件
gradle先是通过BuildCacheKey在本地缓存目录找到对应的gzip文件,然后unpack
它,通过正则匹配到outputs属性的输出文件,进行复用
如果缓存读取失败,那么就会真正执行task,并在之后将其结果保存到缓存中,缓存会在local和server都进行保存
server端的保存是HttpBuildCacheService
处理的,和读取类似,这里构建了一个PUT请求,将outputs文件pack
为gzip文件上传
CaptureStateAfterExecutionStep
这一步会构造一个OriginMetadata
,并将task的outputs指定的文件快照记录下来,作为AfterExecutionState
的outputsProducedByWork
其他参数AfterExecutionState
都是和BeforeExecutionState
一样的
CreateOutputsStep
确保outputs属性指定的文件目录存在,对于目录类型会去创建,对于文件类型会创建其父目录
TimeoutStep
task可以设置超时时间,如果设置了超时时间,会启一个定时器到时interrupt
Task的执行线程
CancelExecutionStep
Task可以被取消,被取消时也是通过interrupt
Task的执行线程来实现的
RemovePreviousOutputsStep
这一步是针对预期增量构建,但因为某些原因导致没有进行增量构建的Task,删除其之前的outputs文件,例如某些input属性变动,导致需要重新构建
ExecuteStep
真正执行任务的step,也就是依次执行Task的actions
至此Task的执行逻辑就全部分析完毕
参考文档
Authoring Tasks
Incremental build
Developing Custom Gradle. Task Types