王前明 译
《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去做构建时,问题已经足够复杂到以致于需要你做更多的事情,而“做更多的事情”就会转变为“需要知道更多”,简单的事情之后就是悬崖。
本文的目标是给予读者一些视角,从而让读者明白在掉落悬崖时发生了什么,如何才能再爬上去。
构建系统的基本要素
几乎每个构建系统都有两个基本要素:
- 任务:这组成了构建的操作功能菜单。一次构建通常有多个任务且一般情况下会通过命令行调用需要的任务,比如gradle
build。在其他构建系统中有不同的名称,如:在make中称之为targets。 - 依赖:依赖说的是“这个不能在那个之前发生”。通常,这表示“在那个组件可用/更新之前,不能编译/执行当前这个组件”,但依赖可以指任何诸如此类的顺序:“先做这个,再做那个”。
依赖关系可以被分为“内部的”(依赖当前构建中的其他组件)或“外部的”(从本地或者远程的外部仓库更新的组件)。
构建工具通过读取脚本,该脚本通常位于标准命名的文件中,如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 :a
task a doFirst
task 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等语言体系、微服务体系。对企业架构设计与推动落地有较多经验,曾带领团队完成过多个重大项目及架构改造。
平时喜欢写作、分享感兴趣的技术点,翻译一些原版技术书籍、文章,希望以此提高自己的同时让更多的国内技术人受益。
标签:task,Bruce,make,Gradle,Eckel,vip,构建,println From: https://blog.51cto.com/u_15767091/6562223