写点什么

gradle 中的增量构建

发布于: 2021 年 02 月 17 日

简介

在我们使用的各种工具中,为了提升工作效率,总会使用到各种各样的缓存技术,比如说 docker 中的 layer 就是缓存了之前构建的 image。在 gradle 中这种以 task 组合起来的构建工具也不例外,在 gradle 中,这种技术叫做增量构建。


增量构建

gradle 为了提升构建的效率,提出了增量构建的概念,为了实现增量构建,gradle 将每一个 task 都分成了三部分,分别是 input 输入,任务本身和 output 输出。下图是一个典型的 java 编译的 task。



以上图为例,input 就是目标 jdk 的版本,源代码等,output 就是编译出来的 class 文件。


增量构建的原理就是监控 input 的变化,只有 input 发送变化了,才重新执行 task 任务,否则 gradle 认为可以重用之前的执行结果。


所以在编写 gradle 的 task 的时候,需要指定 task 的输入和输出。


并且要注意只有会对输出结果产生变化的才能被称为输入,如果你定义了对初始结果完全无关的变量作为输入,则这些变量的变化会导致 gradle 重新执行 task,导致了不必要的性能的损耗。


还要注意不确定执行结果的任务,比如说同样的输入可能会得到不同的输出结果,那么这样的任务将不能够被配置为增量构建任务。


自定义 inputs 和 outputs

既然 task 中的 input 和 output 在增量编译中这么重要,本章将会给大家讲解一下怎么才能够在 task 中定义 input 和 output。


如果我们自定义一个 task 类型,那么满足下面两点就可以使用上增量构建了:


第一点,需要为 task 中的 inputs 和 outputs 添加必要的 getter 方法。


第二点,为 getter 方法添加对应的注解。


gradle 支持三种主要的 inputs 和 outputs 类型:


  1. 简单类型:简单类型就是所有实现了 Serializable 接口的类型,比如说 string 和数字。

  2. 文件类型:文件类型就是 File 或者 FileCollection 的衍生类型,或者其他可以作为参数传递给 Project.file(java.lang.Object) 和 Project.files(java.lang.Object…) 的类型。

  3. 嵌套类型:有些自定义类型,本身不属于前面的 1,2 两种类型,但是它内部含有嵌套的 inputs 和 outputs 属性,这样的类型叫做嵌套类型。

接下来,我们来举个例子,假如我们有一个类似于 FreeMarker 和 Velocity 这样的模板引擎,负责将模板源文件,要传递的数据最后生成对应的填充文件,我们考虑一下他的输入和输出是什么。


输入:模板源文件,模型数据和模板引擎。


输出:要输出的文件。


如果我们要编写一个适用于模板转换的 task,我们可以这样写:


import java.io.File;import java.util.HashMap;import org.gradle.api.*;import org.gradle.api.file.*;import org.gradle.api.tasks.*;
public class ProcessTemplates extends DefaultTask { private TemplateEngineType templateEngine; private FileCollection sourceFiles; private TemplateData templateData; private File outputDir;
@Input public TemplateEngineType getTemplateEngine() { return this.templateEngine; }
@InputFiles public FileCollection getSourceFiles() { return this.sourceFiles; }
@Nested public TemplateData getTemplateData() { return this.templateData; }
@OutputDirectory public File getOutputDir() { return this.outputDir; }
// 上面四个属性的setter方法
@TaskAction public void processTemplates() { // ... }}
复制代码

上面的例子中,我们定义了 4 个属性,分别是 TemplateEngineType,FileCollection,TemplateData 和 File。前面三个属性是输入,后面一个属性是输出。


除了 getter 和 setter 方法之外,我们还需要在 getter 方法中添加相应的注释: @Input , @InputFiles ,@Nested 和 @OutputDirectory, 除此之外,我们还定义了一个 @TaskAction 表示这个 task 要做的工作。


TemplateEngineType 表示的是模板引擎的类型,比如 FreeMarker 或者 Velocity 等。我们也可以用 String 来表示模板引擎的名字。但是为了安全起见,这里我们自定义了一个枚举类型,在枚举类型内部我们可以安全的定义各种支持的模板引擎类型。


因为 enum 默认是实现 Serializable 的,所以这里可以作为 @Input 使用。


sourceFiles 使用的是 FileCollection,表示的是一系列文件的集合,所以可以使用 @InputFiles。


为什么 TemplateData 是 @Nested 类型的呢?TemplateData 表示的是我们要填充的数据,我们看下它的实现:


import java.util.HashMap;import java.util.Map;import org.gradle.api.tasks.Input;
public class TemplateData { private String name; private Map<String, String> variables;
public TemplateData(String name, Map<String, String> variables) { this.name = name; this.variables = new HashMap<>(variables); }
@Input public String getName() { return this.name; }
@Input public Map<String, String> getVariables() { return this.variables; }}
复制代码

可以看到,虽然 TemplateData 本身不是 File 或者简单类型,但是它内部的属性是简单类型的,所以 TemplateData 本身可以看做是 @Nested 的。


outputDir 表示的是一个输出文件目录,所以使用的是 @OutputDirectory。


使用了这些注解之后,gradle 在构建的时候就会检测和上一次构建相比,这些属性有没有发送变化,如果没有发送变化,那么 gradle 将会直接使用上一次构建生成的缓存。


注意,上面的例子中我们使用了 FileCollection 作为输入的文件集合,考虑一种情况,假如只有文件集合中的某一个文件发送变化,那么 gradle 是会重新构建所有的文件,还是只重构这个被修改的文件呢?

留给大家讨论


除了上讲到的 4 个注解之外,gradle 还提供了其他的几个有用的注解:


  • @InputFile: 相当于 File,表示单个 input 文件。

  • @InputDirectory: 相当于 File,表示单个 input 目录。

  • @Classpath: 相当于 Iterable<File>,表示的是类路径上的文件,对于类路径上的文件需要考虑文件的顺序。如果类路径上的文件是 jar 的话,jar 中的文件创建时间戳的修改,并不会影响 input。

  • @CompileClasspath:相当于 Iterable<File>,表示的是类路径上的 java 文件,会忽略类路径上的非 java 文件。

  • @OutputFile: 相当于 File,表示输出文件。

  • @OutputFiles: 相当于 Map<String, File> 或者 Iterable<File>,表示输出文件。

  • @OutputDirectories: 相当于 Map<String, File> 或者 Iterable<File>,表示输出文件。

  • @Destroys: 相当于 File 或者 Iterable<File>,表示这个 task 将会删除的文件。

  • @LocalState: 相当于 File 或者 Iterable<File>,表示 task 的本地状态。

  • @Console: 表示属性不是 input 也不是 output,但是会影响 console 的输出。

  • @Internal: 内部属性,不是 input 也不是 output。

  • @ReplacedBy: 属性被其他的属性替换了,不能算在 input 和 output 中。

  • @SkipWhenEmpty: 和 @InputFiles 跟 @InputDirectory 一起使用,如果相应的文件或者目录为空的话,将会跳过 task 的执行。

  • @Incremental: 和 @InputFiles 跟 @InputDirectory 一起使用,用来跟踪文件的变化。

  • @Optional: 忽略属性的验证。

  • @PathSensitive: 表示需要考虑 paths 中的哪一部分作为增量的依据。

运行时 API

自定义 task 当然是一个非常好的办法来使用增量构建。但是自定义 task 类型需要我们编写新的 class 文件。有没有什么办法可以不用修改 task 的源代码,就可以使用增量构建呢?


答案是使用 Runtime API。


gradle 提供了三个 API,用来对 input,output 和 Destroyables 进行获取:


  • Task.getInputs() of type TaskInputs

  • Task.getOutputs() of type TaskOutputs

  • Task.getDestroyables() of type TaskDestroyables

获取到 input 和 output 之后,我们就是可以其进行操作了,我们看下怎么用 runtime API 来实现之前的自定义 task:


task processTemplatesAdHoc {    inputs.property("engine", TemplateEngineType.FREEMARKER)    inputs.files(fileTree("src/templates"))        .withPropertyName("sourceFiles")        .withPathSensitivity(PathSensitivity.RELATIVE)    inputs.property("templateData.name", "docs")    inputs.property("templateData.variables", [year: 2013])    outputs.dir("$buildDir/genOutput2")        .withPropertyName("outputDir")
doLast { // Process the templates here }}
复制代码

上面例子中,inputs.property() 相当于 @Input ,而 outputs.dir() 相当于 @OutputDirectory。


Runtime API 还可以和自定义类型一起使用:


task processTemplatesWithExtraInputs(type: ProcessTemplates) {    // ...
inputs.file("src/headers/headers.txt") .withPropertyName("headers") .withPathSensitivity(PathSensitivity.NONE)}
复制代码

上面的例子为 ProcessTemplates 添加了一个 input。


隐式依赖

除了直接使用 dependsOn 之外,我们还可以使用隐式依赖:


task packageFiles(type: Zip) {    from processTemplates.outputs}
复制代码

上面的例子中,packageFiles 使用了 from,隐式依赖了 processTemplates 的 outputs。


gradle 足够智能,可以检测到这种依赖关系。


上面的例子还可以简写为:


task packageFiles2(type: Zip) {    from processTemplates}
复制代码

我们看一个错误的隐式依赖的例子:


plugins {    id 'java'}
task badInstrumentClasses(type: Instrument) { classFiles = fileTree(compileJava.destinationDir) destinationDir = file("$buildDir/instrumented")}
复制代码

这个例子的本意是执行 compileJava 任务,然后将其输出的 destinationDir 作为 classFiles 的值。


但是因为 fileTree 本身并不包含依赖关系,所以上面的执行的结果并不会执行 compileJava 任务。


我们可以这样改写:


task instrumentClasses(type: Instrument) {    classFiles = compileJava.outputs.files    destinationDir = file("$buildDir/instrumented")}
复制代码

或者使用 layout:


task instrumentClasses2(type: Instrument) {    classFiles = layout.files(compileJava)    destinationDir = file("$buildDir/instrumented")}
复制代码

或者使用 buildBy:


task instrumentClassesBuiltBy(type: Instrument) {    classFiles = fileTree(compileJava.destinationDir) {        builtBy compileJava    }    destinationDir = file("$buildDir/instrumented")}
复制代码

输入校验

gradle 会默认对 @InputFile ,@InputDirectory 和 @OutputDirectory 进行参数校验。


如果你觉得这些参数是可选的,那么可以使用 @Optional。


自定义缓存方法

上面的例子中,我们使用 from 来进行增量构建,但是 from 并没有添加 @InputFiles, 那么它的增量缓存是怎么实现的呢?


我们看一个例子:


public class ProcessTemplates extends DefaultTask {    // ...    private FileCollection sourceFiles = getProject().getLayout().files();
@SkipWhenEmpty @InputFiles @PathSensitive(PathSensitivity.NONE) public FileCollection getSourceFiles() { return this.sourceFiles; }
public void sources(FileCollection sourceFiles) { this.sourceFiles = this.sourceFiles.plus(sourceFiles); }
// ...}
复制代码

上面的例子中,我们将 sourceFiles 定义为可缓存的 input,然后又定义了一个 sources 方法,可以将新的文件加入到 sourceFiles 中,从而改变 sourceFile input,也就达到了自定义修改 input 缓存的目的。


我们看下怎么使用:


task processTemplates(type: ProcessTemplates) {    templateEngine = TemplateEngineType.FREEMARKER    templateData = new TemplateData("test", [year: 2012])    outputDir = file("$buildDir/genOutput")
sources fileTree("src/templates")}
复制代码

我们还可以使用 project.layout.files()将一个 task 的输出作为输入,可以这样做:


    public void sources(Task inputTask) {        this.sourceFiles = this.sourceFiles.plus(getProject().getLayout().files(inputTask));    }
复制代码

这个方法传入一个 task,然后使用 project.layout.files()将 task 的输出作为输入。


看下怎么使用:


task copyTemplates(type: Copy) {    into "$buildDir/tmp"    from "src/templates"}
task processTemplates2(type: ProcessTemplates) { // ... sources copyTemplates}
复制代码

非常的方便。


如果你不想使用 gradle 的缓存功能,那么可以使用 upToDateWhen()来手动控制:


task alwaysInstrumentClasses(type: Instrument) {    classFiles = layout.files(compileJava)    destinationDir = file("$buildDir/instrumented")    outputs.upToDateWhen { false }}
复制代码

上面使用 false,表示 alwaysInstrumentClasses 这个 task 将会一直被执行,并不会使用到缓存。


输入归一化

要想比较 gradle 的输入是否是一样的,gradle 需要对 input 进行归一化处理,然后才进行比较。


我们可以自定义 gradle 的 runtime classpath 。


normalization {    runtimeClasspath {        ignore 'build-info.properties'    }}
复制代码

上面的例子中,我们忽略了 classpath 中的一个文件。


我们还可以忽略 META-INF 中的 manifest 文件的属性:


normalization {    runtimeClasspath {        metaInf {            ignoreAttribute("Implementation-Version")        }    }}
复制代码

忽略 META-INF/MANIFEST.MF :


normalization {    runtimeClasspath {        metaInf {            ignoreManifest()        }    }}
复制代码

忽略 META-INF 中所有的文件和目录:


normalization {    runtimeClasspath {        metaInf {            ignoreCompletely()        }    }}
复制代码

其他使用技巧

如果你的 gradle 因为某种原因暂停了,你可以送 –continuous 或者 -t 参数,来重用之前的缓存,继续构建 gradle 项目。


你还可以使用 –parallel 来并行执行 task。


本文已收录于 http://www.flydean.com/gradle-incremental-build/

最通俗的解读,最深刻的干货,最简洁的教程,众多你不知道的小技巧等你来发现!

欢迎关注我的公众号:「程序那些事」,懂技术,更懂你!


发布于: 2021 年 02 月 17 日阅读数: 10
用户头像

关注公众号:程序那些事,更多精彩等着你! 2020.06.07 加入

最通俗的解读,最深刻的干货,最简洁的教程,众多你不知道的小技巧,尽在公众号:程序那些事!

评论

发布
暂无评论
gradle中的增量构建