前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目录添加进来
cleantask将build目录添加进来了
java plugin将sourcSets的output文件目录加进来了
只要是位于BuildOutputCleanupRegistry中文件目录的文件,都属于是ownedByBuild的 - generatedByGradle
RecordOutputStep会将输出的文件路径都保存下来,结果保存在 当前项目根目录/.gradle/buildOutputCleanup/outputFiles.bin 中
例如compileJavatask会将build/classes/java/main、build/generated/sources/annotationProcessor/java/main等文件路径保存下来
jartask会将build/libs/xxx.jar路径保存下来
保存的是outputs属性指定的文件路径,其目录下的文件的路径是不会被保存的。所以compileJavatask保存的是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的属性比较多,先看看脑图好有个整体印象
originMetadatabuildInvocationId每次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的map
FileCollectionFingerprint,这是对FileSystemSnapshot,也就是从文件类型的快照提取的指纹信息,这也是一个map,key是文件absolutePath,value包含absolutePathfileType(RegularFile,Directory,Missing)contentHash- RegularFile是其内容的hash,Directory和Missing类型是常量,重要的就是这个hash值了normalizedPath根据normalization策略而来的path,具体在后续说明rootHashes基于子文件hash值计算出的hash
strategyConfigurationHash采用的normalization策略本身的hash值
outputFilesProducedByWork
outputs属性指定的输出文件的快照,返回值类型为FileSystemSnapshots
记录下整个outputs文件树结构的快照FileSystemSnapshot,本身包含absolutePath,name(文件名)属性,会遵守文件的顺序和文件的树形结构,分为3种类型
目录为DirectorySnapshotchildren子文件contentHash基于子文件hash值计算出的hash 文件为RegularFileSnapshot,包含contentHash文件内容的hashlastModifiedlength缺失情况为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
一般想要有缓存复用的属性尽量使用这个,这样就不会受项目目录的影响,也可以和其他机器共用缓存
例如compileJavatask的stableSources,也就是sourceSet定义的目录,默认是src/main
位于根目录的文件,normalizedPath取文件名
目录内的文件,normalizedPath取和根目录路径的相对路径 - NAME_ONLY
normalizedPath为文件名,文件名不变,层级改变也没关系
Transformation,和@InputArtifact一起用的情况比较多,只要artifact的文件名、内容没变,outputs没变,层级变了不影响up-to-date的check - NONE
只对文件类型计算hash
normalizedPath为空字符串
文件路径不重要,只关心文件内容
例如Pmdplugin的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、resources3个方面进行自定义,相关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
比如projectAapi 依赖了libA,libA又依赖了libB,不管libA是用什么方式依赖的libB
projectA的compile classpath都会有libB,那如果libB修改了影响ABI的代码,则会导致projectArebuild,即使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