写点什么

Bruce Eckel 教你如何爬出 Gradle 的“坑”?

作者:图灵社区
  • 2022 年 1 月 17 日
  • 本文字数:6047 字

    阅读完需:约 20 分钟

Bruce Eckel教你如何爬出 Gradle 的“坑”?

王前明 译


《On Java 中文版》全书代码都是基于 Gradle 来构建的,很多书友表示对新手不够友好。刚开始接触 Gradle 时,Bruce 本人也有类似的感受。不过,他最后选择用自己的方式克服这种“陌生感”,也对 Gradle 有了更深入的认识。


本篇文章主要分享 Bruce 在学习 Gradle 时总结的一些方法论以及需要规避的陷阱,希望对你有帮助。



我于上世纪八十年代开始使用 make。在我创作 Thinkingin C++时,我创建了一个称为 makebuilder 的工具,它能分析书中提取出的示例代码并生成合适的 makefile。make 是一个只关注源文件之间的生成执行和依赖关系的工具,所以它还算是比较容易理解。


当我在写 Thinking inJava 时,我也使用了一个类似的工具 antbuilder,它能生成相关的 Ant 构建文件。虽然 Ant 是在“XML 即未来”的集体疯狂期间被创建的,但 Ant 仍然是一个专用于构建的工具,比较容易理解。


这就是我开始研究 Gradle 时的背景,一开始对其抱有一定期待,主要希望它具有简单的结构和设置过程,且可以像以上两种工具那样易于上手。我读到的关于 Gradle 的文章都印证了这个期望,说大多数配置都很简单,一般不需要深入到配置之下去了解更多。


与之相反,Gradle 对我来说却非常神秘。当我开始写 On Java 8 时,我的朋友 James Ward 建议我使用 Gradle,并且他会提供帮助。最终,他为本书创建了各种 Gradle 构建文件。我仅限于读懂和修改其中的一些部分,对此却依然一知半解。之后,在写作 Atomic Kotlin 时,仍然使用 Gradle,但由我的合著者 Svetlana 创建并管理该文件。


最近,要出版《On Java 中文版》的图灵公司让我添加关于 Java11 的最后一章。然而当我开始深入思考这件事时,我意识到我需要将本章的例子抽成独立的仓库,且有自己的基于 Java 11 的 Gradle 构建。我实在不想再向别人寻求帮助(更多的是因为身边离得最近的助手已经表示了对 Gradle 的极大厌恶)。所以我决定自己投入时间去搞清楚并理解它,至少让我能够搞定 Java11 的这一章,并且最好能在不扔东西的情况下搞定(这里指我可能会由于沮丧而扔东西,不是抛异常)。


通过网上搜索,我更多的接触了 Gradle 文档。凭着极大的耐心和毅力,Gradle 的神秘面纱逐渐被揭开。与此同时,我开始理解为什么以前让我那么困惑,为什么不能将 Gradle 视为配置的一种运用。不能以急躁的态度去接触它,而这就是曾经阻碍我的原因,也是我在用 Gradle 时的问题:


做任何事情之前你必须努力知道一切。


假如你确实可能会为一个基础的构建去创建一个简单的 build.gradle 文件。但通常的情况是,当你决定用 Gradle 去做构建时,问题已经足够复杂到以致于需要你做更多的事情,而“做更多的事情”就会转变为“需要知道更多”,简单的事情之后就是悬崖。


本文的目标是给予读者一些视角,从而让读者明白在掉落悬崖时发生了什么,如何才能再爬上去。

构建系统的基本要素


几乎每个构建系统都有两个基本要素:

  1. 任务:这组成了构建的操作功能菜单。一次构建通常有多个任务且一般情况下会通过命令行调用需要的任务,比如 gradle build。在其他构建系统中有不同的名称,如:在 make 中称之为 targets。

  2. 依赖:依赖说的是“这个不能在那个之前发生”。通常,这表示“在那个组件可用/更新之前,不能编译/执行当前这个组件”,但依赖可以指任何诸如此类的顺序:“先做这个,再做那个”。


依赖关系可以被分为“内部的”(依赖当前构建中的其他组件)或“外部的”(从本地或者远程的外部仓库更新的组件)。


构建工具通过读取脚本,该脚本通常位于标准命名的文件中,如 build.gradle 或者 Makefile。根据脚本中的指令,构建工具进行必要的操作以更新项目。


早期的构建工具(如 make)都是以配置为中心的,并将操作降级到外部程序中。例如,一个简单的 Makefile 看起来像这样:

vip: vip.o    cc vip.o -o vip
vip.o: vip.c cc -c vip.c -o vip.o
复制代码


没有缩进的那两行构成了依赖关系:vip 依赖 vip.o,vip.o 依赖 vip.c。如果我们修改了 vip.c,make 会知道 vip.c 当前比 vip.o 新,所以 vip.o 就变旧了。此时 make 会通过运行 cc -c vip.c-o vip.o 将 vip.o 更新。现在 vip

又比 vip.o 旧了,所以 make 又会运行命令 ccvip.o -o vip。


缩进的那两行命令(可怕的是,缩进用的是制表符,这是因为 make 是在 Unix 的早期被创建的,那时他们还痴迷于节省字节)并非 make 的一部分,而仅是 shell 命令行程序(在这个例子中,是 C 语言的编译器 cc)。除了根据 Makefile 执行命令会使目标更新之外,make 什么都不知道。


make 的简洁是优雅的,并且至今仍然有很多人在使用它。但随着时间的推移,由于大家开始在更大更复杂的程序中依赖 make,make 的一个重大限制被暴露出来。总是依赖外部程序作为命令变的很具挑战性,所以在 make 的一些版本中,开始添加越来越多的内部功能来满足这些需求。对于那些一直跟到 make 的最新版本的人,可能会有和我一样的“啊哈”瞬间,“这就是让 make 创建者停下来的原因吧!最新版使他们意识到自己正在构建一门编程语言”。


现代构建系统的作者们明白,在构建工具中需要有一定程度的编程能力支撑,而且通常情况下,与其迷失在创建一门新语言上,选择利用现有的编程语言显得更加合理。但问题是:

  • 选什么语言?最好的选择似乎应该是用户已经熟悉的语言,以降低学习构建系统时的认知障碍。

  • 语言侵入性有多强?这门语言在多大程度上主导了你的构建系统的使用体验?使用构建工具需要多少语言专业知识?

  • 语言如何影响?我理想的是一个看起来像现有语言的构建系统,在其中添加尽量少的额外语法,用以配置目标规则。如你所见,Gradle 的设计就受其所使用的 Groovy 语言影响。


我们仍处于“在现有语言之上添加构建系统”这种范式的早期阶段。Gradle 是其中的一个实现,所以我们期望会有一个除此之外的次优选择。但是,通过了解它存在的一些问题,会让大家在学习 Gradle 时少受点挫折。

Gradle 构建的问题



你不是在做配置,你是在编程


尽管 Gradle 尝试使之看起来就像声明配置一样,但每个配置实际上就是一个函数调用。基本上,除了一些语言指令之外,其他的要么是创建对象,要么就是调用函数。意识到这一点我认为非常有用,因为现在我看着这些配置声明,就知道他们实际上是在调用函数,让我理解起来更加容易。

Groovy 不是 Java


必须掌握绝大部分的 Groovy 语言才能写出有用的 Gradle 构建文件。我发现在深入研究 Groovy 之前,几乎不可能明白这里面到底发生了什么。Groovy 相较于 Java 有很大的提升,其中的一些特性影响了 Kotlin 的语言设计。


Groovy 的语法让人联想到 Java,但这是一门不同的语言,需要学习一套新的规则和技巧。而 Groovy 可以访问现有 Java 库的这个事实,让 Gradle 的开发者受益良多。


(旁白:理解 Kotlin 让我能理解 Groovy。)

Gradle 使用了领域特定语言


领域特定语言(DSL)专门用于特定的应用领域。所谓内部的 DSL,其目标就是为了将焦点缩小到当前问题上(比如:用于配置软件的构建)。因此,理想情况下用户只需要理解 DSL 就可以完成他们的工作。

例如,要告诉 Gradle 在哪里可以找到 Java 的源码文件,可以这样写:

sourceSets {    main {        java {            srcDirs 'java11'        }    }}
复制代码


这旨在创建一种声明的方式来描述构建,它依赖 Groovy 的 lambda 语法(不幸的是,他们称之为闭包)。如果函数调用的最后一个参数是一个 lambda,则可以将之放到参数列表之后。在这里,sourceSets, main 和 java 都是只有一个 lambda 参数的函数,所以不需要带括号的列表,只需要 lambda。因此 sourceSets、main 和 java 都是函数调用,但这些语法效果让它们看起来像是别的东西。


这些 DSL 语法真的有帮助吗?当我读到它们时,我必须在脑海中将之转换为函数调用。于我而言,这些额外的认知开销是一种障碍。这些 DSL 操作完全可以由函数调用完成,毕竟程序员已经熟悉了函数调用。一些人喜欢用函数调用来表达他们的构建文件,而将 DSL 语法忽略掉。


就像我前面说的那样,要想做除了基本构建之外的任何事情,就必须了解远比 DSL 语法更多的知识,所以 DSL 完全没有存在的理由。不幸的是,DSL 不仅仅是这其中的一部分,而且还是向新手介绍 Gradle 的常用方式。

用很多种方式来实现相同事情


Groovy 允许用许多不同的方式来实现相同事情,Gradle 文档似乎也专注于这种多样性的实现方式。当我们只想完成某件事情时,添加这些变化只会产生干扰。更糟的是,大家都会随意的使用不同的方式,这就导致我们必须了解并清楚这些复杂的语法。


比如,前面的 sourceSets 通过添加括号,可以配置为使用函数调用的语法:

sourceSets {    main {        java {            srcDirs('java11')        }    }}
复制代码


或者可以选择不用 DSL 语法而写的更加紧凑:

sourceSets.main.java.srcDirs = ['java11']
复制代码


也等同于这样:

sourceSets.main.java.srcDirs('java11')
复制代码


你可以在不同的方式之间自由选择,而大家也是这么做的,所以当阅读示例代码时,就必须要理解所有的变体。这增加了学习 Gradle 的成本。

引用《Python 之禅》(Zen of Python)中的一句话:


应该提供一种,并且最好只有一种,一目了然的解决方案。


能通过多种方式来做事情并没有益处。

特殊的魔法 


在没有完全理解发生了什么之前,似乎有许多神奇的东西需要特殊的魔法。


考虑创建一个 task。通常情况下我们会在 build.gradle 中用一个静态的声明:

task hello {    doLast {        println 'Hello world!'    }}
复制代码


doLastlambda 函数在 task 完成时执行。


此时,当你在命令行中执行 gradle hello 时,这个 hello 任务会执行。


实际上,你可以在函数中动态地创建任务。前提是必须知道 tasks 对象,它就在那里,自动地隐藏在每个 gradle 的构建中。如果在空的 build.gradle 文件中输入:tasks.forEach { printlnit }


它将打印出在 tasks 列表中的所有任务。而且不用自己创建任何任务你就能看到:

task ':buildEnvironment'task ':components'task ':dependencies'task ':dependencyInsight'task ':dependentComponents'task ':help'task ':init'task ':model'task ':outgoingVariants'task ':prepareKotlinBuildScriptModel'task ':projects'task ':properties'task ':tasks'task ':wrapper'
复制代码


在 tasks 对象中,我们可以找到用于动态任务创建的 create()方法(出于某种原因,tasks 似乎也包含其自身)。我们传入希望创建的任务名字,正如在 hello2 中所示。

task hello1 {    doLast {        println 'Hello 1!'    }}
tasks.create("hello2") { dependsOn hello1 doLast { println 'Hello 2!' }}
task("hello3") { dependsOn hello2 doLast { println 'Hello 3!' }}
task all { doLast { tasks.matching { it.name.startsWith("hello") }.forEach { println it.name } }}
复制代码


hello3 展示了另一种创建 task 的方法,只需调用 task()函数。注意每个 hello 任务都明显依赖上一个任务,所以如果你运行 gradle hello3,会看到 hello2 和 hello1 也会执行。all 遍历了 tasks 的列表,找出了所有名字以 hello 为开头的任务,并展示它们。


一般情况下,如果想设置一个项目级别的变量供多段代码使用,可以使用 ext,这是另一个存在的对象。它不仅保存项目级别的值,还可以从其他文件中收集值,并决定在发生冲突时如何覆盖这些值。

有时,你想要定义一些值并在文件的范围内使用它们。要用 Groovy 的类型推断来定义值,可以使用 def:

def config = "Configuration"
task x { println config}
String useConfig() { return config // Fails: can't see 'config'}
复制代码


如果函数没有任何返回,则可以使用 def 来定义,否则会需要给出返回类型。在这里,useConfig()返回了一个 String 类型。


虽然 config 在 task x 中是可见的,但却在函数 useConfig()中不可见。我不知道为什么会这样,但可以通过创建一个包含 static 属性的类来解决这个问题,这个属性在任务和函数中都有效:

class Vals {    static def config = "Configuration"}
task x1 { doLast { println "x1: ${Vals.config}" }}
String useConfig() { return Vals.config // Succeeds}
task x2 { doLast { println "x2: ${useConfig()}" }}
task all { dependsOn tasks.matching { it.name.startsWith("x") }}
复制代码


注意,all 依赖所有名字以 x 开头的任务,所以运行 all 会执行 x1 和 x2。

上面这些仅仅触及了冰山一角。

理解 LifeCycle 


如果不理解 LifeCycle,就会很容易犯错。比如,你不小心将代码放到了 task 的 lambda 表达式内,就像这样:

task a {    println "task a"}
复制代码


运行它似乎没什么问题:

>gradle a
> Configure project :task a
复制代码


它输出了预期的 task a,还有一个与之无关的输出:>Configure project :,但我知道有一个项目配置的阶段,所以可能这里就是这个意思。


它想告诉你的是,println 在配置阶段被调用,而不是在 a 这个任务被执行的时候。不幸的是,在某些时候却能达到预期的效果。


让之在执行 task 的时候运行代码,我们可以使用 doFirst 或 doLast,就像这样:

task a {    doFirst {        println "task a doFirst"    }    println "task a initialization"    doLast {        println "task a doLast"    }}
复制代码


现在输出了显示了初始化以及正在执行的任务:

>gradle a
> Configure project :task a initialization
> Task :atask a doFirsttask a doLast
复制代码


有很多类似于此的事情我们需要了解,否则就会体验到“惊喜”。


其他问题

  •  Gradle 的文档会假设你已经知道了很多。它不是一个教程,更像是一个核心转储。我现在明白了,因为在做任何事情之前你必须知道一切。但这个假设对新人并不友好。

  • 启动时间慢。多年来,虽然他们一直努力加快 Gradle 的速度,但在运行 Gradle 时,启动时间还是很恼人。相比之下,make 更快。即使我用 Python 创建的所有构建工具,也常常能在 Gradle 的启动时间内运行完成。

  • 要想熟悉 Gradle 的功能并不容易,所以我们经常搞不清有哪些可能或已经存在的功能去解决问题。在发现已经有解决方案(或根本没有)之前,很容易陷入困境。

我现在明白了


我终于开始理解我现有的脚本了,这也是我不考虑将 Gradle 脚本切换到 Kotlin 的原因之一。但我现在有了全局的认知,很明显我可以切换,而且我也有意愿去做。特别是,IntellijIDEA 对 Groovy 不支持推断类型,而这是 IDE 查找对象的可用属性和方法所必需的。仅凭这一点就值得我切换到 Kotlin。我觉得这肯定会对使用 Gradle 进行构建的 Kotlin 的程序员产生吸引力。


如果你一直使用 Gradle 不得其法,我希望这篇文章能够为你提供一些见解。


James Ward 和我在快乐之路编程播客(Happy Path Programming Podcast)中对此文有更详细的讲解,感兴趣的朋友可以去收听。

__________

【关于译者】

王前明,拥有近十年的软件开发经验,先后在恒生电子、德比软件等公司担任高级开发,架构师、技术经理。熟悉 Java、Golang 等语言体系、微服务体系。对企业架构设计与推动落地有较多经验,曾带领团队完成过多个重大项目及架构改造。

平时喜欢写作、分享感兴趣的技术点,翻译一些原版技术书籍、文章,希望以此提高自己的同时让更多的国内技术人受益。



用户头像

图灵社区

关注

好书,让编码更高效 2019.02.22 加入

IT出版旗舰品牌

评论

发布
暂无评论
Bruce Eckel教你如何爬出 Gradle 的“坑”?