拥抱 Gradle: 下一代自动化工具

Yingxin Wu · 2013-05-01

认识 Gradle

过去 Java 世界的人谈起构建和自动化, AntMaven 一定是必备词汇吧,而如今,”Gradle“这个名字也渐渐吸引了更多的目光。今天我们就来认识一下号称“下一代自动化工具”的 Gradle。

不过在此之前,我们先来温习一下既熟悉又陌生的 Ant 。

假设这里有一个使用 Ant 构建的 Java 工程,现在我们决定给 Snapshot 版本增加更多的 debug 信息,以下是一种可能的实现方式:

<?xml version="1.0" encoding="UTF-8"?>
<project name="demo" default="compile">
  <property name="version" value="1.0-SNAPSHOT"/>

  <target name="compile">
    <condition property="debug" else="off">
        <contains string="${version}" substring="SNAPSHOT"/>
    </condition>
    <javac debug="${debug}"
      ...
    />
  </target>
</project>

这段脚本当然能够工作,但必须承认,我无法在5秒钟之内看清楚它究竟表达了什么,其含义已被没完没了的标签所淹没。哦,你觉得挺好的,习惯了就好?那,如果这样的脚本有 500 行,甚至更长呢?

嗯,我想我们需要一个更好的解决方案,也许会是这样的:

apply plugin: 'java'

version = '1.0-SNAPSHOT'

compileJava {
  options.debug = version.endsWith('SNAPSHOT')
}

这就是我们的第一段 Gradle 代码,如你所见,它要简短得多,而且你一定也找到了“熟面孔”:属性访问、赋值和方法调用。请注意我用了“代码”一词,没错, Gradle 脚本实际上就是一段 Groovy 代码。

对比这两段脚本,就能够对“我们为什么需要下一代构建工具”这个问题作出初步的回答:

  • 程序员更容易读懂编程语言编写的代码,而不是 XML,后者擅长作为一种数据交换格式,是给机器读的(如 Web Service )
  • 编程语言的强大能力应为构建过程所用,如上面代码中的 endsWith 方法,而无需舍近求远地使用 <contains>
  • 更重要的是, Java 程序员无需翻阅文档就能使用 ifelseendsWith ,而 <condition><contains> 则不见得
  • 额外地,动态语言以及 DSL 能够使得程序员更专注在构建逻辑上,脚本也更为简洁、易读,而在这方面 XML 真是望尘莫及

Gradle 是什么

在对 Gradle 有了一点感性认识以后,现在我们可以来讨论: Gradle 究竟是什么?

讨论这个问题,我们还是得从前 Gradle 时代说起: Ant 是一个非常通用的构建工具,对你做什么和怎么做几乎没有任何约束,但通用的代价是牺牲了特定领域的便捷性,于是 Ivy 为我们提供了统一管理外部依赖的机制, Maven 则在同一个工具内提供了构建和依赖管理的能力,同时引入了惯例优先的理念,从而使得 Java 工程的构建变得便捷,也更规范。

这些先辈都很棒,但也有着共同的弱点:它们都使用 XML 作为描述格式;它们的插件开发不是很便捷( XML 与 Java 之间是存在鸿沟的)。

作为后起之秀,在吸收前辈们的精华并设法克服它们的缺点之后,就有了 Gradle:

  • 同时提供了构建和依赖管理的能力
  • 完全支持 Maven 、 Ivy 的资源库( Repository ),你只是换了一种使用方式,而不需要重建它们
  • 但同时提供了更多的可选项,你甚至可以使用一个普通的文件夹作为资源库,而仍可拥有依赖传递的特性( Transitive Dependency )
  • 提供惯例优先模式,而惯例默认值的覆盖变得更为容易
  • 使用 Groovy 作为构建语言(实际上是基于 Groovy 的 DSL ),大大提升了自动化过程的可编程能力和脚本的可读性,插件的编写、构建逻辑的复用也变得更为容易

Gradle 实战

现在,我们就通过具体的例子来进一步认识 Gradle 。虽然被设计为通用的自动化工具,但无疑 Java (或基于 JVM 的语言)仍是 Gradle 的核心领域,我们就从这里开始。

Java 工程构建

第一个例子

继续文章开头的例子,现在我们的 Java 工程构建已从 Ant 切换到了 Gradle ,可我们还没剖析过那段很酷的代码,它究竟是什么意思?

apply plugin: 'java' // 引用 Java 插件

version = '1.0-SNAPSHOT' // 定义项目的版本号

// 覆盖compileJava 任务(在 Java 插件中定义)默认的编译选项
compileJava {
  options.debug = version.endsWith('SNAPSHOT')
}

声明需要引用的插件,并覆盖惯例设置,就是这样!假设工程的目录结构符合惯例(Standard Directory Layout)、不依赖第三方组件,那么它就已经可以正确地工作。

从这个简单的例子,我们可以了解一些 Gradle 的理念:

  1. 在专注各个领域的插件(如 Java )中封装构建所需的大部分(如果不是全部)工作,并提供合理的默认值,即惯例(如工程目录结构),保持开箱即用
  2. 使用者的主要任务是根据实际情况覆盖各种默认设置
  • 我们对 Gradle 的使用以及扩展,都应该符合这些理念,以便更好的享受 Gradle 带来的进步

依赖管理

开发工作继续深入,为了避免重新发明轮子,我们决定使用一些优秀的第三方组件。对外部依赖,遵循“一切皆为代码”的理念,声明第三方组件的标识符和准确的版本,而不是把 200MB 莫名其妙的 jar 文件一股脑地扔到 SCM 。

build.gradle 中的任意位置添加:

// 声明需要使用的资源库
repositories {
  mavenCentral()
}

// 依赖声明
dependencies {
  compile 'org.slf4j:slf4j-api:1.6.6'

  runtime (
    'ch.qos.logback:logback-classic:1.0.7',
    'org.slf4j:jcl-over-slf4j:1.6.6',
  )

  testCompile 'junit:junit:4.10'
}

compile, runtimetestCompile 被称为 Configuration ,就是一组依赖的集合,它们之间存在着关联,比如 compile 中的依赖必然也会被包括在 testCompileruntime 中。

在不同的环境下工作,就会使用不同的 Configuration ,从而检查出代码中可能存在的错误引用或者冗余引用,以及避免把测试组件发布到生产环境。

  • 请仔细管理 Configuration ,这能让组件的构建和发布保持整洁,如果有必要你甚至可以定义自己的 Configuration

与 Maven 一样, Gradle 默认会自动解析、下载间接的依赖(即 Transitive Dependency Management )。大多数情况下这个特性很方便,但总有例外的时候,随着外部依赖的增多,间接依赖的组件就容易出现冲突。这个时候,一个 Gradle 命令可以帮助我们, gradle dependencies 。不需要引入额外的插件,借助它就可以打印出工程的依赖树。如果发现存在冲突的组件,可以关闭依赖传递或使用 exclude 排除特定的间接依赖,再显式地声明。例如:

// 排除全部或特定的间接依赖
runtime ('commons-dbcp:commons-dbcp:1.4') {
  transitive = false
  // 或 exclude group: xxx, module: xxx
}

// 然后显式声明
runtime 'commons-pool:commons-pool:1.6'
  • 小心管理工程的依赖,必要时需排除冲突的间接依赖
  • 题外话, gradle dependencies 命令容易敲错?可以使用缩写 gradle dep 。不必输入 Task 的全名,敲入字母(或驼峰缩写)直至能够唯一识别即可

属性和环境变量

随着构建逻辑的增多,我们发现每次发布,都要在多处同时修改版本号,这很容出现疏漏。于是,我们把构建过程的一些全局性或者需反复引用的值抽取出来,统一在属性文件中定义,使构建脚本更整洁、更易维护性。

建议:

  • 将工程范围内的全局属性放到工程(主、子工程均可)目录下的 gradle.properties 文件中
  • 而与开发者个人相关且不便纳入版本控制的属性,放到 $HOME/.gradle 下的 gradle.properties 文件,比如签署app的密钥
  • 与环境相关的属性(测试环境标志、工程发布目录等),则可通过 -P 参数在执行命令时传入,如 gradle demo -Pdebug=line(建议在 CI 环境中自动执行)

多工程构建

项目很成功,需求源源不断,现在我们的工程已经膨胀了数倍,考虑到并行开发和代码重用的需要,我们决定将原来的单个大工程拆分成多个较小的工程(模块)。

假设项目拆分成了 core 和 web 两个子工程,目录结构如下:

demo                项目根目录
  |-- core          core子工程
  |    |-- build    子工程的构建目录
  |    \-- src      子工程的源代码目录
  |
  \-- web           web子工程
       |-- build    子工程的构建目录
       \-- src      子工程的源代码目录

在这样的多工程构建环境下,关键的问题包括:

  1. 工程(模块)间的依赖关系,这关系到编译的先后顺序
  2. 第三方依赖的统一管理,避免第三方组件的版本冲突(这是很让人头疼的问题)
  3. 构建脚本的整洁,构建逻辑的重用

带着这些问题,我们开始在 Gradle 中使用多工程构建。

首先需要声明子工程,在根目录下放置一个 settings.gradle 文件:

include "core", "web"

在根工程的 build.gradle 中定义公共的构建逻辑:

subprojects {
  apply plugin: 'java'

  repositories {
    mavenCentral()
  }

  // 所有的子项目都需要的依赖
  dependencies {
    compile 'org.slf4j:slf4j-api:1.6.6'
    ...
  }
}

subprojects 中定义的任何内容都将对所有子工程生效,包括属性、依赖,甚至 Task 。

  • 多项目环境下执行 Task 时,将会对所有适用的子工程进行调用,如: gradle compileJava 将编译所有的子工程
  • 单独执行某个工程的 Task ,需要指定工程前缀,如: gradle :core:compileJava

子工程如果没有特别的需要,可以没有 build.gradle 文件,我们的 core 模块就是如此。不过很显然 web 模块应该是一个 JEE Web 应用,而且需要引用 core 模块,因此,我们为它添加一份 build.gradle

apply plugin: 'war'
apply plugin: 'jetty'

dependencies {
  compile project(':core')
  providedCompile 'javax.servlet:javax.servlet-api:3.1'
}
  • providedCompile (在war插件中定义)可以确保 servlet-api 能够在编译时被引用,却不随 web 工程发布(运行时由 Web 容器提供)

现在执行 gradle :web:compileJava , Gradle 将会确保 core 工程首先被编译并打包;执行 gradle :web:assemble 得到的 war 包也将包含 core.jar。

看来我们关注的多工程构建问题已经有了答案:

  • 我们只需要声明子工程间的依赖关系, Gradle 将自动管理构建顺序,而这样的声明与第三方依赖的声明方式是一致的
  • 公共的依赖统一声明,避免各自为政带来的混乱
  • subprojects allprojects 中定义公共的属性、逻辑和依赖,子工程只需进行增量定义或覆盖默认值即可

Gradle 也采用多工程管理自身的源代码,因此一定十分深刻地了解多工程构建的种种需求,从而进行更好的支持。 Gradle 也确实将多工程构建视为其亮点之一。


发布组件

随着企业规模的扩大,我们开始需要在团队之间共享组件,这时企业内部的 Maven 资源库镜像就派上了用场(在我看来,资源库无疑是 Maven 最成功之处)。

我们的 Core 组件是如此酷,以至于其他团队天天嚷着要引入,好吧,现在让我们看看如何将组件发布到 Maven 资源库。

首先定义发布的目标资源库,现在 core 工程也需要 build.gradle 了:

apply plugin: 'maven'

uploadArchives {
  repositories {
    mavenDeployer {
      repository(url: <repo_url>) {
        authentication(
          userName: <repo_user>,
          password: <repo_passwd>)
      }
    }
  }
}

这是给 uploadArchives 任务添加了一个目标资源库,其中 <repo_url> <repo_user> <repo_passwd> 分别为目标资源库的位置和认证信息(还记得吗?身份认证信息最好存放在 $HOME/.gradle/gradle.properties 中)

既然使用 Maven 资源库,最好还是按 Maven 的惯例,补充完整组件描述符( POM ):

apply plugin: 'maven'

uploadArchives {
  repositories {
    mavenDeployer {
      repository(url: <repo_url>) {
        ...
      }
      pom.project {
        name 'core'
        description '<project_desc>'
        ...
      }
    }
  }
}

看看最后这一串大括号,是否觉得嵌套层次有点深?让我们稍稍整理一下:

apply plugin: 'maven'

ext.pomCfg = {
  name 'core'
  description '<project_desc>'
  ...
}

uploadArchives.repositories.mavenDeployer {
  repository(url: '<repo_url>') {
    ...
  }
  pom.project pomCfg
}

好了,这样看着就舒服多了。而且,现在我们的 Core 组件已经发布到了内部的 Maven 资源库,可以供其他团队引用了。


定义企业内部的构建规范

一个企业或团队,在运作过程中或多或少都会积累下来一些约定(或称为规范、最佳实践),或许我们会有一个独特的工程目录结构,或者经过调优的编译选项,等等。企业如果希望提升构建的自动化程度,就应该考虑把这些规范固化下来,并在多个团队中共享。

由于 Gradle 构建是基于惯例的,重定义惯例也更为容易,这就使得它天然地成为企业构建规范定义和发布的最佳工具。在 Gradle 中,构建规范可以通过插件的形式定义和发布。

而 Gradle 编写插件的方式有很多种:

  • 共享脚本 - 即把可重用的gradle片段摘取到一个独立的文件,然后通过 apply from 的方式引用,这是最廉价的一种方式
  • buildSrc - 执行时, Gradle 会自动引用 buildSrc 目录下的插件代码,它可以是构建脚本的片段,也可以是与独立插件一样的 Groovy 、 Java 代码
  • 独立插件 - 扩展 Gradle 功能的插件,使用 Groovy 、 Java 编写,通过 jar 包发布。

由于我们主要考虑共享构建规范,第一种方式是比较好的选择。

我们把约定的工程目录、编译选项等单独定义至 company.gradle

// 编译器选项
tasks.withType(Compile) {
  options.encoding = 'utf-8'
}

// 源代码目录结构
sourceSets.main {
  java.srcDirs 'src/domain', 'src/controller', 'src/service'
}

// 企业内部资源库
repositories {
  mavenRepo url: <company_public_repo>
}
...

我们在内网站点共享了这个文件,供各个项目引用:

apply from: <link_to_company_gradle>

// 项目特定的构建逻辑
...

Gradle Wrapper

随着时间的推移, Gradle 版本也在不断升级,有些前卫的家伙总是把 Gradle 更新到最新版本,而菜鸟们的机器上却没有 Gradle 运行时,这就使得我们的工程构建可能在不同环境下出现不同的结果。尤其当我们需要在多台服务器上同时进行构建、发布的时候,这个问题就更为突出了。

这个问题早在 Ant 、 Maven 的时代就存在了,不过现在终于有了更好的解决方案:Gradle Wrapper。非常简单,实际上只需要这么一个 Task :

task wrap(type: Wrapper) {
  gradleVersion = '1.4' // 声明使用的 Gradle 版本
  scriptFile = 'g' // 默认 gradlew ,也太长了吧
}

只需一人(通常为工程的创建者)执行此 Task ,生成 gg.bat 脚本及相关文件,把这些内容和工程文件一起放入 SCM 。其他开发者取得后,立刻可以通过 gg.bat 脚本执行 Gradle 命令,不必预先安装 Gradle 运行环境,第一次运行时会自动下载、安装。

  • 遗憾的是,由于 Gradle 二进制包的大小以及服务器位置的问题,等待下载时要有耐心,建议在内网预先缓存该二进制包

这大概也可以看作”一切皆为代码“理念的进一步延续,即,声明需要使用的 Gradle 版本,使用时则会自动下载,确保在任何机器上都能够还原期望的构建环境。


小结

在本文中,我们通过模拟一个 Java 项目从无到有、由小到大的发展过程,逐步展现了 Gradle 为构建工作(或更广泛地:自动化工作)带来的变革,它所展现的“力与美”令人惊艳,尤其对于 Java 社区那些一本正经的 OOer 们,恐怕就只有O_o的份了(别介意,笔者本人也是其中一员)。

从中我们可以一窥构建工具的发展趋势:

  • 对构建语言的选用,真正的编程语言(及基于此的 DSL )取代 XML 。严格来讲 Ant、 Maven 的 Schema 也是一种 DSL ,但 XML 表达能力的缺陷最终使它们败下阵来
  • 构建语言与工程语言一致或相近,如 Ruby 的Rake, JVM 语言的 Gradle 、SBT,使得程序员更容易上手,同时他们的编程知识也得以在构建工作中一显身手
  • 基于惯例的构建,使我们不必一再重复一些“显而易见”的配置,同时更容易地规范企业内部的构建过程,从而进一步提升企业的效率
  • 一切皆代码,组件依赖、构建环境等都成为可以进入 SCM 的“代码”,从而无论在何处,都可以还原出期望的构建环境

Gradle 还刚刚起步,却已经吸引了 SpringSourceHibernateGrails等大名鼎鼎的客户。有理由相信, Gradle 在未来还会带给我们更大的惊喜。


本文已发表在《程序员》杂志 2013年第4期

资源

  1. 样例代码
  2. Gradle Docs
  3. Gradle Cookbook
  4. Groovy
  5. Stack Overflow 上的 Gradle

Twitter, Facebook