Gradle深入解析 - Task原理(执行篇)

1,120 阅读28分钟

前2篇文章探究了gradle是如何处理Task GraphTask调度的,至此Task的前期工作就已经完成了
下面就该执行Task了,如果观察过Task执行的话,会留意到console输出中Task后面有的带有执行结果的标识,如SKIPPEDUP-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  
}

onlyIfenabled都可以控制Task执行条件,如果其结果是false,那这个Task就不需要被执行,SkipOnlyIfTaskExecuter就是用来判断这个的

如果在控制台看到有Task执行结果后面带有SKIPPED标识,那么通常在这一步处理掉的

还有一种特殊情况,我们可以在Task的action中抛出StopExecutionException异常,这种情况输出结果后面不会带有SKIPPED标识,不是由SkipOnlyIfTaskExecuter处理,这种情况和上面有相同之处,在Task执行失败之后,依赖于它的Task依旧能够执行

SkipTaskWithNoActionsExecuter

如果Task没有action,那它就不需要执行,通常这些都是lifecycle tasks

  • lifecycle tasks gradle的LifecycleBasePlugin有一些内置的lifecycle tasks,例如buildtestclean
    这些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

  1. UNTRACKED 当Task被注解上了@UntrackedTask
  2. RERUN_TASKS_ENABLED 执行gradle命令时,后面加了--rerun-tasks
  3. NO_OUTPUTS Task outputs可以设置upToDateWhen来决定其是否复用之前的结果,如果Task既没有声明任何outputs属性,也没有设置upToDateWhen的话,为此执行模式
  4. UP_TO_DATE_WHEN_FALSE 当Task的upToDateWhen返回false时
  5. INCREMENTAL 其他情况时的执行模式,但Task是否能够真正增量执行还有很多因素影响

这些类型主要是对下面3种属性的封装,这3种属性对Task的增量build有影响。从名称上比较容易理解,在后面的分析中会了解到它们如何影响构建过程

rebuildReason
taskHistoryMaintained
allowedToUseCachedResults

ExecutionModerebuildReasontaskHistoryMaintainedallowedToUseCachedResults
INCREMENTALnulltruetrue
NO_OUTPUTSTask has not declared any outputs despite executing actionsfalsefalse
RERUN_TASKS_ENABLEDExecuted with '--rerun-tasks'truefalse
UP_TO_DATE_WHEN_FALSETask.upToDateWhen is falsetruefalse
UNTRACKEDTask state is not trackedfalsefalse

FinalizePropertiesTaskExecuter

Propertyfinalize以确定下来,不再接受对属性的改动
Property在Task Graph篇章中有介绍过,这里就不再介绍了
compileJava举例,就是classpathdestinationDirectorysourceCompatibilitytargetCompatibility等等一些参数

ExecuteActionsTaskExecuter

Task真正执行的地方,它会将Task转化为Unit Of Work去执行

Unit Of Work

UnitOfWork,从名字上理解,它是work的最小单元,gradle抽象的一个更细粒度的用来描述Work的接口,先来一张图以对其有个大致的理解

UnitOfWorkexecute方法入参为ExecutionRequest,返回结果为WorkOutput
ExecutionRequestInputVisitorExecutionBehavior的影响
WorkOutput也会被OutputVisitorWorkResult进行分析
Identity是用来为UnitOfWork提供唯一标识用的
这些几乎都是接口,gradle制定了UnitOfWork的整体框架,剩下的实现部分并没有约束

Step

UnitOfWork的执行是由一系列的Step去执行的,StepTaskExecuter一样使用了代理模式,它的实现更加复杂

从接口能看出几个概念

Step
Context
Result
UnitOfWork

大概的示意图如下

ContextStep执行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表示会在某个工作目录下执行actionaction会收到2个参数,workspacehistoryhistory就是上一次构建的结果

一般是在withWorkspace中调用actionexecuteInWorkspace,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_OUTPUTSUNTRACKED这2种是不支持history
history具体的加载逻辑在后面LoadPreviousExecutionStateStep

CleanupStaleOutputsStep

这是用来清理一些腐坏的build文件用的,主要是用于处理gradle版本更新场景的
也只针对能支持history的Task,因为这些Task才可能会产生输出,而这些输出的文件可能由于各种原因不正常了
这一步会删除outputs输出的文件中ownedByBuild非gradle生成的文件,2个条件

  • ownedByBuild
  • 非generatedByGradle

2者其实都是通过文件路径来判断的,而判断ownedByBuildgeneratedByGradle所通过的文件路径的集合是不一样的

  • ownedByBuild
    通过调用BuildOutputCleanupRegistry.registerOutputs来将build目录添加进来
    clean task将build目录添加进来了
    java plugin将sourcSets的output文件目录加进来了
    只要是位于BuildOutputCleanupRegistry中文件目录的文件,都属于是ownedByBuild
  • generatedByGradle
    RecordOutputStep会将输出的文件路径都保存下来,结果保存在 当前项目根目录/.gradle/buildOutputCleanup/outputFiles.bin
    例如compileJava task会将build/classes/java/mainbuild/generated/sources/annotationProcessor/java/main等文件路径保存下来
    jar task会将build/libs/xxx.jar路径保存下来
    保存的是outputs属性指定的文件路径,其目录下的文件的路径是不会被保存的。所以compileJava task保存的是classes/java/main路径,这里面编译出来的classes文件路径是不处理的

outputFiles.png

虽然感觉它像是每次构建前会去删除不属于上次构建的文件
但实际如果没有历史构建记录的话,手动在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

这里的keyidentity.getUniqueId(),对于task来说就是它的完整路径,如:lib:compileJava

这里的实现中会有一个keySerializer和一个valueSerializer,分别负责key和value的序列化和反序列化工作,这里的key为字符串所以不需要特别处理,valueSerializer会将缓存反序列化为PreviousExecutionState
这里序列化/反序列化具体实现使用的是之前在gradle脚本篇章中提到过的kryo三方库

使用到了内存、文件双缓存
保存的路径为 当前项目根目录/.gradle/8.0(gradle版本)/executionHistory/executionHistory.bin

executionHistory.png

PreviousExecutionState

ExecutionState的属性比较多,先看看脑图好有个整体印象

  • originMetadata
    • buildInvocationId 每次build都会生成一个uuid,此次build所有的task使用的都是这个id
    • executionTime 执行耗时
  • 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包含
    • absolutePath
    • fileType(RegularFile,Directory,Missing)
    • contentHash - RegularFile是其内容的hash,Directory和Missing类型是常量,重要的就是这个hash值了
    • normalizedPath 根据normalization策略而来的path,具体在后续说明 rootHashes 基于子文件hash值计算出的hash
      strategyConfigurationHash 采用的normalization策略本身的hash值
  • outputFilesProducedByWork
    outputs属性指定的输出文件的快照,返回值类型为FileSystemSnapshots
    记录下整个outputs文件树结构的快照FileSystemSnapshot,本身包含absolutePathname(文件名)属性,会遵守文件的顺序和文件的树形结构,分为3种类型
    目录为DirectorySnapshot
    • children 子文件
    • contentHash 基于子文件hash值计算出的hash 文件为RegularFileSnapshot,包含
    • contentHash 文件内容的hash
    • lastModified
    • length 缺失情况为MissingFileSnapshot
  • successful: Boolean 是否执行成功

有3个ExecutionState,3者所包含的信息基本一致

PreviousExecutionState 上一次task执行后的状态
BeforeExecutionState 本次task执行前的状态
AfterExecutionState 本次task执行后的状态

ExecutionState记录了Task本身以及inputs/outputs的所有信息,这些信息有几个主要的作用

  1. 是用于和task上次执行的结果进行比较,如果属性全部没有改变过,那它符合up-to-date
  2. 用于找出增量构建时发生改变的属性、文件等,具体在后面的ResolveChangesStep会详细说明
  3. build cache key的计算

MarkSnapshottingInputsStartedStep

标记一下input snapshot开始

RemoveUntrackedExecutionStateStep

这是执行善后工作的,它会先让后续Step执行完,执行的Result可能会带有一个 AfterExecutionState,用来记录本次执行的状态,和PreviousExecutionState对应
如果有PreviousExecutionState,那就会有AfterExecutionState
如果PreviousExecutionState没有,那AfterExecutionState也没有

PreviousExecutionState是取决于是否支持history的,也就是说NO_OUTPUTUNTRACKED这2种执行方式,在后续Step中有可能产生缓存,而在这一步会将其缓存清除掉

SkipEmptyWorkStep

gradle Task执行结果后面带有的NO_SOURCE标识,就是在这一步处理掉的

@SkipWhenEmpty的文件属性或者调用了skipWhenEmpty给属性强制设置不能为空,如果没有对应的inputs文件存在的话,会跳过它的执行,返回NO_SOURCE结果
不止如此,如果这个Task有上一次构建的历史文件存在,而这次没有inputs文件存在的话,会将上次的缓存清楚,此时执行结果是EXECUTED

例如 Copy task,可以通过fromto来设置待复制的文件和目标路径
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入手看看是否这就够了

  1. 我们会记录文件路径,那该记录什么路径呢?

文件路径有绝对路径和相对路径,我们该记录哪个?
看上去相对路径更合理,但是否这样就满足所有需求了呢?
比如有一个对jar包进行transform的action,只要jar包名称没变,内容没变,我就认为它是没有变化的,但是如果它生成的目录层级变化了,如果使用相对路径记录,就会认为它发生了变化
还有些情况,目录下面有空目录,这些路径是否需要记录,比如编译java代码的时候,这些空目录文件不会对结果产生任何影响,我们可以忽略掉,但如果记录了它们,那删除空目录会导致前后2次构建的inputs不同,而重新构建

  1. 文件的内容hash不变是否能等同于表示文件没有变?

文件内容hash没有变,文件内容肯定是一样的
那么反过来呢,有没有什么场景是我们虽然修改了文件,但我们这种改动对文件是没有影响的呢 比如properties文件,里面可以添加多个配置,如果加一行注释,该影响Task up-to-date的检测吗,如果将2个属性位置换一下又如何呢?
再比如class path,我们在编译java代码的时候通常需要依赖,这些依赖都是通过jar包或者目录的方式添加到class path中的,这些jar包内添加了一些资源文件,又或者是某个private方法改了,需要我们的代码重新编译吗?

所以针对这些问题,gradle需要对文件的snapshot进行fingerprint操作,这个过程也叫做normalization

Normalization

normalization有标准化,归一化的意思,影响normalization的主要有3个方面FileNormalizerDirectorySensitivityLineEndingSensitivity,下面我们来看看它们究竟都做了些什么

FileNormalizer

FileNormalizer主要影响normalizationPath和文件内容hash的生成,结合上面对文件路径的讨论,normalizationPath就是用来标准化文件路径的

PathSensitivity

  1. ABSOLUTE
    normalizationPath为绝对路径,这会对build cache的共享有影响,绝对路径不同会导致hash不同,缓存没法复用
    默认是这个,所以自定义Task想要使用build cache时需要注意这点⚠️
  2. RELATIVE
    一般想要有缓存复用的属性尽量使用这个,这样就不会受项目目录的影响,也可以和其他机器共用缓存
    例如compileJava task的stableSources,也就是sourceSet定义的目录,默认是src/main
    位于根目录的文件,normalizedPath取文件名
    目录内的文件,normalizedPath取和根目录路径的相对路径
  3. NAME_ONLY
    normalizedPath为文件名,文件名不变,层级改变也没关系
    Transformation,和@InputArtifact一起用的情况比较多,只要artifact的文件名、内容没变,outputs没变,层级变了不影响up-to-date的check
  4. NONE
    只对文件类型计算hash
    normalizedPath为空字符串
    文件路径不重要,只关心文件内容
    例如Pmd plugin的ruleSetFiles使用的就是PathSensitivity.NONEruleSetFiles是xml文件,里面是对issue的一些自定义操作,比如排除掉对某些目录的检测等等,不关心xml的名称,只关心里面的内容是否发生了变化
    但有一点值得注意,使用PathSensitivity.NONE时,如果你改了脚本文件的文件路径,但是没有改动文件内容,虽然文件本身的hash没有改变,但是Action实现的hash可能因此改变,所以还是有变动的
    所以这个属性用在目录上更适合,其内部文件层级变动、名称改变不会产生影响,或者使用通配符的方式

CompileClasspath

classpath情况比较复杂,需要单拎出来说,甚至要区分runtimecompile

@CompileClasspath注解的属性,其normalization使用的即是CompileClasspath

指纹提取的逻辑在CompileClasspathFingerprinter
compile classpath可能有目录和jar包,里面除了class文件外可能还有其他文件 它只关心class文件,文件的顺序也不关心,其中对class文件hash的工作是交由AbiExtractingClasspathResourceHasher处理的
AbiExtractingClasspathResourceHasher使用org.objectweb.asm库来从类字节码提取信息,对于private类会忽略,其他访问修饰符声明的类,将它们的publicprotectdefault声明的方法的方法名、返回值、入参类型、注解、异常抛出等等信息进行记录,还有对字段的相关信息的记录

这里有一个ABI的概念
ABI(application binary interface)
ABI是二进制程序模块间的接口,通常是用machine code定义的数据结构、计算流程的访问,使用偏底层的,硬件依赖的格式
API是源码定义的,相对高级的,不依赖硬件且一般是可读的格式

实际上这里的ABIAPI基本内容是一样的,这里说的API是指定义的外部可用的方法,字段等,通常是public的
因为拿到一个jar包,它里面public声明的类,方法等,其实我们都能够使用,就相当于是其暴露出来的API。只不过因为是从class字节码提取的信息,所以这里的ABI可以简单看作是API的字节码版本

下面这些case对于compile classpath没有影响,也就是说下面这些情况不会导致项目重新编译

  • jar或者根目录路径的变动
  • jar包内的时间戳、entry的顺序变化
  • jar包内resourcesmanifest的变动,包括添加删除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造成影响,可以从propertiesmetaInfresources3个方面进行自定义,相关API的使用可以参考configure_input_normalization

比如下面这个例子,忽略所有.properties文件中时间戳属性,它不会参与到hash的计算中去,如果timestamp变了,也不会影响到Task up-to-date的check

normalization {
    runtimeClasspath {
        properties {
            ignoreProperty 'timestamp'
        }
    }
}

javaDoc就使用到了@Classpath注解

这里的CompileClasspathRuntimeClasspath不是完全和java编译、执行过程的术语等同,更偏向于对这些类型的class path的通常的处理方式

这也能给缓存优化提供一些思路,java定义依赖有2种基础的方式apiimplementation
其区别大家肯定都知道,api建立的依赖关系,它会导致transitive dependencies影响到项目的compile classpath
比如 projectA api 依赖了 libA, libA 又依赖了 libB,不管 libA 是用什么方式依赖的libB
projectAcompile 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的进行对比,previousbefore的对比多出来的部分就是OverlappingOutputs,还能区分具体是哪个属性的

理解起来也比较简单,排除人为干扰的情况,正常在当前Task执行前,outputs的文件应该和PreviousExecutionState所记录的一致,如果有多出来的部分,那应该就是有后续Task的输出占用了同样的路径

ValidateStep

这一步主要验证@CacheableTask注解标注的Task的问题

如果其Task有标注@CacheableTask,那么其相应的@InputFile等注解需要额外标注上normalization相关注解
normalizationCaptureStateBeforeExecutionStep中有详细说明,这些会影响到缓存的有效性
Task默认都是@DisableCachingByDefault

ResolveCachingStateStep

这一步是判断Task能否使用缓存,并生成BuildCacheKey

  1. 如果build_cache没有开启(org.gradle.caching为false)不能够使用缓存
    如果开启了,但是Task有验证问题也不行,因为只有error的问题会打断构建,warning的不会,所以需要先将所有error、warning等报错修复,才能使用caching
  2. 如果Task被注解上了@DisableCachingByDefault的话那也不支持caching
  3. 如果Task没有outputs文件,缓存就是针对输出的文件做的,没有输出自然不需要缓存。不止如此,如果outputs存在属性返回类型为FileTree,也是不支持caching的,返回FileFileCollection可以
  4. OverlappingOutputs的情况
  5. cacheIfdoNotCacheIf中的判断条件也会有影响

FileTreeFileCollection的区别是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

影响增量的因素主要有

  1. Task执行模式的属性rebuildReason
  2. Task的ExecutionBehavior
  3. Task是否存在验证问题,有warning的task不支持
  4. 是否存在上次构建结果的记录,如果没有也不支持

RebuildReason

5种执行模式中,只有INCREMENTAL没有rebuildReason,其他情况都有,也就是说其他的执行模式都是全量构建的
ResolveTaskExecutionModeExecuter小节中有列出不同执行模式rebuildReason

只有增量构建才可以使用构建缓存,其他几种类型的执行模式走到这意味着Task一定会被EXECUTED

ExecutionBehavior

ExecutionBehavior有2种

NON_INCREMENTAL
INCREMENTAL

Task是根据自己是否有以InputChanges作为参数action来区别是否支持增量的
InputChanges有所有变动过的文件,以及它们变动的类型changeTypechangeType可以区分是新增,删除还是修改,还有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,注解了@IncrementalINCREMENTAL,其他情况都是NON_INCREMENTAL,这些注解都是针对input files
根据每个input files的属性注解的不同,其InputBehavior也可能不同

PRIMARYINCREMENTAL都是支持增量的,它们的区别是在对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是否发生了改变,具体比较下面几个方面

  1. Task实现和Action实现是否有变动 比较本次和上次构建的Task及Actions的classIdentifierclassLoaderHash
  2. input属性(非文件部分的属性)是否有变化 一方面需要对比是否有属性新增或减少,一方面需要比较具体的值是否发生了变化
  3. input files属性是否有变化 input files属性是否有新增或减少
    非增量input files属性部分的文件是否有变动,这里通过比较fingerprint信息判断的
  4. 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的变动是通过对比AfterExecutionStatePreviousExecutionState得到的,比较过程和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是记录一些元数据,里面有buildInvocationIdgradle版本执行耗时task名称等信息
然后每个output属性都会对应有以tree-属性名为名称的目录,里面保存着当时执行Task时生成的文件

gradle先是通过BuildCacheKey在本地缓存目录找到对应的gzip文件,然后unpack它,通过正则匹配到outputs属性的输出文件,进行复用

如果缓存读取失败,那么就会真正执行task,并在之后将其结果保存到缓存中,缓存会在local和server都进行保存
server端的保存是HttpBuildCacheService处理的,和读取类似,这里构建了一个PUT请求,将outputs文件pack为gzip文件上传

CaptureStateAfterExecutionStep

这一步会构造一个OriginMetadata,并将task的outputs指定的文件快照记录下来,作为AfterExecutionStateoutputsProducedByWork
其他参数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