Java-程序员的-Groovy-指南-全-
Java 程序员的 Groovy 指南(全)
原文:Making Java Groovy
译者:飞龙
第一部分. 熟悉 Groovy
欢迎来到第一部分:“熟悉 Groovy。”本节由四个章节组成,涵盖与任何特定应用程序无关的主题。在第一章中,我试图帮助你为 Groovy 制造商业和技术案例。第二章通过示例教程展示了如何使用 Groovy 解决一些小但有趣的问题。结合附录 B,它应该为你提供理解本书其余部分所需的 Groovy 背景。
第三章回顾了 Groovy 和 Java 如何紧密合作。它涵盖了从 Java 程序化运行 Groovy 脚本,以及其他两种语言可以混合的方式。将两种语言集成最简单的方法就是为每个语言创建类,实例化它们,并调用它们的方法。本章提供了实现这一点的示例。
本部分的最后一章回顾了在处理 Java 问题时特别有帮助的惯用 Groovy 特性。从 POGOs 到 AST 转换到 Groovy JDK,本章展示了 Groovy 可以简化 Java 开发的许多方式。
第一章. 为什么要在 Java 中添加 Groovy?
本章内容涵盖
-
Java 的问题
-
帮助 Java 的 Groovy 特性
-
Java 和 Groovy 的常见用例以及 Groovy 如何使它们更简单
尽管存在诸多缺陷(我们很快就会对其进行回顾),Java 仍然是当今行业中的主导面向对象编程语言。它无处不在,尤其是在服务器端,它被用于实现从网络应用程序到消息系统再到服务器基本基础设施的一切。因此,并不令人惊讶的是,Java 开发者和 Java 开发工作比其他任何编程语言都要多。作为一个语言,Java 是一个无与伦比的成功故事。
如果 Java 如此普遍且如此有用,为什么还要切换到其他任何东西?为什么不在任何提供 Java 虚拟机 (JVM) 的地方继续使用 Java?
在本书中,对那个问题的回答是,尽管如此。在 Java 对你有所帮助并完成任务的地方,无论如何都要继续使用它。我预计你已经具备 Java 背景,并且不想失去所有辛苦赚来的经验。然而,有些问题是 Java 可以轻松解决的,而有些问题是 Java 使之变得困难的。对于这些困难问题,考虑一个替代方案。
那个替代方案是 Groovy。在本章中,我将回顾一些导致开发者遇到问题的 Java 问题,并讨论 Groovy 如何帮助缓解这些问题。我还会展示一系列作为 Groovy 生态系统一部分的工具,这些工具可以使纯 Java 开发更加容易。从长远来看,我建议采用混合方法:让 Java 做它擅长的事情,让 Groovy 在 Java 难以处理的地方提供帮助。
在整个过程中,这将是一个口号:
指导原则
Java 适用于工具、库和基础设施。Groovy 适用于其他一切。
在 Java 表现良好的地方使用 Java,在它使你的生活更轻松的地方使用 Groovy。没有人会重写,比如说,Spring 框架,用 Groovy。没有必要。Groovy 与 Spring 配合得很好,我将在第七章详细讨论。同样,JVM 无处不在。这是一个好事,因为 Java 可以运行的地方,Groovy 也可以运行,如图 1.1 所示。
图 1.1. Groovy 为 Java 虚拟机生成字节码。可以提前编译它们,或者让groovy命令从源代码生成它们。

我将在下一章讨论实际细节,但就其基础而言,Groovy 确实是 Java。Groovy 脚本和类编译成字节码,这些字节码可以自由地与编译后的 Java 类混合使用。从运行时角度来看,运行编译后的 Groovy 只需将一个 JAR 文件添加到你的环境中即可。
这本书的一个目标是在 Groovy 可以显著帮助 Java 开发者的机会中识别出来。为了做到这一点,让我首先回顾一下 Java 可能存在哪些需要帮助的问题。
1.1. Java 的问题
在 1990 年代中后期,一场完美的风暴席卷了开发世界,最终导致将主要开发语言从 C++迁移到 Java。Java 实际上是 C++家族中的下一代语言。它的语法与 C 和 C++有很多相似之处。语言结构,如内存管理和指针算术,这些结构给中级开发者带来了问题,现在被自动处理或完全从程序员控制中移除。语言本身很小(尽管现在想象起来可能很难),易于编写,最重要的是,它是免费的。只需下载一个 JDK,访问库文档(在当时,提供干净、最新、超链接的库文档是一项相当大的创新),然后开始编码。当时的领先浏览器 Netscape 甚至内置了 JVM。结合“一次编写,到处运行”的口号,Java 取得了胜利。
从那时起已经过去了很长时间。Java 已经发展壮大,早期开发中做出的决策现在反而使开发变得复杂,而不是简化。那些决策是什么?以下是一个简短但并不详尽的列表:
-
Java 是静态类型的。
-
Java 中所有方法都必须包含在类中。
-
Java 禁止操作符重载。
-
属性和方法的默认访问权限是“包私有”。
-
Java 对待原始类型和类的方式不同。
随着时间的推移,Java 也积累了一些不一致性。例如,数组有一个length属性,字符串有一个length方法,集合有一个size方法,而节点列表(在 XML 中)有一个getLength方法。Groovy 为它们都提供了一个size方法。
Java 还缺乏元编程能力.^([1]) 这不是缺陷,但它限制了 Java 创建领域特定语言(DSLs)的能力。
¹ 这有多种很好的理由,其中许多与性能相关。元编程依赖于动态能力,如反射,当 Java 最初发布时,反射非常慢。1998 年的 Groovy 在 Java 1.2 上最多是一个令人畏惧的前景。
还有一些其他问题,但这个列表将为我们提供一个良好的起点。让我们单独看看这些项目中的几个。
1.1.1. 静态类型是缺陷还是特性?
当 Java 被创造出来时,行业中的思考是静态类型——你必须声明每个变量的类型——是一种优势。静态类型和动态绑定的结合意味着开发者有足够的结构来让编译器立即捕捉到问题,但仍然有足够的自由来实现和使用多态。多态允许开发者覆盖超类中的方法并在子类中改变其行为,使通过继承的重用变得可行。更好的是,Java 默认是动态绑定的,所以你可以覆盖任何你想要的东西,除非方法上应用了关键字final。
静态类型也使得集成开发环境(IDE)变得有用,因为它们可以使用类型来提示开发者使用正确的字段和方法。像 Eclipse 和 NetBeans 这样的 IDE,既强大又免费,在行业中变得普遍,部分原因就是这种便利性。
那么,静态类型有什么问题呢?如果你想听到一些不满,可以问任何 Smalltalk 开发者。更实际地说,在 Java 的动态绑定限制(除非两个类通过继承相关联,否则你不能覆盖任何东西)下,静态类型过于限制。动态类型语言有更多的自由,让一个对象代表另一个对象。
以一个简单的例子来说明,考虑数组和字符串。两者都是收集信息的数据结构:数组收集对象,字符串收集字符。两者都有向现有结构中添加新元素的概念。假设我们有一个包含数组的类,我们想要测试这个类的功能。我们并不关心测试数组的操作。我们知道它们是有效的。但我们的类依赖于这个数组。
我们需要某种类型的模拟对象来代表数组进行测试。如果我们有一个具有动态类型的语言,并且我们只是在它上面调用append方法并使用字符参数,我们可以在有数组的地方提供一个字符串,一切仍然会正常工作。
在 Java 中,一个对象只能代表另一个对象,如果这两个类通过继承相关联,或者如果它们都实现了相同的接口。静态引用只能分配给该类型或其子类的对象,或者如果引用是接口类型,则可以分配给实现该接口的类。然而,在动态类型语言中,只要它们实现了我们需要的方法,任何类都可以代表另一个类。在动态世界中,这被称为鸭子类型:如果它像鸭子走路,如果它像鸭子嘎嘎叫,那么它就是一只鸭子。参见图 1.2。
图 1.2. 从鸭子类型的角度看数组和字符串。每个都是具有append方法的集合。如果我们只关心这一点,它们就是相同的。

只要字符串有我们需要的append方法,我们就不会介意它不是一个数组。这个例子还展示了 Groovy 的一个特性,这个特性在 Java 中没有被包含:操作符重载。在 Groovy 中,所有操作符都由可以重写的方法表示。例如,+操作符使用plus()方法,*使用multiply()。在之前的图中,<<操作符代表leftShift()方法,它对于数组和字符串都实现为append。
Groovy 特性
Groovy 的特性,如可选类型和操作符重载,为开发者提供了在更少的代码中更大的灵活性。
关于可选类型,Groovy 为你提供了两者的最佳选择。如果你知道变量的类型,请随意指定它。如果你不知道或者不在乎,请随意使用def关键字。
1.1.2. 方法必须在类中,即使你不需要或想要一个
之前,史蒂夫·耶格(Steve Yegge)写了一篇非常有影响力的博客文章,名为“在名词王国中的执行”。^([2]) 在这篇文章中,他描述了一个名词统治、动词是二等公民的世界。这是一篇有趣的博客文章,我推荐阅读。
² 阅读史蒂夫·耶格(Steve Yegge)博客上 2006 年 3 月 30 日的文章:
mng.bz/E4MB。
Java 在这个世界中根深蒂固。在 Java 中,所有方法(动词)都必须位于类(名词)内部。你不能单独拥有一个方法。它必须位于某个类的内部。大多数时候这并不是一个大问题,但考虑一下,例如,对字符串进行排序。
与 Groovy 不同,Java 没有对集合的原生支持。尽管集合从 Java 一开始就存在,以数组以及原始的java.util.Vector和java.util.Hashtable类的形式存在,但正式的集合框架是在 Java 2 标准版,版本 1.2 中添加的。除了为 Java 提供了一组小但有用的基本数据结构,如列表、集合和映射之外,该框架还引入了迭代器,它将你遍历集合的方式与其底层实现分离开来。最后,该框架引入了一套多态算法,这些算法可以在集合上工作。
在所有这些准备就绪之后,我们可以按照以下列表所示组装一个字符串集合并对它们进行排序。首先需要实例化一个字符串集合,然后填充数据,最后进行排序。
列表 1.1. 使用Collections.sort方法排序字符串

集合框架提供了接口,如List,以及实现类,如ArrayList。add方法用于填充列表。然后java.util包中的.Collections实用类包括静态方法,用于排序和搜索列表。在这里,我使用单参数sort方法,该方法根据其自然排序对参数进行排序。假设列表的元素来自实现java.util.Comparable接口的类。该接口包括compareTo方法,如果其参数大于当前对象,则返回负数;如果参数小于当前对象,则返回正数;否则返回零。String类实现了Comparable作为字典序排序,即字母顺序,但将大写字母排在小写字母之前。
我们稍后将查看与此对应的 Groovy 代码,但首先让我们考虑另一个问题。如果你想要按长度而不是按字母顺序对字符串进行排序怎么办?String类是一个库类,所以我不能编辑它来更改compareTo方法的实现。它也被标记为final,所以我不能简单地扩展它并覆盖compareTo实现。然而,对于这种情况,Collections.sort方法被重载,可以接受一个类型为java.util.Comparator的第二个参数。
下一个列表显示了我们对字符串列表的第二次排序,这次使用的是作为匿名内部类实现的比较器。与上一个示例中的main方法不同,这里有一个StringSorter类,它可以使用默认排序或按长度对字符串进行排序。
列表 1.2. 一个用于排序字符串的 Java 类

我们在这里看到了名词战胜动词的胜利。Comparator接口有一个compare方法,我们只想为Collections.sort提供我们自己的方法实现。然而,我们无法在不将其包含在一个类中的情况下实现一个方法。在这种情况下,我们通过一个称为匿名内部类的尴尬的 Java 结构提供自己的实现(按长度降序排序)。为此,我们输入单词new,然后是我们要实现的接口的名称(在这种情况下,Comparator),打开一个花括号,并在sort方法的第二个参数中放入我们的实现。这是一个丑陋、尴尬的语法,它唯一的优点是,你最终会习惯它。
下面是 Groovy 脚本形式的等价代码:
def strings = ['this','is','a','list','of','strings']
Collections.sort(strings, {s1,s2 -> s2.size() - s1.size()} as Comparator)
assert strings*.size() == [7, 4, 4, 2, 2, 1]
首先,我通过简单地定义和填充列表,就像它是一个数组一样,利用 Groovy 对集合的原生支持。strings变量实际上是对java.util.ArrayList实例的引用。
接下来,我使用Collections.sort的两个参数版本对字符串进行排序。有趣的部分是,sort方法的第二个参数是一个闭包(在括号内),然后使用as运算符将其“强制”实现Comparable。^([3])
³ 如此的闭包强制转换将在第四章中进一步讨论。
闭包旨在实现compare(String, String)方法,类似于之前 Java 列表中展示的那样。在这里,我在箭头左侧展示了两个虚拟参数s1和s2,然后在右侧使用它们。我将闭包作为Comparator接口的实现。如果接口有几个方法,并且我想为每个方法提供不同的实现,我将提供一个以方法名称为键、相应闭包为值的映射。
最后,我使用所谓的扩展点运算符来调用排序集合中每个元素的size方法,它返回一个结果列表。在这种情况下,我要求获取集合中每个字符串的长度,并将结果与预期值进行比较。
顺便说一下,Groovy 脚本也不需要任何导入。Java 自动导入java.lang包。Groovy 还自动引入了java.util、java.net、java.io、groovy.lang、groovy.util、java.math.BigInteger和java.math.BigDecimal。这是一件小事,但很方便。
Groovy 特性
集合的原生语法和额外的自动导入减少了所需的代码量和其复杂性。
如果你之前使用过 Groovy,你可能知道实际上有一个更简单的方法来进行排序。我根本不需要使用Collections类。相反,Groovy 已经将sort方法添加到了java.util.Collection本身。默认版本执行自然排序,一个参数版本的sort方法接受一个闭包来进行排序。换句话说,整个排序可以简化为单行:
strings.sort { -it?.size() }
闭包告诉sort方法使用每个元素的size()方法的结果来进行排序,负号表示这里我要求降序排序。
Groovy 特性
Groovy 对 JDK 的扩展简化了其使用,Groovy 闭包消除了匿名内部类等人工包装。
在本节中有两个主要的生产力提升。首先,Groovy 添加到 Java 库中的所有方法,称为 Groovy JDK。我会在接下来的章节中经常提到这些方法。其次,我利用 Groovy 将方法本身作为对象处理的能力,称为闭包。我将在接下来的章节中有很多关于闭包的讨论,但最后一个例子展示了它们的一个优点:你几乎永远不需要匿名内部类。
顺便说一下,在闭包中,我使用了额外的 Groovy 特性来保护自己。it这个词后面的问号是安全的解引用操作符。如果引用为 null,它将在这里调用size方法。如果不为 null,它将返回null并避免NullPointerException。这个小小的语法比我想象的更能吸引更多的 Java 开发者转向 Groovy。^([4])
⁴ 有时候他们会眼含泪水。“真的吗?”他们说。“我不用做所有那些 null 检查?”他们如此高兴,真是感人。
1.1.3. Java 过于冗长
下面的列表显示了一个简单的 POJO。在这种情况下,我有一个名为Task的类,可能是项目管理系统的一部分。它具有表示任务名称、优先级和开始和结束日期的属性。
列表 1.3. 表示任务的 Java 类

我们有私有字段和公共的获取器和设置器方法,以及我们需要的任何构造函数。我们还添加了一个典型的重写toString方法。我可能还可以使用重写equals和hashCode,但为了简单起见,我省略了这些。
大部分代码可以由 IDE 生成,但这仍然是一个很长的列表,我还没有添加必要的equals和hashCode重写。这实际上是一个愚蠢的数据结构,却需要这么多代码。
这里显示了类似的普通旧格罗 ovy 对象(POGO):
@EqualsAndHashCode
class Task {
String name
int priority
Date startDate
Date endDate
String toString() { "($name,$priority,$startDate,$endDate)" }
}
严肃地说,这就是整个类,它确实包括重写equals和hashCode方法。Groovy 类默认是公开的,Groovy 方法也是如此。属性默认是私有的。属性的访问是通过动态生成的获取器和设置器方法完成的,所以尽管看起来我们是在处理单个字段,但实际上我们是通过获取器和设置器方法来处理的。此外,Groovy 自动提供了一个基于映射的构造函数,消除了需要大量重载构造函数的需求。@EqualsAndHashCode注解代表一个抽象语法树(AST)转换,它生成相关的方法。最后,我使用 Groovy 字符串及其参数替换功能将任务转换为字符串。
格罗 ovy 特性
Groovy 的动态生成能力大大减少了类中所需的代码量,让你能够关注本质而不是仪式。
Java 还包括检查型异常,这最多算是一种双刃剑。其哲学是在开发周期的早期就捕捉(无意中用了双关语)问题,这也被认为是静态类型的一个优势。
1.1.4. Groovy 使 Java 测试变得容易
就算一个类能编译,并不意味着它实现了正确。就算你为各种异常做了准备,并不意味着代码能正常工作。你仍然需要测试它,否则你真的不知道。5
5 我最喜欢的例子来自一个朋友,他曾经在上世纪 C++ 语言还非常新潮的时候教 C++。他看了一个学生的代码,一团糟。然后他注意到第一行是
/*,最后一行是*/。他说:“你注释掉了整个程序。”学生耸了耸肩说:“这是我能让它编译的唯一方法!”
过去十年左右最重要的生产力提升之一是自动化测试工具的兴起。Java 有 JUnit 和其衍生工具,它们使编写和运行测试自动化且简单。
测试是 Groovy 发挥作用的另一个领域。首先,基本的 Groovy 库包括 GroovyTestCase,它扩展了 JUnit 的 TestCase 类并添加了一系列有用的方法,例如 testArrayEquals、testToString,甚至 shouldFail。接下来,Groovy 的元编程能力催生了用于测试的简单 DSL。
一个特别好的例子是 Spock 框架,我将在 第六章 中讨论测试。Spock 精简且表达力强,有 given、expect 和 when/then 这样的块。
以排序字符串为例,这是在 Java 中实现的,前面已经讨论过。在 列表 1.3 中,我展示了一个 Java 类,它可以按字典顺序和递减长度对字符串进行排序。现在我想测试这一点,为此我将使用 Groovy 的 Spock 测试框架。
下面的列表展示了检查两种排序方法的 Spock 测试。
列表 1.4. 检查每个 Java 排序方法的 Spock 测试

在 Spock 测试中,被测试的 Java 类作为属性实例化。我使用 Groovy 的原生集合来填充数据,尽管被测试的类是用 Java 编写的,并且方法接受 Java 列表作为参数。6 我有两个测试,在每种情况下,即使不了解 Spock,也应该清楚测试在做什么。我正在利用 Groovy 的可选括号和扩展点操作符,它适用于列表并返回具有指定属性的唯一列表。
在 第六章 中讨论了将 Groovy 测试应用于 Java 代码。
测试通过了,我可以用相同的测试与 Groovy 实现一起使用。然而,重要的是,我可以在没有任何问题的前提下,向 Java 系统中添加一个 Groovy 测试。
1.1.5. Groovy 工具简化了你的构建
Groovy 帮助 Java 的另一个领域是在构建过程中。我将在第五章中详细讨论 Groovy 的构建机制,但在这里我将只提到它们如何帮助 Java 的几种方式。如果你习惯于使用 Apache Ant 构建系统,Groovy 为 Ant 添加了执行和编译任务。另一个选项是使用AntBuilder,它允许你使用 Groovy 语法编写 Ant 任务。
这实际上是 Groovy 的一个常见主题,我应该强调:
Groovy 特性
Groovy 增强并扩展了现有的 Java 工具,而不是取代它们。
如果你的公司已经从 Ant 迁移到 Maven,你正在使用一个工作在更高抽象层次并为你管理依赖项的工具。在第五章中提供了两种将 Groovy 添加到 Maven 构建的方法。然而,Groovy 生态系统提供了另一种选择。
在第五章中,我讨论了最新的 Groovy 杀手级应用 Gradle。Gradle 基于 Maven 仓库进行依赖项管理(尽管底层使用 Ivy),并以类似于 Ant 的方式定义构建任务,但它易于设置和运行。Maven 非常强大,但它与那些一开始就没有考虑到它的项目有很多麻烦。Maven 是一个非常具有意见的框架,并且通过插件进行定制。最终,在 Maven 中,构建文件是用 XML 编写的。Gradle 完全是关于定制的,并且因为构建文件是用 Groovy 编写的,所以你可以使用 Groovy 语言的全部功能。
虽然 Gradle 构建文件是用 Groovy 编写的,但这并不限制它只能用于 Groovy 项目。如果你的 Java 项目实际上是用 Maven 形式编写的,并且没有外部依赖项,那么这就是你的整个 Gradle 构建文件:
apply plugin:'java'
应用 Java 插件定义了一系列任务,从编译到测试再到 JAR。如果这一行代码在一个名为 build.gradle 的文件中,那么只需在命令行中输入gradle build,就会引发一系列活动。如果你(希望如此)要进行一些测试,你需要添加 JUnit 或 Spock 的依赖。生成的构建文件如下所示:

现在运行 gradle build 会导致一系列阶段:
:compileJava
:processResources
:classes
:jar
:assemble
:compileTestJava
:processTestResources
:testClasses
:test
:check
:build
结果是一个包含所有测试用例的漂亮、超链接的文档集,以及一个用于部署的 JAR 文件。
当然,如果有一个名为java的插件,那么还有一个名为groovy的插件。更好的是,Groovy 插件包括 Java 插件,并且像往常一样,增强并改进它。如果你的项目与本书中讨论的项目类似,即它结合了 Groovy 和 Java 类,并在最有帮助的地方使用它们,那么你只需要 Groovy 插件就可以开始了。还有许多其他插件可用,包括eclipse和web。我将在第五章中讨论它们,关于构建过程。
在本节中,我回顾了 Java 中内置的一些功能以及它们如何导致代码比必要的更冗长和复杂。我展示了 Groovy 如何简化实现,甚至增强现有的 Java 工具,使其更容易使用且更强大。我将在整本书中展示更多细节。首先,我想在下一节中列出 Groovy 为 Java 带来的额外功能。
1.2. Groovy 对 Java 的帮助
我实际上一直在讨论这些内容,但让我在这里提出一些具体观点。首先,Groovy 版本的 Java 类几乎总是更简单、更干净。Groovy 的语法远没有 Java 那么冗长,通常更容易阅读。
尽管这个说法是真实的,但它有点误导。我并不主张将所有的 Java 代码重写为 Groovy。恰恰相反;如果你的现有 Java 代码工作得很好,那太好了,尽管你可能想要考虑添加 Groovy 测试用例,如果你还没有的话。在这本书中,我更感兴趣的是帮助 Java 而不是取代它。
Groovy 为 Java 提供了什么?以下是在本书其余部分详细讨论的短列表主题:
1. Groovy 为现有的 Java 类添加了新功能。 Groovy 包括一个 Groovy JDK,它记录了 Groovy 添加到 Java 库中的方法。我使用字符串的
Collection接口中添加的sort方法是一个简单的例子。你还可以使用 Java 类与 Groovy 一起,并为 Java 添加如操作符重载等功能。这些以及相关主题将在第四章([kindle_split_014.html#ch04])中讨论。2. Groovy 使用 Java 库。 几乎每个 Groovy 类都依赖于 Java 库,无论是带有还是不带 Groovy 的扩展。这意味着几乎每个 Groovy 类都已经是一个集成故事,将 Groovy 和 Java 结合在一起。Groovy 的一个很好的用途是尝试使用你之前没有使用过的 Java 库。
3. Groovy 使处理 XML 和 JSON 变得容易。 这是在 Groovy 中表现优异的领域。Groovy 包括名为
MarkupBuilder的类,它使得生成 XML 变得容易,以及名为JsonBuilder的类,它生成 JSON 对象。它还包括名为XmlParser和XmlSlurper的类,它们将 XML 数据结构转换为内存中的 DOM 结构,以及JsonSlurper来解析 JSON 数据。这些将在整本书中使用,特别是在第九章(RESTful Web 服务)中。4. Groovy 包括简化的数据源操作。
groovy.sql.Sql类提供了一种非常简单的方式来处理关系数据库。我将在第八章(数据库)、第七章(使用 Spring 框架)和第九章(RESTful Web 服务)中详细讨论这一点。5. Groovy 的元编程简化了开发。 构建器类是 Groovy 元编程的一个例子。我将在几个章节中展示 DSL 的示例。
6. Groovy 测试适用于 Java 代码。 本章演示并广泛讨论的 Spock 测试工具,在第六章(kindle_split_017.html#ch06)关于测试的内容中,是测试 Java 系统的一个很好的方法。
7. Groovy 构建工具适用于 Java(和混合)项目。 在第五章(kindle_split_016.html#ch05)关于增强构建过程的内容中,我将讨论
AntBuilder,如何将 Groovy 添加到 Maven 构建中,以及 Gradle。8. 像 Grails 和 Griffon 这样的 Groovy 项目使开发 Web 和桌面应用程序变得更容易。 Grails 项目是一个基于 Spring 和 Hibernate 的完整栈、端到端框架,用于构建 Web 应用程序。Griffon 将相同的约定优于配置思想引入桌面开发。Grails 在第八章(kindle_split_020.html#ch08)关于数据库和第十章(kindle_split_022.html#ch10)关于 Web 应用程序中进行了讨论。
当查看 Java 开发者通常遇到的问题时,这个列表将成为简化实现、使其更容易阅读和理解、更快实施的想法的来源。
1.3. Java 用例以及 Groovy 如何帮助
我迄今为止讨论的示例都是代码级别的简化。它们非常有帮助,但我可以做得更多。Groovy 开发者处理的问题与 Java 开发者类似,因此已经创建了多个高级抽象,以使解决问题变得更简单。
在这本书中,我还将调查 Java 开发者日常面临的各种类型的问题,从访问和实现 Web 服务到使用对象关系映射工具以及改进构建过程。在每种情况下,我将探讨添加 Groovy 如何使您作为开发者的生活变得更轻松。
在我们继续前进的过程中,以下是一些我将讨论的领域,我将简要介绍 Groovy 如何帮助。这还将提供对即将到来的章节的轻量级概述。
1.3.1. Spring 框架对 Groovy 的支持
目前 Java 行业中最为成功的开源项目之一是 Spring 框架。它是项目的瑞士军刀;它在 Java 世界中无处不在,并为几乎每个目的都提供了工具。
没有人会建议用 Groovy 重写 Spring。它现在在 Java 中运行得很好。也没有必要将其“移植”到 Groovy。就 Spring 而言,编译后的 Groovy 类只是一组字节码。Groovy 可以使用 Spring,就像使用另一个库一样。
然而,Spring 的开发者对 Groovy 非常了解,并为其构建了特殊的功能以支持其使用。Spring 的 bean 文件可以包含内联脚本的 Groovy bean。Spring 还允许你部署 Groovy 源代码,而不是编译版本,作为所谓的可刷新bean。Spring 定期检查可刷新 bean 的源代码以查找更改,如果发现任何更改,则重新构建它们并使用更新版本。这是一个非常强大的功能,正如第七章中关于使用 Spring 的内容将展示的那样。
最后,Grails 项目的开发者还创建了一个名为BeanBuilder的类,用于在 Groovy 中脚本化 Spring bean。这就像 Gradle 增强 XML 构建文件一样,将 Groovy 的功能带到了 Spring bean 文件中。
1.3.2. 简化数据库访问
几乎所有 Java 开发者都与数据库打交道。Groovy 有一套特殊的类,可以轻松实现数据库集成,我将在第八章关于数据库的章节中对其进行回顾。我还展示了通过一个封装相应 Java API 的 Groovy 库与 MongoDB 数据库交互的示例。
我还将借鉴 Grails 世界的经验,讨论 GORM,即 Grails 对象关系映射工具,这是一个用于配置 Hibernate 的 DSL。实际上,GORM 已经被重构以支持各种持久化机制,包括 MongoDB、Neo4J、Redis 等 NoSQL 数据库。
1.3.3. 构建和访问 Web 服务
今天,另一个活跃的开发领域是 Web 服务。Java 开发者使用基于 SOAP 和 RESTful 的服务,前者涉及自动生成的代理,后者尽可能使用 HTTP。REST 在第九章中有介绍,基于 SOAP 的 Web 服务在附录 C 中讨论,该附录可免费下载。在这两种情况下,如果稍加注意,现有的 Java 工具与 Groovy 实现配合得很好。
1.3.4. Web 应用增强
Groovy 包含一个“groovlet”类,它类似于基于 Groovy 的 servlet。它接收 HTTP 请求并返回 HTTP 响应,并包括请求、响应、会话等预构建对象。Groovy 和 Java 集成最成功的实例之一,可以说是 Groovy 的杀手级应用,是 Grails 框架,它为 Web 应用带来了非凡的生产力。这两者都在第十章关于 Web 开发的章节中有介绍。
在这些用例中,Groovy 可以与现有的 Java 工具、库和基础设施协同工作。在某些情况下,Groovy 将简化所需的代码。在其他情况下,集成将更加深入,并将提供远超 Java 本身所包含的功能。在所有这些情况下,希望生产力的提升既明显又显著。
1.4. 摘要
Java 是一种庞大、强大的语言,但它已经显露出它的年龄。在其早期开发中做出的决策现在可能并不一定合适,随着时间的推移,它已经积累了问题和不一致性。尽管如此,Java 无处不在,它的工具、库和基础设施既有用又方便。
在本章中,我回顾了 Java 开发世界中的一些问题,从其冗长性到匿名内部类再到静态类型。大多数 Java 开发者已经习惯了这些“问题”,以至于他们把它们看作是特性而不是错误。然而,添加一点 Groovy,生产力的提升可以相当可观。我演示了仅仅使用 Groovy 原生集合和 Groovy 添加到标准 Java 库中的方法,可以将大量代码缩减到几行。我还列出了 Groovy 的功能,这将是一个简化 Java 开发的丰富思想来源。
尽管 Groovy(以及使用 Groovy 的乐趣)非常强大,但我仍然不建议用 Groovy 替换现有的 Java。在这本书中,我提倡一种混合方法。哲学是在适当的地方使用 Java,这通常意味着使用其工具和库,并将其部署到其基础设施上。我在 Java 中最有帮助的地方添加 Groovy。在下一章中,我将通过检查 Java 和 Groovy 在类级别的集成开始这段旅程。
第二章 通过示例学习 Groovy
本章涵盖
-
基本 Groovy 语法
-
集合和闭包
-
使用 Groovy JDK
如前一章所述,这本书并不是旨在成为 Groovy 的全面参考,但至少需要一定的 Groovy 熟练程度。虽然有些人通过简短、简单的代码示例来学习每个概念,但其他人更喜欢看到基本概念结合解决实际问题。对于那些喜欢每个功能都有代码片段的人来说,我提供了附录 B,一个按功能排列的 Groovy 教程。
在本章中,我将通过几个小型但非平凡的 Groovy 示例进行说明。希望这不仅能帮助传达语言的语法,还能传达一些标准的 Groovy 惯用法。一些示例将在本书的其他章节中再次使用,但在这里作为基本 Groovy 实践的说明。
2.1. 你好,Groovy
由于每本编程语言书籍都依法必须包含一个“Hello, World!”程序,所以这里是 Groovy 的版本:
println 'Hello, World!'
在 Java 中,您使用javac进行编译,并使用java执行生成的字节码。在 Groovy 中,您可以使用groovyc进行编译,并使用groovy执行,但实际上您并不需要先进行编译。groovy命令可以与源代码参数一起运行,它将首先编译然后执行。Groovy 是一种编译型语言,但您不需要分离步骤,尽管大多数人都会这样做。例如,当您使用 IDE 时,每次保存 Groovy 脚本或类,它都会被编译。
之前展示的单行就是一个完整的程序。与 Java 不同,你不需要将所有 Groovy 代码放入一个类中。Groovy 支持运行脚本。在底层,所有内容仍然是 Java 字节码,所以发生的情况是 Groovy 脚本最终成为扩展groovy.lang.Script类的main方法体。
注意 Groovy 和 Java 之间语法上的两个额外差异:
-
分号是可选的。 你可以添加它们,如果你一行中有多个语句,使用它们是合适的,但通常并不必要。
-
括号通常是可选的。
println命令实际上是一个方法调用,而String是传递给方法的参数。因为没有任何歧义,你可以省略括号。不过,如果你想包含它们,也没有错。
可选的括号
括号在需要之前是可选的。简单的函数调用通常省略括号,但如果存在任何不确定性,请添加它们。Groovy 的一切都关于简洁和可理解性。
现在“Hello, World!”示例已经完成,我可以继续进行一些更有趣的内容。Groovy 的一个有用用例是它能够很好地作为 RESTful 网络服务(如 Google Chart)的客户端。
2.2. 访问 Google 图表工具
谷歌提供的一个 API 是名为 Chart API 的 RESTful 网络服务,或者更正式地,称为 Google Chart Tools Image API.^([1]) 文档位于developers.google.com/chart/image/。这些图表工具为 JavaScript 用户提供了一个丰富的 API,但输入最终是带有查询参数的 URL。
¹ Google 于 2012 年 4 月 20 日正式弃用了 Google Chart Tools 中的图像图表部分。截至 2013 年夏季,该 API 仍然可用。这里既作为一个良好且自包含的示例,也作为一个简单应用,展示了 Groovy 的许多特性。书中还提供了其他访问公开服务的示例。
开发者向基本 URL chart.apis.google.com/chart 发送请求,并附加查询参数以指定图表类型、大小、数据以及任何标签。因为该 API 还需要一个“Hello, World”示例,以下是三维饼图的 URL:
https://chart.apis.google.com/chart?
cht=p3&
chs=250x100&
chd=t:60,40&
chl=Hello|World
这个 URL 本可以全部放在一行中,但在这里(以及文档中)将其展开是为了说明目的。在基本 URL 之后,参数列表中指定图表类型(cht)为 3D 饼图,图表大小(chs)为 250x100 像素,图表数据(chd)以简单的文本格式表示为 60 和 40,以及图表标签(chl)“Hello”和“World。”将此 URL 输入浏览器,返回的结果图像如图 2.1 所示。图 2.1。
图 2.1. Google 图表 API 的“Hello, World”示例

显示的 URL 是硬编码来生成图 2.1 中的图表。为了使其更通用,我将展示如何从字符串、列表、映射、闭包和构建器生成 URL。
目标
编写一个 Groovy 脚本以生成“Hello, World”3D 饼图作为桌面应用程序。
在这个过程中,我将讨论
-
字符串操作
-
列表和映射
-
使用闭包处理数据
-
Groovy 构建器类
在这个例子中,我将使用简单的脚本实现这些步骤;稍后,它可能被转换为类以进行集成。
2.2.1. 使用查询字符串组装 URL
首先,我需要一个变量来表示基本 URL。在 Groovy 脚本中,你实际上根本不需要声明任何类型。如果你声明了一个类型,变量就会成为脚本的局部变量。如果没有,它就成为了“绑定”的一部分,这在下一章中会讨论。在这里,因为我知道在转换之前 URL 将包含在一个字符串中,所以我将声明该变量为 java.lang.String 类型:
String base = 'http://chart.apis.google.com/chart?'
Groovy 是可选类型的。这意味着你可以根据需要指定类型,或者如果你不知道或不在乎,可以使用关键字 def。在开发人员中关于何时使用 def 和何时指定类型有一些争议。Dierk Koenig,优秀书籍《Groovy in Action》(Manning,2007)的主编,是这样说的:
使用 def
如果你想到一个类型,就输入它(来自 Dierk Koenig)。换句话说,如果你知道一个变量将是 String、Date 或 Employee,就使用那种类型的变量。
在我的个人经验中,我过去经常使用 def,但随着时间的推移,我使用它的次数越来越少。我同意 Dierk 的观点,并补充说,当我倾向于使用 def 时,我经常停下来片刻,并在使用它之前尝试想出一个实际类型。尽管其他开发人员有其他风格。这就是可选类型语言的美妙之处:每个人都有空间。
我现在需要将查询参数追加到这个 URL 上。而不是直接编写查询字符串,我将使用这种类型应用程序的典型惯用语,即构建一个映射,然后从映射参数生成查询字符串。考虑到这一点,以下是参数映射:
def params = [cht:'p3',chs:'250x100',
chd:'t:60,40',chl:'Hello|World']
在 Groovy 中,你使用方括号创建一个映射,每个条目由冒号分隔的键和值组成。默认情况下,键被认为是字符串。值可以是任何东西。默认情况下,params 变量是 java.util.LinkedHashMap 的实例。
集合
Groovy 有原生的列表和映射语法。映射键默认为字符串。
每个对应的值都由单引号包围。在 Groovy 中,单引号字符串是 java.lang.String 的实例。双引号字符串是“插值”字符串,不幸的是被称为 GStrings。我将在本程序稍后展示一个字符串插值的例子。
要将映射转换为查询字符串,我首先需要将映射中的每个条目转换为“key=value”形式的字符串,然后使用带符号的&作为分隔符将它们全部连接起来.^([2]) 第一步是通过使用添加到所有 Groovy 集合中的特殊方法完成的,称为collect。collect方法接受一个闭包作为参数,将闭包应用于集合的每个元素,并返回一个包含结果的新集合。
² 我还需要对映射条目进行 URL 编码,但在这个例子中它们已经很好了。在其他 RESTful Web 服务的例子中,我将演示编码过程。
闭包将在下一侧边栏中介绍,并在整本书中广泛讨论,但在此刻,请将它们视为代表函数主体的代码块,可能包含占位符参数。在collect的情况下,当应用于映射时,闭包可以接受一个或两个参数。如果闭包接受一个参数,则该参数表示Map.Entry;如果有两个参数,则第一个是每个条目的键,第二个是值。
要将映射转换为key=value对的列表,以下两个参数的闭包在collect方法中工作:
params.collect { k,v -> "$k=$v" }
在 Groovy 中,如果任何方法的最后一个参数是闭包,你可以将闭包放在括号外面。在这种情况下,collect的唯一参数是闭包,因此甚至省略了可选的括号。
什么是闭包
闭包是一段代码块,由花括号分隔,可以被视为一个对象。箭头符号用于指定占位符参数。在当前示例中应用于映射的闭包中,两个占位符参数是k和v,分别代表每个条目的键和值。箭头右侧的表达式表示用等号将每个键和值替换到GString中。这个collect方法将映射中的每个条目转换为将键分配给值的字符串,并生成一个结果列表。
操作的结果如下所示:
["cht=p3", "chs=250x100", "chd=t:60,40", "chl=Hello|World"]
此过程如图 2.2 所示。
图 2.2. 将collect应用于映射以将其转换为列表,其中每个条目都转换为字符串。

要创建查询字符串,请使用 Groovy 为集合添加的另一种方法,称为join。join方法接受一个参数,用作将元素组装成字符串时的分隔符。要创建查询字符串,请使用带符号的&作为参数调用join方法:
["cht=p3", "chs=250x100", "chd=t:60,40", "chl=Hello|World"].join('&')
结果是所需的查询字符串,如下所示:
"cht=p3&chs=250x100&chd=t:60,40&chl=Hello|World"
到目前为止,整个过程如下,从基本 URL 和参数映射开始,构建 Google Chart URL:
String base = 'http://chart.apis.google.com/chart?'
def params = [cht:'p3',chs:'250x100',
chd:'t:60,40',chl:'Hello|World']
String qs = params.collect { k,v -> "$k=$v" }.join('&')
所有这些操作的结果实际上是一个字符串,而不是 URL。在将其转换为 URL 之前,让我首先验证这个过程是否成功。通常这需要测试,正如在第六章测试中广泛讨论的那样。然而,在这里,我将使用 Groovy 的 assert 关键字,它接受一个布尔表达式作为参数。如果表达式为真,则不返回任何内容,如果不为真,则错误信息将打印到控制台。在这种情况下,我将使用 Map 接口的 contains 方法来检查 params 映射中的每个条目是否以正确的格式出现在查询字符串中:
params.each { k,v ->
assert qs.contains("$k=$v")
}
断言关键字
Groovy 断言是一种验证正确性的简单方法。如果表达式为真,则断言返回无内容,如果不为真,则打印详细的错误消息。
join 方法的优点之一是您不必担心在字符串的开始或结尾不小心添加一个 & 符号。它只在内部添加分隔符。
注意,这也是一个需要括号(在 join 方法上)的情况。在 Groovy 中,如果您在调用无参数的方法时省略括号,编译器会假设您正在请求相应的 getter 或 setter 方法。因为我想要 join() 方法(而不是不存在的 getJoin()),所以我需要括号。
2.2.2. 传输 URL
Groovy JDK 向 String 类添加了 toURL() 方法。正如您所想象的,此方法将 java.lang.String 实例转换为 java.net.URL 实例。
Groovy JDK
Groovy 向现有的 Java 库类添加了许多有用的方法。我多次发现添加到 String、Date 或 Collection 等类的方法,我一直希望这些方法在 Java 中就有。Groovy 添加的方法集合被称为 Groovy JDK,并有自己的 JavaDocs 集合。Groovy JDK 文档可通过 Groovy 主页上的链接获取。
Groovy JDK 的详细讨论可以在第三章中找到。
要向 URL 发送 HTTP GET 请求并检索结果,将字符串转换为 URL 并调用添加到 java.net.URL 的另一个 Groovy JDK 方法,即 getText() 方法。换句话说,网页上的数据可以通过以下代码检索:
url.toURL().text
在这里,我故意使用 URL 类的 text 属性,知道这将调用 getText() 方法。实际上调用 getText 没有问题,但这更符合 Groovy 的习惯。
通常这将是我想写的代码,我在关于 Web 服务的章节中的一些示例中使用了这种技术,但在这个特定的情况下,结果不是文本。Google 图表将这里生成的 URL 转换为二进制图像,因此将其转换为文本并不很有帮助。
Groovy 属性
在 Groovy 中访问属性会自动调用相关的 getter 或 setter 方法。
接下来,我将构建一个包含 javax.swing .ImageIcon 中的图像的 Swing 用户界面。这将给我一个机会来展示构建器,这是 Groovy 元编程的一个很好的示例。
2.2.3. 使用 SwingBuilder 创建 UI
在 Groovy 中,每个类都有一个元类。元类是另一个管理实际调用过程的类。如果你对一个不存在的方法调用类,调用最终会被元类中的 methodMissing 方法拦截。同样,访问一个不存在的属性最终会在元类中调用 propertyMissing。自定义 methodMissing 和 propertyMissing 的行为是 Groovy 运行时元编程的核心。
Groovy 元编程是一个庞大的主题,但在这里我将演示其一个有用的结果:构建器类的创建。在构建器中,对 methodMissing 的调用为该类型的构建器执行特定操作。
这里我将展示一个 Swing 构建器。这是一个拦截组件名称的类,并从结果中构建 Swing 用户界面。这实际上比解释更容易展示。但是,我首先将向迄今为止构建的 Google Chart 脚本中添加一些导入:^([3])
³ 这又是 Java 开发者在第一次学习 Groovy 时经常得到的“哦!我们为什么一直没这么做?”类型的启示之一。为什么 Java 程序中我们只导入
java.lang?为什么不导入许多典型的包?这不会让编码更容易吗?Groovy 说可以。
import java.awt.BorderLayout as BL
import javax.swing.WindowConstants as WC
import groovy.swing.SwingBuilder
import javax.swing.ImageIcon
自动导入
你可能已经注意到,我还没有需要任何导入语句。Java 自动导入 java.lang 包。Groovy 导入 java.lang,以及 java.util、java.io、java.net、groovy.lang、groovy.util、java.math.BigInteger 和 java.math.BigDecimal。³
在这个脚本中,我正在从 Java 标准库导入三个类。前两个导入使用 as 操作符为相应的类构建别名。这样,使用 BorderLayout 和 WindowConstants 的代码就可以直接写 BL 或 WC。我还添加了 ImageIcon 类,它将保存 Google Chart 返回的图像。从 Groovy 库导入的是 SwingBuilder,它将被用来构建 Swing UI。
as 关键字
as 关键字有几个用途,其中之一是为导入的类提供别名。as 关键字对应于添加到 Groovy JDK 中的 java.lang.Object 的 asType 方法。
在 SwingBuilder 的情况下,你调用构建器上不存在的方法,但这些方法会被转换成相应的 Swing API。例如,通过调用 frame 方法,你实际上是在实例化 JFrame 类。传递一个类似映射的参数 visible:true 相当于调用 setVisible 方法并传递一个 true 参数。
这是使用构建器的代码。不在SwingBuilder中的每个方法都被翻译为对 Swing 库类的适当方法调用:
SwingBuilder.edt {
frame(title:'Hello, World!', visible:true, pack: true,
defaultCloseOperation:WC.EXIT_ON_CLOSE) {
label(icon:new ImageIcon("$base$qs".toURL()),
constraints:BL.CENTER)
}
}
SwingBuilder上的edt方法使用事件调度线程构建 GUI。它接受一个闭包作为参数,从这里开始有趣的部分。闭包内的第一条语句是对frame方法的调用,但事实上,SwingBuilder中没有frame方法。构建器的元类拦截了这个调用(通过methodMissing),并将其解释为实例化javax.swing.JFrame类的请求。这里的frame方法列出了一系列映射条目,这些条目旨在为JFrame的标题、可见性和关闭操作提供值。构建器将它们解释为对JFrame实例的setTitle、setVisible和setDefaultCloseOperation的调用。
在括号之后还有一个闭包。这表示我即将提供将被添加到JFrame实例中的组件。下一个调用是对label方法的调用,当然这个方法不存在。Swing 构建器知道要生成一个JLabel实例,用包含 Google 图表返回的图像的新ImageIcon调用其setIcon方法,并将JLabel放置在BorderLayout的中心。
最后,在frame闭包之后,我调用JFrame上的pack方法,使生成的 GUI 刚好足够容纳图像。下一个列表包含完整的脚本(没有断言,以保持列表简短)。
列表 2.1. 使用 Google 图表构建 Swing UI 3D 饼图
import java.awt.BorderLayout as BL
import javax.swing.WindowConstants as WC
import groovy.swing.SwingBuilder
import javax.swing.ImageIcon
def base = 'http://chart.apis.google.com/chart?'
def params = [cht:'p3',chs:'250x100',
chd:'t:60,40',chl:'Hello|World']
String qs = params.collect { k,v -> "$k=$v" }.join('&')
SwingBuilder.edt {
frame(title:'Hello, Chart!', pack: true,
visible:true, defaultCloseOperation:WC.EXIT_ON_CLOSE) {
label(icon:new ImageIcon("$base$qs".toURL()),
constraints:BL.CENTER)
}
}
结果图像显示在图 2.3 中。
图 2.3. “Hello, World” Swing 用户界面,包含由 Google 图表返回的图像

经验教训(Google 图表)
-
Groovy 变量可以有类型,或者如果你不知道或者不在乎,可以使用
def关键字。关键字def也可以用作方法返回类型或参数。 -
Groovy 具有列表和映射的本地语法。这个例子使用了 Groovy 映射;列表在本书的其他许多例子中也被使用。
-
闭包类似于带有参数的匿名函数体。
-
collect方法通过将闭包应用于每个元素并返回结果列表来转换集合。 -
Groovy JDK 向标准 Java API 添加了许多方法。
-
Groovy 解析器和构建器简化了与许多 API 的工作。
下一个示例展示了 Groovy 的 XML 解析和生成能力、数据库操作、正则表达式、groovlets 等。
2.3. Groovy 棒球
图 2.4 显示了我在其中创建的 Web 应用程序,我称之为 Groovy 棒球。在棒球赛季的特定日期,该页面创建一个 Google 地图,显示当天所有大联盟棒球比赛的比分,使用以主场为中心的信息标记。比赛结果也列在一个小表中。提供了一个日历小部件,用户可以通过 Ajax 调用选择不同的日期,从而更新页面。
图 2.4. Groovy 棒球是一个显示特定日期 MLB 比赛结果的 Web 应用程序。

部分功能由 JavaScript 通过 Google Maps API 提供,该 API 创建地图并添加标记。对于给定日期的比赛结果集,通过使用原型 JavaScript 库的 Ajax 调用获取。稍后我将展示该代码。同时,我想强调这个应用程序的 Groovy 部分。
该应用程序简单,但它有相当多的动态部分,所以我将分阶段构建它。第一个任务是收集个别 MLB 球场的地理信息,并将其保存到数据库中,如图 2.5 所示。
图 2.5. 构建 Groovy 棒球,第一部分—地理编码球场数据并将其保存到数据库中

在这个过程的这部分,我将涵盖
-
简单的 Groovy 对象
-
访问 RESTful Web 服务
-
groovy.sql.Sql类
下一步是访问在线比分板并解析生成的 XML 文件,如图 2.6 所示。
图 2.6. 构建 Groovy 棒球,第二部分—提取比分数据并创建输出 POGOs

在这个阶段,我将讨论
-
从数据库中读取
-
在互联网上下载信息
-
解析 XML
最后,我需要将结果数据以视图层能理解的形式发送,如图 2.7 所示。figure 2.7。
图 2.7. 构建 Groovy 棒球,第三部分—驱动系统并生成 XML

在这个阶段,我将涵盖
-
使用 groovlet
-
生成 XML
我将从第一部分开始这个过程,创建 POGO 并将数据保存到数据库中。
2.3.1. 数据库数据和简单的 Groovy 对象
网页上的比赛结果集中在每场比赛的主场。Google Maps 根据给定位置的纬度和经度放置标记。因为球场不太可能移动,所以提前计算这些位置并将它们保存在某种持久化结构中是值得的。在这种情况下,我使用了 MySQL 数据库,但任何数据库都适用。
我将在这里构建一个脚本来收集每个 MLB 球场的必要信息,计算其纬度和经度,并将它们存储在数据库表中。我将从一个表示球场的类开始。
体育场 POGO
在 Java 中,我们会称这个类为普通 Java 对象,简称 POJO。在 Groovy 中,我会使用普通 Groovy 对象,简称 POGO。下面的列表展示了 Stadium 类。
列表 2.2. Stadium.groovy:一个用于存储球场信息的 POGO
package beans
class Stadium {
int id
String name
String city
String state
String team
double latitude
double longitude
String toString() { "($team,$name,$latitude,$longitude)" }
}
如果你习惯了 Java,这里引人注目的是缺少了什么。分号的存在可能在这个时候并不令人惊讶。可能令人惊讶的是,任何地方都没有公共或私有访问修饰符。在 Groovy 中,如果你没有指定访问修饰符,属性将被假定为私有的,方法将被假定为公共的.^([4])
⁴ 这又是一个“duh”时刻。Java 中的默认访问是“包私有”,这意味着成员可以从同一子目录中的任何其他类访问。在大概 15 年的 Java 编码中,我可能故意使用过这种访问方式两次,而且两次都有合理的替代方案。我可以理解尝试创建某种友元访问,但为什么将其作为默认值?再次强调,Groovy 做的是有意义的。
你可能还会注意到,Stadium 类中没有构造函数。在 Java 中,如果你没有添加构造函数,编译器会免费为你提供一个默认构造函数。然而,在 Groovy 中,你不仅得到默认的构造函数,还有一个基于映射的构造函数,允许你通过提供键值对来设置任何组合的属性值。
考虑到这一点,以下是用于将 Stadium 位置填充到数据库表中的脚本的第一部分:
def stadiums = []
stadiums <<
new Stadium(name:'Angel Stadium',city:'Anaheim',state:'CA',team:'ana')
stadiums <<
new Stadium(name:'Chase Field',city:'Phoenix',state:'AZ',team:'ari')
...
stadiums <<
new Stadium(name:'Rogers Centre',city:'Toronto',state:'ON',team:'tor')
stadiums <<
new Stadium(name:'Nationals Park',
city:'Washington',state:'DC',team:'was')
stadiums 变量被初始化为一个空的 java.util.ArrayList。左移运算符在 Collection 中被实现为一个追加方法,所以列表中的其余部分实例化了每个 MLB 球场并将其追加到列表中。
每个构造函数都会设置球场的 name、city、state 以及三个字母的 team 简称。缺少的是 latitude 和 longitude 值。为了提供这些值,我使用了 Google 地理编码器,这是 Google 提供的另一个 RESTful 网络服务,类似于前一部分讨论的 Google 图表 API。
POGO
普通 Groovy 对象类似于 POJO,但具有自动生成的获取器、设置器和基于映射的构造函数。
地理编码
Google 地理编码 API 的文档位于 developers.google.com/maps/documentation/geocoding/。地理编码器将地址转换为纬度和经度。要使用 Google 地理编码器,你需要组装一个包含地址信息的 URL。根据文档,URL 的格式如下
http://maps.googleapis.com/maps/api/geocode/output?parameters
这里 output 的值是 xml 或 json,取决于你想要返回哪种类型的数据.^([5]) parameters 属性包含地址以及一个 sensor 值。以下是文档中的示例,它自然地使用了加利福尼亚州山景城 Google 总部地址:
⁵ 真正的 REST 倡导者更倾向于在 HTTP 请求的
Accept头中进行内容协商。在这里,谷歌通过单独的 URI 来实现这一点。
http://maps.googleapis.com/maps/api/geocode/
xml?address=1600+Amphitheatre+Parkway,+Mountain+View,+CA&sensor=*true_or_*
*false*
如果你打算使用 JavaScript 访问地理编码器,我会建议使用json(JavaScript 对象表示法)作为输出值。因为我使用 Groovy,并且 Groovy 与 XML 配合得很好,所以我将使用xml值。查询字符串包含两个参数。第一个是地址,它包含街道、城市和州的 URL 编码值(用“,”分隔)。另一个参数称为sensor,如果请求来自具有 GPS 功能的设备,则其值为 true,否则为 false。
我将通过设置一个变量到基本 URL 来开始地理编码过程:
def base = 'http://maps.googleapis.com/maps/api/geocode/xml?'
要组装查询字符串,考虑一个包含体育场名称、城市和州的列表:
[stadium.name, stadium.city, stadium.state]
这些值中任何一个都可能包含空格、撇号或其他在 URL 中不合法的符号。因此,我需要将每个值进行 URL 编码。正如我在上一节中所示,将collect方法应用于列表返回一个包含转换后值的新列表。在这种情况下,我想要的转换是使用java.net.URLEncoder中的encode方法,如下所示:
[stadium.name, stadium.city, stadium.state].*collect* {
URLEncoder.encode(it,'UTF-8')
}.join(',')
如果你在这里没有指定占位符参数的闭包,每个列表元素将被分配给一个名为it的变量。闭包的主体使用 UTF-8 编码方案在名称、城市和州上执行静态encode方法。结果是包含编码值的列表。最后,使用“,”作为分隔符将列表的值连接成一个字符串。
这样就完成了地址的组装。使用与谷歌图表列表中相同的闭包来形成完整的查询字符串。到目前为止的完整过程如下所示:
def url = base + [sensor:false,
address: [stadium.name, stadium.city, stadium.state].*collect* {
URLEncoder.encode(it,'UTF-8')
}.*join*(',')
].*collect* {k,v -> "$k=$v"}.*join*('&')
构建查询字符串
参数映射、collect闭包和join方法的组合是构建查询字符串的一种方便方式。开发者可以以任何顺序存储参数,或者从用户那里接受它们(如在 Grails 应用程序中),并以最小的努力将它们转换为查询字符串。
所有这些字符串操作的结果是创建一个完整的 URL,类似于前一个示例中显示的 URL,它可以被传输到谷歌地理编码器。
现在是时候进行有趣的部分了。地理编码器返回一个相当大的 XML 块(此处未显示,但可在谷歌地理编码器文档的developers.google.com/maps/documentation/geocoding/#XML在线找到)。使用 Java 处理 XML 将会非常冗长。幸运的是,对于 Groovy 来说,XML 不是什么大问题。将 URL 传输到谷歌地理编码器并将结果解析为 DOM 树的过程只需要一行代码:
def response = new XmlSlurper().parse(url)
Groovy 有两个用于解析 XML 的类。一个是 XmlParser,另一个是 XmlSlurper。两者都将 XML 转换为 DOM 树。底层结构和过程有些不同,但从实际角度来看,slurper 更高效且占用更少的内存,所以我将在这里使用它。提取所需的结果只是简单地遍历树。我可以粘贴一个 XML 输出的副本来显示结构,但如果你看到 Groovy 解析代码,这很容易理解:
stadium.latitude = response.result[0].geometry.location.lat.toDouble()[6]
stadium.longitude = response.result[0].geometry.location.lng.toDouble()
⁶ 在 Java 中试试 那个。没有什么能像与 XML 一起工作那样让 Java 开发者喜欢 Groovy。
换句话说,slurper 返回 DOM 树的根,并将其分配给一个名为 response 的变量。根有一个名为 result 的子元素,它有一个名为 geometry 的子元素,它有一个名为 location 的子元素,然后它有两个子元素,一个名为 lat,另一个名为 lng。有时地理编码器会返回多个结果,所以我使用了 result 的数组索引 0 来只使用第一个。因为 XML 中的所有内容都是 String,而我想要将结果分配给 Stadium 中的双精度值,所以我最终使用添加到 String 的 toDouble 方法来进行转换。
解析 XML
无论你使用 XmlParser 还是 XmlSlurper,从 XML 中提取数据只是遍历树。⁷]
⁷ 解析(实际上是 slurping)JSON 同样简单。本书源代码的 第二章 包含另一个示例,该示例访问并解析 JSON 数据。
以下列表显示了完整的 Geocoder 类,其中包含其 fillInLatLng 方法,该方法接受一个 Stadium 参数并填充纬度和经度值。
列表 2.3. Geocoder.groovy,它使用 Google 地理编码器计算纬度和经度
class Geocoder {
def base = 'http://maps.googleapis.com/maps/api/geocode/xml?'
def fillInLatLng(Stadium stadium) {
def url = base + [sensor:false,
address: [stadium.name, stadium.city, stadium.state].*collect* {
URLEncoder.encode(it,'UTF-8')
}.*join*(',')
].*collect* {k,v -> "$k=$v"}.*join*('&')
def response = new XmlSlurper().parse(url)
stadium.latitude =
response.result[0].geometry.location.lat.*toDouble*()
stadium.longitude =
response.result[0].geometry.location.lng.*toDouble*()
return stadium
}
}
groovy.sql.Sql 类
返回到原始问题,我想将体育场信息存储在数据库中。我现在将利用 Groovy 库中的一个非常有用的类,groovy.sql.Sql。这个类连接到数据库,并允许你对它执行 SQL。为了开始这个过程,这是如何实例化 Sql 类的:
Sql db = Sql.*newInstance*(
'jdbc:mysql://localhost:3306/baseball',
'...username...',
'...password...',
'com.mysql.jdbc.Driver'
)
Sql 类有一个静态的 newInstance 方法,其参数包括 JDBC URL、用户名和密码以及驱动类。结果是数据库的连接。接下来,如果 stadium 表已经存在,我将删除它:
db.execute "drop table if exists stadium;"
execute 方法接受一个 SQL 字符串并在数据库上运行它。在这里,我再次利用了可选的括号。
下一步是创建一个用于存储体育场信息的表:
db.execute '''
create table stadium(
id int not null auto_increment,
name varchar(200) not null,
city varchar(200) not null,
state char(2) not null,
team char(3) not null,
latitude double,
longitude double,
primary key(id)
);
'''
三个单引号代表 Groovy 中的多行字符串。三个双引号将是一个多行 GString,我可以用它来进行参数替换,但在这个特定情况下不需要。
现在表已经构建好了,是时候用体育场数据填充表了:
Geocoder geo = new Geocoder()
stadiums.*each* { s ->
geo.fillInLatLng s
db.execute """
insert into stadium(name, city, state, team, latitude, longitude)
values(${s.name},${s.city},${s.state},
${s.team},${s.latitude},${s.longitude});
"""
}
在实例化地理编码器后,我遍历集合中的每个体育馆,将每个分配给虚拟变量 s。对于每一个,在计算纬度和经度之后,我执行一个包含在三个双引号内的 insert 语句,其中使用标准的 ${...} 符号替换从体育馆中需要的值。
剩下的工作就是进行某种合理性检查,以确保接收到的值是合理的。以下是一些用于此目的的 assert 语句:
assert db.rows('select * from stadium').size() == stadiums.size()
db.eachRow('select latitude, longitude from stadium') { row ->
assert row.latitude > 25 && row.latitude < 48
assert row.longitude > -123 && row.longitude < -71
}
第一个 assert 语句检查表中的总行数是否与集合中的体育馆数量匹配。下一个语句在连接上调用 eachRow 方法,仅选择经纬度,并将虚拟变量 row 分配给结果集中的每一行。两个包含的 assert 语句验证纬度在 25 到 48 之间,经度在 -123 到 -71 之间。
Sql 类
groovy.sql.Sql 类几乎消除了原始 JDBC 的所有仪式,并添加了便利的方法。
完整的脚本在下一部分列出。
列表 2.4. populate_stadium_data.groovy


此脚本收集每个 MLB 体育馆的所有经纬度值,创建一个数据库表来存储它们,并填充该表。它只需运行一次,然后应用程序就可以使用该表。在审查代码的过程中,我使用了 Stadium POGO、一个列表、几个带有闭包的 collect 方法、一个在 Groovy 脚本中使用 Java 的 URLEncoder 类的示例,以及通过 groovy.sql.Sql 类进行数据库操作。
下一步是从美国职业棒球大联盟维护的网站上收集比分数据,并生成可以发送到视图页面的 XML 信息。
2.3.2. 解析 XML
美国职业棒球大联盟持续在线更新棒球比赛的结果。信息以 XML 格式保存在从 gd2.mlb.com/components/game/mlb/ 下降的链接中。
在网站上,比赛按日期排列。从基本 URL 深入需要形式为 "year_${year}/month_${month}/day_${day}/" 的链接,其中年份是四位数字,月份和日期各为两位数字。该日期的比赛作为单独的链接列出。例如,图 2.8 显示了 2007 年 5 月 5 日每场比赛的链接。^([8)]
⁸ 令人惊讶的是,5 月 5 日也是我儿子的生日。
图 2.8. 2007 年 5 月 5 日举行的棒球比赛的链接

每个单独比赛的链接形式为
gid_${year}_${month}_${day}_${away}mlb_${home}mlb_${num}
year、month和day的值符合预期。away和home的值是每个队伍的三字母小写缩写,num的值代表那天比赛的编号(第一场比赛为 1,双头赛的第二场比赛为 2)。每个比赛的链接包含一系列文件,但我感兴趣的是名为 boxscore.xml 的文件。
为了检索得分板信息,我将创建一个名为GetGameData的类。这个类将具有基础 URL 和队伍缩写的属性,如所示。下一个列表显示了该类的一部分。
列表 2.5. GetGameData的一部分,显示属性和初始化


abbrevs映射中的键值对分别持有每个队伍的三字母缩写和城市名称。
下一步是处理实际的得分板。以下是一些随机选取的样本数据。我选择的随机日期是 2007 年 10 月 28 日.^([9]) 下一个列表显示了 XML 形式的得分板,截断以显示典型元素,而不显示所有元素。
⁹ 正好是红袜队在 2007 年赢得世界大赛的那一天。
列表 2.6. boxscore.xml:2007 年世界大赛第 4 场比赛的得分板
<boxscore game_id=*"2007/10/28/bosmlb-colmlb-1"* game_pk=*"224026"*
home_sport_code=*"mlb"* away_team_code=*"bos"* home_team_code=*"col"*
away_id=*"111"* home_id=*"115"* away_fname=*"Boston Red Sox"*
home_fname=*"Colorado Rockies"*
away_sname=*"Boston"* home_sname=*"Colorado"* date=*"October 28, 2007"*
away_wins=*"5"* away_loss=*"0"* home_wins=*"0"* home_loss=*"5"* status_ind=*"F"*>
<linescore away_team_runs=*"4"* home_team_runs=*"3"*
away_team_hits=*"9"* home_team_hits=*"7"* away_team_errors=*"0"*
home_team_errors=*"0"*>
<inning_line_score away=*"1"* home=*"0"* inning=*"1"* />
<inning_line_score away=*"0"* home=*"0"* inning=*"2"* />
...
<inning_line_score away=*"0"* home=*"0"* inning=*"9"* />
</linescore>
<pitching team_flag=*"away"* out=*"27"* h=*"7"* r=*"3"* er=*"3"* bb=*"3"*
so=*"7"* hr=*"2"* bf=*"37"* era=*"2.60"*>
<pitcher id=*"452657"* name=*"Lester"* pos=*"P"* out=*"17"* bf=*"23"*
er=*"0"* r=*"0"* h=*"3"* so=*"3"* hr=*"0"* bb=*"3"* w=*"2"* l=*"0"* era=*"0.00"*
note=*"(W, 2-0)"* />
<pitcher id=*"434668"* name=*"Delcarmen"* pos=*"P"* out=*"2"* bf=*"4"*
er=*"1"* r=*"1"* h=*"2"* so=*"1"* hr=*"1"* bb=*"0"* w=*"0"* l=*"0"* era=*"9.00"*
note=*"(H, 2)"* />
...
<pitcher id=*"449097"* name=*"Papelbon"* pos=*"P"* out=*"5"* bf=*"5"*
er=*"0"* r=*"0"* h=*"0"* so=*"1"* hr=*"0"* bb=*"0"* w=*"0"* l=*"0"* era=*"0.00"*
note=*"(S, 4)"* />
</pitching>
<batting team_flag=*"home"* ab=*"34"* r=*"3"* h=*"7"* d=*"2"* t=*"0"* hr=*"2"*
rbi=*"3"* bb=*"3"* po=*"27"* da=*"18"* so=*"7"* avg=*".216"* lob=*"12"*>
<batter id=*"430565"* name=*"Matsui"* pos=*"2B"* bo=*"100"* ab=*"4"* po=*"3"*
r=*"0"* bb=*"0"* a=*"5"* t=*"0"* sf=*"0"* h=*"1"* e=*"0"* d=*"1"* hbp=*"0"*
so=*"1"* hr=*"0"* rbi=*"0"* lob=*"2"* fldg=*"1.000"* avg=*".286"* />
<batter id=*"466918"* name=*"Corpas"* pos=*"P"* bo=*"101"* ab=*"0"* po=*"0"*
r=*"0"* bb=*"0"* a=*"1"* t=*"0"* sf=*"0"* h=*"0"* e=*"0"* d=*"0"* hbp=*"0"*
so=*"0"* hr=*"0"* rbi=*"0"* lob=*"0"* fldg=*"1.000"* avg=*".000"* />
...
</batting>
<pitching team_flag=*"home"* out=*"27"* h=*"9"* r=*"4"* er=*"4"* bb=*"1"*
so=*"4"* hr=*"2"* bf=*"34"* era=*"6.91"*>
<pitcher id=*"346871"* name=*"Cook"* pos=*"P"* out=*"18"* bf=*"23"* er=*"3"*
r=*"3"* h=*"6"* so=*"2"* hr=*"1"* bb=*"0"* w=*"0"* l=*"2"* era=*"4.50"*
note=*"(L, 0-2)"* />
...
</pitching>
<batting team_flag=*"away"* ab=*"33"* r=*"4"* h=*"9"* d=*"2"* t=*"0"* hr=*"2"*
rbi=*"4"* bb=*"1"* po=*"27"* da=*"8"* so=*"4"* avg=*".322"* lob=*"10"*>
<batter id=*"453056"* name=*"Ellsbury"* pos=*"CF-LF"* bo=*"100"* ab=*"4"*
po=*"3"* r=*"1"* bb=*"0"* a=*"0"* t=*"0"* sf=*"0"* h=*"2"* e=*"0"* d=*"1"*
so=*"1"* hr=*"0"* rbi=*"0"* lob=*"2"* fldg=*"1.000"* avg=*".450"* />
<batter id=*"456030"* name=*"Pedroia"* pos=*"2B"* bo=*"200"* ab=*"4"* po=*"1"*
r=*"0"* bb=*"0"* a=*"4"* t=*"0"* sf=*"0"* h=*"0"* e=*"0"* d=*"0"* hbp=*"0"*
so=*"0"* hr=*"0"* rbi=*"0"* lob=*"2"* fldg=*"1.000"* avg=*".227"* />
...
</batting>
...
</boxscore>
根元素是<boxscore>,它有几个属性。它有一个名为<linescore>的子元素,显示每局的得分。然后是<pitching>和<batting>元素,分别代表主队和客队。
这不是一个特别复杂的 XML 文件,但如果您必须使用 Java 处理它,代码会很快变得复杂。使用前面展示的 Groovy,您只需遍历树即可。
解析这些数据与上一节中解析地理编码数据的做法相同。在这里,我需要根据月份、日期和年份组装 URL,然后解析得分板文件:
def url = base + "year_${year}/*month_*${month}/*day_*${day}/"
def game = "gid_${year}_${month}_${day}_${away}mlb_${home}mlb_${num}/
*boxscore*.xml"
def boxscore = new XmlSlurper().parse("$url$game")
解析文件后,我可以遍历树以提取队伍名称和得分:
def awayName = boxscore.@away_fname
def awayScore = boxscore.linescore[0].@away_team_runs
def homeName = boxscore.@home_fname
def homeScore = boxscore.linescore[0].@home_team_runs
点号代表子元素,如前所述,这次@符号表示属性。
解析 XML
点号从父元素遍历到子元素,而@符号代表属性值。
XML、正则表达式和 Groovy 的真理
要进行一些稍微有趣的处理,可以考虑确定获胜和失败的投手。XML 在pitcher元素的note属性中包含该信息,我可以使用正则表达式处理,假设它确实存在:
def pitchers = boxscore.pitching.pitcher
pitchers.each { p ->
if (p.@note && p.@note =~ /W|L|S/) {
println " ${p.@name} ${p.@note}"
}
}
首先,我选择两队的所有pitcher元素。然后,我想检查pitcher元素以找出谁获胜和失败,以及是否有人被分配了救援。在 XML 中,这些信息保存在pitcher元素的note注释中,这可能存在也可能不存在。
因此,在if语句中,我检查是否存在note属性。在这里,我使用“Groovy 真理”,这意味着非空引用评估为真。同样,非空字符串或集合、非零数字以及当然,布尔字面量true也是如此。如果存在note元素,然后我使用所谓的“斜线”语法来检查该注释是否与正则表达式匹配:p.@note =~ /W|L|S/。如果匹配,则打印出值。
生成游戏结果
在我展示完整方法之前,我还需要一个部分。对于 Groovy 棒球应用程序,我对控制台输出不感兴趣。相反,我想将游戏结果组装成可以在视图层由 JavaScript 处理的格式。这意味着我需要返回一个可以转换为 XML(或 JSON)的对象。
这里有一个名为GameResult的类用于此目的:
class GameResult {
String home
String away
String hScore
String aScore
Stadium stadium
String toString() { "$home $hScore, $away $aScore" }
}
闭包返回值
闭包中的最后一个表达式会自动返回。
这个 POGO 是主队和客队以及主队和客队得分以及球场的简单包装。球场是必需的,因为它包含我需要的用于 Google 地图的纬度和经度值。下面的列表现在显示了GetGameData类中列表 2.5 所示的完整getGame方法。
列表 2.7. GetGameData.groovy中的getGame方法

该方法使用一个XmlSlurper将 XML 比赛得分转换为 DOM 树,提取所需信息,并创建并返回Game-Result类的实例。
在GetGameData类中还有一个其他的方法,这是用来解析当天比赛列表网页的。这是必要的,因为由于雨停和其他推迟,事先无法知道哪场比赛将在某一天实际进行。
解析 HTML 总是一个冒险的提议,尤其是因为它可能不是良好形成的。有第三方库来做这件事^([10]),但这里显示的机制是有效的。它还展示了 Groovy 中的正则表达式映射。下一个列表显示了GetGameData中的getGames方法。
¹⁰ 例如,请参阅NekoHTML 解析器。
列表 2.8. GetGameData中的getGames方法

Groovy 中的=~方法返回一个java.util.regex.Matcher实例。正则表达式中的括号是分组,这让我可以从 URL 中提取客队缩写、主队缩写和比赛编号。我使用这些来调用列表 2.7 中的getGames方法,并将结果放入GameResult实例的集合中。
测试
剩下的就是测试完整的GetGameData类。下一个列表显示了用于此目的的 JUnit 测试。
列表 2.9. GetGameDataTests.groovy:JUnit 4 测试用例

这是一个标准的 JUnit 4 测试用例。我在第六章(kindle_split_017.html#ch06)关于测试的章节中有更多关于 Groovy 测试能力的话要说,但这里有一个简单的例子。这个类除了以下三点以外没有固有的 Groovy 特性:(1)我使用了基于映射的构造函数来实例化测试用例,(2)尽可能省略了可选的括号,(3)不需要显式的 public 或 private 关键字。否则,这只是一个普通的测试用例,并且像往常一样工作。
我在本节中讨论了什么?
-
Groovy 为映射提供了方便的语法。
-
如前所述,XML 解析和提取数据都很简单。
-
Groovy 有用于正则表达式的斜线语法。
-
Groovy 类与 JUnit 测试一起工作。
还需要解决一个最终的问题,那就是用于调用每个日期的系统的驱动程序。在下一节中,我使用一个“groovlet”来完成这个目的。
2.3.3. HTML 构建器和 groovlets
到目前为止使用的类访问 XML 比赛得分信息并将其转换为一系列比赛结果对象。然而,对于视图层,我需要可以由 JavaScript 处理的对象。有几种方法可以实现这一点,但其中之一是使用 XML 构建器以 XML 形式写出信息.^([11])
^(11)数据也可以很容易地以 JSON 格式编写。本书中使用了其他 JSON 示例。
生成 XML
标准的 Groovy 库包括一个名为 groovy.xml.MarkupBuilder 的类,^([12]), 它是标准库中几个构建器之一(类似于本章开头展示的 SwingBuilder)。每个构建器都会拦截不存在的方法调用(所谓的 假想 方法)并从中构建节点以形成树结构。然后,根据该类型的构建器适当地导出树。
^(12)我敢打赌,如果这个类今天被创建,它会被命名为
XmlBuilder。
这实际上比解释更容易看到。考虑前一个节段的 GameResult 类,它包含了主队和客队名称、比分以及一个指向 Stadium 对象的引用。以下是创建 XML 的语法:
MarkupBuilder builder = new MarkupBuilder()
builder.games {
results.each { g ->
game(
outcome:"$g.away $g.aScore, $g.home $g.hScore",
lat:g.stadium.latitude,
lng:g.stadium.longitude
)
}
}
在实例化 MarkupBuilder 并调用引用 builder 之后,第二行在它上面调用了 games 方法。它可能看起来不像一个方法,但请记住,在 Groovy 中,如果闭包是方法的最后一个参数,它可以放在括号外面,这里我使用了可选的括号。当然,MarkupBuilder 中没有名为 games 的方法。这使得它成为一个假想的方法,构建器拦截这个方法调用并从中创建一个节点。在 MarkupBuilder 中,这意味着它最终会创建一个名为 games 的 XML 元素。闭包语法暗示下一个元素将是 games 的子元素。
在闭包内部,代码遍历每个包含的结果,将其分配给虚拟变量 g。对于每个 GameResult g,构建器创建一个名为 game 的元素。game 上的括号表示 game 将包含属性。在这种情况下,每个 game 都有一个 outcome、一个 lat 和一个 lng。
这是 MarkupBuilder 的输出:
<games>
<game outcome='Boston Red Sox 4, Colorado Rockies 3'
lat='39.7564956' lng='-104.9940163' />
</games>
如果那天有十二场比赛,那么每场比赛都会有一个 <game> 元素。总之,在 Groovy 中,生成 XML 与解析 XML 一样简单。
使用 groovlets 进行服务器端处理
为了驱动整个系统,我需要一个服务器端组件,它接收所需日期并调用 GetGameData 类来检索游戏,然后以 XML 格式返回。Groovy 有一个名为 groovlet 的组件,可以轻松实现这一切。
Groovlet 是一个由名为 groovy.servlet.GroovyServlet 的类执行的脚本。这个类是 Groovy 标准库的一部分。像任何 servlet 一样,它需要在 web.xml 部署描述符中声明,并映射到特定的 URL 模式。在这种情况下,我选择了模式 *.groovy。以下是部署描述符的摘录:
<servlet>
<servlet-name>GroovyServlet</servlet-name>
<servlet-class>groovy.servlet.GroovyServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>GroovyServlet</servlet-name>
<url-pattern>*.groovy</url-pattern>
</servlet-mapping>
因此,Groovy Baseball 应用程序将发送所有以 .groovy 结尾的 URL 通过 GroovyServlet,它将执行它们。以这种方式执行的 groovlets 作为源代码部署,而不是在 WEB-INF 下的编译类。^[[13] Groovlets 还包含一组表示请求、响应、输入参数等隐式对象。
[1] 详细内容请参阅第十章关于网络应用。
以下列表包含驱动 Groovy Baseball 系统的 groovlet 的完整内容。
列表 2.10. GameServlet.groovy:Groovy Baseball 的 groovlet

Groovlet 可以设置响应头,这里设置为输出 XML 格式。输入参数填充一个名为 params 的字符串映射,可以通过常规方式访问。URL 需要两位数的日期和两位数的月份,因此当需要时会在前面添加零。在检索到该日期的游戏后,使用隐式的 MarkupBuilder 生成输出。在这种情况下,不需要实例化 MarkupBuilder,因为 groovlets 已经包含一个,称为 html。
Groovlet 是从一个常规网页中调用的,使用形式为 .../groovybaseball/GroovyService.groovy?month=10&day=28&year=2007 的 URL。XML 数据被写入输出流,然后可以被 JavaScript 处理。
Groovy Baseball 的经验教训
-
POGOs 默认具有私有属性和公共方法。每个属性都会自动生成公共的获取器和设置器。
-
POGOs 包含一个基于映射的构造函数,可以用来设置任何或所有属性的任何组合。
-
Groovy 中的闭包和方法会自动返回它们的最后一个评估表达式。
-
XmlSlurper类使得解析 XML 变得简单,并返回结果 DOM 树的根。可以通过遍历树来提取值。 -
MarkupBuilder类生成 XML。 -
groovy.sql.Sql类是处理关系数据库的简单外观。 -
Groovlets 是响应 HTTP 请求的简单 Groovy 脚本。
-
所有 Groovy 异常都是未检查的。
系统的其余部分只是 HTML 和 JavaScript,因此超出了 Groovy 讨论的范围。应用程序的完整源代码包含在本书的 GitHub 存储库中。
2.4. 摘要
本章是面向 Java 开发者的 Groovy 教程,通过示例应用程序而不是一系列功能来介绍。令人印象深刻的是 Groovy 如何简化代码。POGs 是 POJOs 的一个最小化且更灵活的版本。groovy.sql.Sql类使得 JDBC 对于合理规模的应用变得实用。Groovy JDK 添加了许多便利方法,如toURL和getText,这些方法使得现有的 Java 类更容易使用。map、闭包和join方法的组合使得构建 Web 服务的 URL 变得简单。最后,Java 中处理 XML 与 Groovy 中处理 XML 之间的差异令人震惊。每次我必须以任何形式处理 XML 时,我总是寻找添加 Groovy 模块来处理细节的方法。
在下一章中,我们将更详细地探讨将 Java 和 Groovy 集成在一起的方法。
第三章. 代码级集成
本章涵盖
-
使用 JSR 223 从 Java 调用 Groovy 脚本
-
使用 Groovy 库类从 Java 调用 Groovy 脚本
在第一章中,我回顾了许多 Java 的可争议的弱点和缺点,并建议 Groovy 可能有助于改善它们。因为那一章旨在作为介绍,所以我只建议 Groovy 如何帮助,而没有展示很多代码示例。
本章开始详细探讨 Java 和 Groovy 的集成。在本章中,我将开始以基本方式使用 Groovy 和 Java,而不必担心框架或解决任何特定的用例。本章讨论的技术指南如图 3.1 所示图 3.1。
图 3.1. 集成功能指南。仅使用 Java 类即可通过 JSR 223 脚本引擎访问 Groovy。如果您愿意将一些 Groovy 库类添加到 Java 中,Eval、GroovyShell和Binding类使得处理脚本变得容易。将 Groovy 和 Java 结合起来的最佳方式是使用两种语言中的类。

3.1. 将 Java 与其他语言集成
将 Java 与其他语言结合一直是一个挑战。Java 历史上与其他语言配合得并不好.^([1]) 从一开始就为 Java 调用其他语言编写的函数设计的唯一 API 是 JNI,即 Java 本地接口,即使在最佳情况下使用起来也很繁琐.^([2]) 然而,在过去的几年里,我们看到从 Groovy 到 Scala 到 Clojure 等一系列直接编译为在 JVM 上运行的字节码的语言兴起,以及像 Jython 或 JRUBY 这样的桥梁语言,允许你在 JVM 上运行用 Python 或 Ruby 编写的代码。从这些“替代”基于 JVM 的语言的角度来看,Java 真正的贡献不是语言本身,而是虚拟机及其相关的 Java 库。基于 JVM 的语言利用 Java 基础设施,并试图处理任何 Java 特定的缺点。
¹ 当然,这一点对大多数语言都适用。
² 曾经在 20 世纪 90 年代末晚期,我不得不在用 Fortran 编写的工程系统前面构建一个 Java Swing 用户界面。我使用了 JNI 从 Java 到 C,然后从 C 到 Fortran。结果就像在木梁上开一个缺口,然后说,“我希望你就在这里断裂。”
JVM
最终,Java 最大的贡献不是语言本身;而是虚拟机。
每当 Java 的基本基础设施集成新的功能时,就会创建一个 Java 规范请求(JSR)来提供一个标准的实现机制。在集成案例中,相关的 JSR 是 JSR 223,Java 平台脚本编程(jcp.org/en/jsr/detail?id=223)。JSR 的目的在于允许其他(可能是脚本)语言从 Java 中调用。尽管本书的大部分内容将假设你是在类级别上混合使用 Java 和 Groovy,为了完整性,我将在此回顾如何从 Java 调用 Groovy 脚本,既使用 JSR 技术,也使用 Groovy 为此目的提供的库类。
Groovy 与 Java 的关系比脚本集成故事所暗示的要近得多。正如我将在从 Groovy 调用 Java 而不是相反方向的章节中演示的那样,几乎任何规模的 Groovy 程序都已经使用了 Java。Groovy 代码可以实例化一个 Java 类,调用添加到其中的 Groovy 方法(所谓 Groovy JDK,在第四章中突出显示[kindle_split_014.html#ch04],第 4.3 节[kindle_split_014.html#ch04lev1sec3]),并且可以在结果上调用额外的 Java 方法。那么问题就变成了,Groovy 为 Java 带来了什么?如何通过添加 Groovy 到 Java 系统中来简化你的开发任务?我将在本章的其余部分(以及本书的其余部分)中回答这个问题。不过,让我们先从脚本故事开始。当 Groovy 由脚本而不是类组成,并且你想要隔离任何 Java 集成代码时,如何在同一个系统中结合 Java 和 Groovy?
3.2. 从 Java 执行 Groovy 脚本
本章前几节中的假设是,你已经编写或获取了一些 Groovy 脚本,并希望以最小侵入性的方式在 Java 系统中使用它们。也许你正在使用这些脚本在 Groovy 中实现业务逻辑,因为它们变化如此频繁(Dierk Koenig,Groovy in Action 的主要作者,将其称为液体心脏技术[Manning, 2007])。也许你正在用 Groovy 替换 Perl 脚本,因为你可以用 Groovy 做任何用 Perl 可以做的事情,并且还有一个额外的优点,那就是你可以与现有的 Java 系统集成。也许你正在遵循 JSR 的原始意图之一,即使用脚本语言生成用户界面,同时让 Java 处理后端功能。无论如何,我想演示如何尽可能容易地从 Java 系统中调用这些脚本。
Groovy 的一个有趣特性是,与 Java 不同,你不必将所有 Groovy 代码放入一个类中。你只需将所有 Groovy 代码放入一个名为你喜欢的任何名称的文件中,只要文件扩展名是 .groovy,然后你可以使用 groovy 命令执行脚本。Groovy 的一个可能的优势是编写简短、简单的程序,而不需要创建一个包含 main 方法的类,在这里我将展示如何将此类脚本集成到 Java 应用程序中。
按照标准,我将从一个基于 JSR 223 的技术开始,即 Java 平台脚本,它允许你仅通过 Java 库调用调用 Groovy。然后我将展示,如果你使用 Groovy API 中的几个类,你可以简化集成。最后,我将展示,如果你可以将你的 Groovy 代码从脚本更改为类,几乎可以消除所有复杂性。
顺便说一句,假设任何 Groovy 脚本都已编译,在运行时将组合应用程序视为全部是 Java。我在本章中计划讨论的所有集成策略都涉及决定在哪里以及如何使用 Groovy 来使你的生活更轻松。然而,一旦你有了组合系统,部署故事就非常简单,正如侧边栏所示。
运行时 Groovy 和 Java 一起
在 运行时,编译后的 Groovy 和编译后的 Java 都会为 JVM 生成字节码。要执行结合它们的代码,只需要将一个 JAR 文件添加到系统中。编译和测试你的代码需要 Groovy 编译器和库,但在运行时你只需要一个 JAR。
那个 JAR 文件包含在你的 Groovy 发行版中的可嵌入子目录中。例如,假设你的 Groovy 安装版本是 2.1.5。那么在你的磁盘上 Groovy 安装目录中,你有以下结构,你需要的是 groovy-all-2.1.5.jar。

将 groovy-all JAR 添加到你的系统中,你就可以使用 java 命令来运行它。
在文本的其余部分,我将把这个 JAR 文件称为“groovy-all” JAR。如果你把这个 JAR 添加到你的类路径中,你可以使用标准的 java 命令来执行 Groovy 和 Java 的组合应用程序。如果你把一个 Groovy 模块添加到一个 Web 应用程序中,请将 groovy-all JAR 添加到 WEB-INF/lib 目录,一切都将正常工作。
这里有一个简单的演示,只是为了证明这个观点。考虑用 Groovy 编写的“Hello, World!”应用程序,与 Java 不同,它是一行代码:
println 'Hello, Groovy!'
如果我把这个保存到一个名为 hello_world.groovy 的文件中,我可以用 groovy 命令来执行脚本,它会编译并运行它。但是,要使用 java 命令来运行它,我必须首先用 groovyc 编译它,然后执行生成的字节码,确保 groovy-all JAR 在类路径中。这个过程分为两步。注意,java 命令应该在一行上:
> groovyc hello_world.groovy
> java –cp
.:$GROOVY_HOME/embeddable/groovy-all-2.1.5.jar
hello_world
→ Hello, Groovy!
我需要 groovyc 命令来编译脚本,但我能够使用普通的 java 命令(只要 groovy-all JAR 在执行类路径中)来执行它。
在 API 层面,要从 Java 调用一个 Groovy 脚本,你有几种选择。我将首先展示“最困难”的方法,使用 JSR-223 API。与 JSR 223 相关的 API 设计用于允许 Java 程序调用用其他语言编写的脚本。
我称之为“困难的方式”是因为它没有利用 Groovy 提供的任何东西,除了脚本本身。我将使用 Java API 提供的间接层,它将 Groovy 代码与调用它的 Java 代码分开。稍后,你将通过组合类和方法开始混合 Java 和 Groovy,你会发现这要容易得多。尽管如此,了解如何使用 JSR 仍然值得,特别是因为它毕竟是标准。此外,尽管在技术上这是困难的方式,但实际上并不那么困难。
3.2.1. 使用 JSR223 脚本为 Java 平台 API
内置于 Java SE 6 及更高版本中,JSR 223 的 API,即 Java 平台的脚本,是一个标准机制,你可以用它来调用用其他语言编写的脚本。这种方法的优势在于它避免了在调用 Java 程序中引入任何特定于 Groovy 的内容。如果你已经有了 Groovy 脚本,并且只想在 Java 内部调用它们,这是一个不错的选择。
JSR 223
JSR 允许你使用纯 Java 类调用 Groovy 脚本。
JSR 定义了一个基于javax.script.ScriptEngine实例的 API。与许多 Java 库一样,该 API 还包括一个工厂接口,在这种情况下称为javax.script.ScriptEngineFactory,用于检索ScriptEngine实例。该 API 还指定了一个javax.script.ScriptEngineManager类,它检索有关可用的ScriptEngineFactory实例的元数据。
在许多 Java API 中,你使用一个工厂来获取你需要的对象。例如,使用 SAX 解析器解析 XML 是通过首先获取SAXParserFactory的实例,然后使用它来获取一个新的 SAX 解析器来完成的。对于 DOM 构建器、XSLT 转换引擎以及许多其他情况也是如此。在每种情况下,如果你想使用除内置默认实现之外的特定实现,你需要指定一个环境变量、方法参数或其他方式让 Java 知道你打算做不同的事情。你还需要在类路径中提供替代实现。
因此,首要问题是确定用于 Groovy 代码的脚本引擎是否默认可用,如果不可用,如何获取它。使用 Oracle 的 Java 7 JDK,我可以确定哪些工厂已经内置。以下列表从管理器检索所有可用的工厂并打印它们的一些属性。
列表 3.1. 查找所有可用的脚本引擎工厂

为了比简单地使用System.out.println语句更好的实践,我设置了一个简单的日志记录器。然后我从管理器检索所有可用的工厂并打印语言名称和引擎名称。最后,我打印出每个工厂的所有可用名称,这显示了所有可以用来检索它们的别名。
结果如下,为了可读性进行了截断:
INFO: lang name: ECMAScript
INFO: lang version: 1.8
INFO: engine version: 1.7 release 3 PRERELEASE
INFO: engine name: Mozilla Rhino
INFO: [js, rhino, JavaScript, javascript, ECMAScript, ecmascript]
输出显示默认情况下只有一个工厂可用,其目的是执行 JavaScript(或者更正式地,ECMAScript)。这个工厂可以通过最后一行上的任何名称检索,但只有一个工厂可用,它与 Groovy 没有关系。
幸运的是,使 Groovy 脚本引擎工厂可用很容易。ScriptEngineManager 类的一个特性是它使用与 JAR 文件相同的扩展机制来检测新的工厂。换句话说,你所要做的就是通过 groovy-all JAR 将 Groovy 库添加到你的类路径中。一旦这样做,相同的程序就会产生这里所示的增加输出:
INFO: lang name: Groovy
INFO: lang version: 2.1.3
INFO: engine version: 2.0
INFO: engine name: Groovy Scripting Engine
INFO: [groovy, Groovy]
在这种情况下,脚本引擎报告 Groovy 语言版本为 2.1.3,引擎版本为 2.0.^([3])
³ 我确实使用了 Groovy 2.1.5 编译器,但脚本引擎仍然报告为 2.1.3。尽管如此,这并不影响结果。
在这个特定的 API 中,尽管现在有工厂可用,但你不需要使用它来获取脚本引擎。相反,ScriptEngineManager 类有一个方法可以通过提供其名称(如前一个输出所示,可以是 groovy 或 Groovy)作为 String 来检索工厂。然后,我可以从 ScriptEngine 使用脚本引擎的 eval 方法执行 Groovy 脚本。这个过程在 图 3.2 中得到了说明。
图 3.2. 使用 JSR 223 的 ScriptEngine 调用 Groovy 脚本。Java 创建一个 ScriptEngineManager,然后生成一个 ScriptEngine。在向引擎提供参数后,其 eval 方法被调用以执行一个 Groovy 脚本。

下一个列表展示了 API 在一个简单的“Hello, World!” Groovy 脚本中的实际应用。
列表 3.2. 使用 ScriptEngine 执行简单的 Groovy 脚本

我通过调用 getEngineByName 方法检索 Groovy 脚本引擎。然后,我使用 eval 方法的两种不同的重载:一个接受 String 参数,另一个接受 java.io.Reader 接口的实现。在前一种情况下,提供的字符串需要是实际的脚本代码。对于读取器,我使用一个包装在“Hello, Groovy!”脚本中的 FileReader。输出是每种情况下都可以预期的。
向 Groovy 脚本提供参数
如果 Groovy 脚本接受输入参数并返回数据呢?在 Groovy 脚本编写世界中,这通过 绑定 来处理。在下一节中,当我讨论 GroovyShell 时,我会展示 Groovy API 中实际上有一个名为 Binding 的类,但在这里我将通过 Java API 隐式地执行绑定。
绑定是在某个作用域内的一组变量集合,使得它们在脚本内部可见。在 JSR 223 API 中,ScriptEngine 类本身就是一个绑定。它具有 put 和 get 方法,可以用来向脚本添加变量并从它们中检索结果。
为了说明这一点,让我们做一些不那么平凡、可能更有实际意义的事情。不要做一个简单的“Hello, World!”脚本,考虑 Google 地理编码器,以它的版本 2 形式。
Groovy 甜点
Groovy 脚本是一种轻松尝试新库的方法。
地理编码器是一种将地址转换为经纬度对的应用程序。谷歌已经公开提供地理编码器多年。在本节中,我将使用版本 2,它需要一个密钥(通过免费注册获得),但这也给了我展示一些有趣的 Groovy 特性的机会。当我在本章后面讨论 XML 处理时,我将使用地理编码器的版本 3。该版本不再需要密钥,但它不会以我在这里使用的相同逗号分隔的形式提供结果。
Google 地理编码器版本 2 的文档可以在 mng.bz/Pg8S 找到。版本 2 目前已弃用但仍然有效。我在这里使用它是因为它来自上一章,这样你可以专注于脚本的输入/输出部分,并且因为它还让我展示了多个返回值^([4])。
⁴ 展示版本 2 地理编码器的另一个原因是 Android 的 Google 地图 API 仍然使用它。
为了使用地理编码器,基本思路是将地址作为 HTTP GET 请求中的参数进行传输并处理结果。如 第二章 所示,使用 Google 地理编码器需要以下步骤:
1. 将包含街道、城市和州的列表转换为以“,”分隔的 URL 编码字符串。
2. 将包含地址和传感器的键的映射转换为查询字符串。
3. 将生成的 URL 传输到 Google 地理编码器。
4. 将结果解析为所需的值。
第一步使用 Groovy 的 collect 方法,该方法接受一个闭包作为参数,将闭包应用于集合的每个元素,并返回一个包含结果的新集合。我将结果集合取出来,并将每个元素连接成一个单独的字符串,使用“,”作为分隔符:
String address = [street,city,state].collect {
URLEncoder.encode(it,'UTF-8')
}.join(',')
未声明的变量
街道、城市和州在脚本中没有声明。这会将它们添加到绑定中,使它们对调用者可用。
要构建一个查询字符串,我将所有必需的参数添加到一个名为 params 的映射中。我还请求以逗号分隔的输出值,这在版本 3 的地理编码器中不可用:
def params = [q:address, sensor:false, output:'csv', key:'ABQIAAAAaUT...']
如果此请求来自具有 GPS 功能的设备,则 sensor 的值应为 true,否则为 false。key 在注册时确定(版本 3 不需要密钥)。output 在这里设置为 CSV,因此结果是一个由逗号分隔的值字符串,由响应代码(希望是 200)、放大级别以及纬度和经度组成。
要将映射转换为查询字符串,再次使用 collect 方法。在映射中,如果使用两个参数的闭包应用 collect,方法会自动将键和值分开。我想要的是将 key:value 这样的表达式替换为 key=value 这样的字符串。然后将查询字符串连接到基本 URL 上,得到完整的 URL:
String url = base + params.collect { k,v -> "$k=$v" }.join('&')
最后,我利用 Groovy JDK。在 Groovy JDK 中,String 类包含一个名为 toURL 的方法,它将 String 转换为 java.net.URL 实例。Groovy JDK 中的 URL 类包括一个 getText 方法,我可以作为 text 属性调用它。
属性访问
在 Groovy 中,标准习惯是访问一个属性,它会被自动转换为 getter 或 setter 方法。
获取所需 CSV 字符串的代码是
url.toURL().text
现在,我可以对字符串使用 split 方法,它会在逗号处分割字符串并返回一个包含元素的列表。然后,我可以利用 Groovy 的酷炫多值返回功能,将每个值分配给一个输出变量。
完整的脚本如下,并在图 3.3 中图形化展示:
String address = [street,city,state].collect {
URLEncoder.encode(it,'UTF-8')
}.join(',+')
def params = [q:address,sensor:false,output:'csv',key:'ABQIAAAAaUT...']
String base = 'http://maps.google.com/maps/geo?'
String url = base + params.collect { k,v -> "$k=$v" }.join('&')
(code,level,lat,lng) = url.toURL().text.split(',')
图 3.3. 访问 Google V2 地理编码器的 Groovy 脚本

运行此脚本需要我提供街道、城市和州的信息,然后检索输出纬度和经度。我想使用 Java 提供输入值并处理输出,但首先我会展示一个典型结果,然后可以作为测试用例使用。为了避免过于以美国为中心,我将使用位于英国格林尼治的皇家天文台的地址。这使得 street、city 和 state 的值分别为“Blackheath Avenue”、“Greenwich”和“UK”。^([5]) 执行脚本的结果如下
⁵ 显然,“状态”一词应被广泛解释。提供一个国家名称作为状态,它将在全球范围内工作。
(code,level,lat,lng) = (200,6,51.4752654,0.0014324)
皇家天文台最初是任意选择的子午线位置,因此经度的值应该非常接近零,确实如此。输入地址并不像可能的那样精确,天文台的地址也不再定义实际的子午线,但结果仍然相当令人印象深刻。作为 JUnit 4 测试的一部分,下一个列表显示了生成的测试用例。
列表 3.3. 一个用于检查 JSR 223 脚本引擎结果的 JUnit 测试用例

结果与单独使用 Groovy 运行 Groovy 脚本相同。设置输入变量的值是微不足道的。输出变量需要转换为 String 类型,然后转换为 double,但这个过程同样简单。如果你的目标是完全从 Java 中执行外部 Groovy 脚本(除了将 groovy-all JAR 添加到类路径之外,不引入任何 Groovy 依赖),这个机制工作得很好。
在下一节中,我想放宽这个要求。如果你愿意使用一些来自 Groovy 标准库的类,生活就会变得简单。
3.2.2. 使用 Groovy Eval 类
Groovy 库中有两个特殊类,groovy.util.Eval 和 groovy.lang.GroovyShell,专门设计用于执行脚本。在本节中,我将使用 Eval 类的示例,在下一节中,我将展示 GroovyShell。在每种情况下,目标仍然是调用 Java 中的外部 Groovy 脚本。
Eval 类是一个实用工具类(所有方法都是静态的),用于执行接受零个、一个、两个或三个参数的操作。相关方法在 表 3.1 中显示。
表 3.1. groovy.util.Eval 中用于从 Java 执行 Groovy 的静态方法
| Eval.me | 重载以接受一个字符串表达式或一个包含字符串符号和对象的表达式 |
|---|---|
| Eval.x | 一个参数:x 的值 |
| Eval.xy | 两个参数,x 和 y |
| Eval.xyz | 三个参数,x、y 和 z |
为了展示这些方法,我将在 JUnit 测试用例中添加额外的测试。测试是用 Java 编写的,所以我将自动从 Java 调用 Groovy。
以下列表显示了四个测试,每个测试对应于 Eval 类中的静态方法。
列表 3.4. JUnit 4 测试类验证从 Java 调用 Eval 方法的结果

在每个测试中,要评估的 Groovy 脚本都包含为一个字符串。与 ScriptEngine 不同,没有为 Reader 实例提供重载,因此要执行一个单独文件中的脚本,需要将文件读入一个字符串。这些方法还假设输入变量被命名为 x、y 和 z,这可能要求过多。尽管如此,这个机制的存在本身就很令人感兴趣。
除了说明从 Java 调用 Groovy 脚本的机制外,这些测试还展示了 String 类中的运算符重载。Groovy 中的减号运算符对应于 String 中的 minus 方法。其实现用于从给定字符串中移除其参数的第一个实例,与字符串一起使用以移除子字符串的实例。在 Groovy 中,字符串可以包含在单引号或双引号内。单引号字符串是常规的 Java 字符串,双引号字符串是参数化字符串,礼貌地称为 Groovy 字符串,但正式名称不幸地是 GString.^([6])
⁶ 要更糟糕的是,简单的参数使用美元符号注入到
GString中。这导致了太多的“在 GString 中插入一个$”笑话。对我来说,这是一个明显的证明,我们计算机科学领域女性不足。你不认为如果当时团队中有一个女性,她可能会说,“嘿,那是个有趣的笑话,但让我们不要将其构建成将被所有人永久使用的标准库中吗?”毕竟,在没有这样做的情况下,让 Groovy 语言被《财富》500 强认真对待已经足够困难了。就我而言,我称它们为 Groovy 字符串,这正是这个类本应被称呼的。虽然这是个有趣的笑话,但也就持续了大约 10 分钟。
使用 Java 中的Eval的过程在图 3.4 中展示。
图 3.4. Java 在 Groovy 的Eval类中调用me、x、xy或xyz方法来执行脚本。

Eval类方便且简单,但通常过于简单。它建立在更强大的基础之上,即GroovyShell类,我将在下一节中讨论它。
3.2.3. 使用GroovyShell类
GroovyShell类用于那些不受上一节中描述的Eval特殊情况的限制的脚本。groovy.lang.GroovyShell类可以用来执行脚本,尤其是当与Binding结合使用时。
与Eval类不同,GroovyShell类不仅包含静态方法。在调用其evaluate方法之前,需要先实例化它。作为一个简单的例子,可以考虑向之前的测试用例集中添加以下测试:
@Test
public void testEvaluateString() {
GroovyShell shell = new GroovyShell();
Object result = shell.evaluate("3+4");
assertEquals(7, result);
}
evaluate方法重载很多。我在这里使用的是接受一个表示要评估脚本的字符串的版本。其他重载接受一个java.io.File或java.io.Reader实例,以及各种附加参数。还有一些重载接受java.io.InputStream作为参数,但由于可能的编码问题,它们已被弃用。
到目前为止,使用GroovyShell看起来很像使用ScriptEngine类,尽管在这种情况下可以直接实例化它。然而,为了处理输入和输出变量,GroovyShell使用groovy.lang.Binding类提供一个输入和输出变量的映射。
下一个列表展示了Binding和GroovyShell类的实际应用。这是要添加到不断增长的 JUnit 4 测试用例集中的另一个测试。
列表 3.5. 使用GroovyShell和Binding调用 Google 地理编码器

使用Binding上的setVariable方法将参数传递到脚本中很容易。然后,绑定被用作GroovyShell构造函数的参数。脚本通常使用evaluate方法从 Java 中运行,并通过从 shell 中获取输出变量来提取结果。使用GroovyShell和Binding的示例在图 3.5 中展示。
图 3.5. Java 代码在Binding中设置变量,该变量用于GroovyShell执行 Groovy 代码。结果通过Binding中的getVariable方法返回。

GroovyShell的功能远不止我这里所展示的。我可以使用parse方法而不是evaluate来解析脚本并检索生成的Script对象的引用。这样,我可以在不每次都需要重新编译的情况下设置绑定变量并重新运行脚本。GroovyShell还可以与类加载器和配置的层次结构一起工作。尽管所有这些都很有趣,但它们并没有真正为集成故事增添很多内容,所以我会将你引荐到 Dierk Koenig 的杰出的Groovy in Action以获取详细信息。
困难方式
使用 Java 中的ScriptEngine类,或者 Groovy 中的Eval和GroovyShell类,如果需要的话,还可以使用Binding,从 Java 中调用 Groovy 脚本。
在ScriptEngine、Eval和GroovyShell类之间,希望你会同意,从 Java 执行 Groovy 脚本有各种方法。总的来说,我仍然称这为“困难的方式”,尽管它并不特别困难,但与简单的方式相比,它非常间接。从现在开始,我将停止尝试维持 Java 代码和 Groovy 代码之间的人工分离。为了取得进展,我只需要将 Groovy 代码放入一个类中。
3.2.4. 从 Java 简单调用 Groovy
到目前为止,我讨论的所有技术——使用 JSR 223 ScriptEngine,或者使用 Groovy API 类Eval和GroovyShell——都运行得很好,但感觉过于复杂。Groovy 应该简化你的生活,所以尽管上一节中展示的所有机制都有效,但对于大多数用例来说,还有更简单的方法。
从 Java 调用 Groovy 的最简单方法是将其放入一个类中并编译它。然后 Java 代码可以实例化该类并正常调用其方法。
简单方式
要从 Java 调用 Groovy,将 Groovy 代码放入一个类中,像往常一样编译它,然后实例化它并像调用 Java 一样调用其方法。
让我们再次回到地理编码器。然而,这次,我将它重构为一个可以被实例化的类,具有可以从外部调用的方法。这个过程在图 3.6 中展示。
图 3.6. 混合 Java 和 Groovy 类。Java 应用程序实例化一个 Location 对象,并向其提供街道、城市和州的信息。它将新的 Location 对象发送到 Groovy 地理编码器,其fillInLatLng方法提供纬度和经度,然后 Java 可以再次检索。

如图所示,Java 应用程序将使用 Location 类来存储所有需要的属性。它将提供 street、city 和 state 字段作为输入参数,但 Location 类还将包括 latitude 和 longitude 字段,这些字段将由 Groovy 地理编码器更新。地理编码器本身将用 Groovy 编写,因为用这种方式编写 RESTful 网络服务客户端代码很容易.^([7])
⁷ 注意这与第二章中使用的
Stadium类的地理编码器非常相似,当时我讨论了 Groovy 棒球应用程序。这里的区别是 CSV 输出,以及我从 Java 中调用 Groovy 实现的情况。
这是新的 Location 类,它可以写成 Java 或 Groovy。这次,为了使代码简单,我将使用 Groovy POGO:
class Location {
String street
String city
String state
double latitude
double longitude
}
Location 类封装了地址信息为字符串,并为使用地理编码器设置的纬度和经度值提供了双精度浮点变量。说到地理编码器,下一个列表显示了将脚本包装成类的修订版。
列表 3.6. 一个用于地理编码的 Groovy 类
class Geocoder {
def base = 'http://maps.google.com/maps/geo?'
void fillInLatLong(Location loc) {
def addressFields = loc.street ?
[loc.street,loc.city,loc.state] : [loc.city,loc.state]
def address = addressFields.collect {
URLEncoder.encode(it,'UTF-8')
}.join(',')
def params = [q:address,sensor:false,
output:'csv',key:'ABQIAAAAa...']
def url = base + params.collect { k,v -> "$k=$v" }.join('&')
def (code,level,lat,lng) = url.toURL().text.split(',')
loc.latitude = lat.toDouble()
loc.longitude = lng.toDouble()
}
}
fillInLatLong 方法接受一个 Location 作为参数。严格来说,我根本不需要为参数声明类型。我可以在方法内部依赖鸭子类型,并小心不要用除了具有街道、城市和州属性的对象之外的其他任何东西调用它。尽管如此,我是在考虑 Location 的前提下构建服务的,所以这样说并无害处。
addressFields 变量使用三元运算符来确定在返回地址组件集合时是否提供了街道。请注意,我在这里依赖于所谓的“Groovy 真实性”,即我无需显式地将 loc.street 与 null 或空字符串进行比较。loc 参数作为街道字段的一部分的任何非空值都将返回 true,因此它将被添加到集合中。
类的其余部分与之前的脚本相同,尽管为了使类更有用,我费尽心思在返回位置之前将字符串结果转换为双精度浮点数。
最后一个问题是值得注意的,它突出了脚本和类之间的重要区别。所有变量,无论是局部变量还是属性,都必须声明。没有未定义的变量,因此也不再需要担心任何绑定。
我如何从 Java 中使用这些类(Geocoder 和 Location)?只需实例化它们,并像往常一样调用方法。在前一节中,我开始将 JUnit 4 测试积累到一个测试类中。这里还有一个要添加到该集合的测试:
@Test
public void testGeocoder() {
Location loc = new Location();
loc.setState("1600 Pennsylvania Avenue");
loc.setCity("Washington");
loc.setState("DC");
Geocoder geocoder = new Geocoder();
geocoder.fillInLatLong(loc);
assertEquals(38.895,loc.getLatitude(),0.001);
assertEquals(-77.037,loc.getLongitude(),0.001);
}
没有什么比这更容易了。我不需要实例化脚本引擎或担心 Groovy 壳或类加载器。只需实例化和填充一个 Location,实例化一个 Geocoder,并调用所需的方法。
从现在起,我将展示的所有示例都将使用简单的方式进行集成。再次强调,这并不是对章节中先前展示的所有技术的价值判断。如果你想要从 Java 调用一个现有的 Groovy 脚本,或者你需要在应用程序中保持 Java 和 Groovy 代码的分离,那么先前的机制仍然有效。然而,像这个脚本一样自由混合类是非常容易的。
在我开始探讨 Groovy 如何帮助 Java 之前,还有一个问题需要解决。到目前为止,本章的目标始终是从 Java 调用 Groovy。那么反方向呢?如何从 Groovy 调用 Java?
3.2.5. 从 Groovy 调用 Java
实际上,这太简单了,几乎不值得作为一个章节。我已经多次展示了它。还记得之前使用 Google V2 地理编码器的例子(为了方便在此重现)?

通过使用库类和各种 Java 方法,集成已经存在。我需要以 URL 编码形式将地址传递给 Google。为此,我将地址的每个元素(街道、城市和州)通过 java.net.URLEncoder 的 encode 方法进行编码。换句话说,Groovy 脚本使用了 Java 库类并调用了其方法之一。
学习到的经验(集成)
1. Groovy 脚本可以使用 Java 的 JSR 223 脚本引擎调用。
2. Groovy 的
Eval类使得调用涉及零、一、二或三个参数的脚本变得简单。3. 使用
GroovyShell和Binding类可以编程设置输入变量,调用脚本,并检索其结果。4. 从 Java 调用 Groovy 最简单的方法是创建一个 Groovy 类,编译它,然后在 Java 中实例化它,并像往常一样调用方法。
Java 和 Groovy 的结合也在 图 3.3 中得到强调,如图所示,原始列表中每个 Java 方法以及每个 Groovy 方法都用箭头表示。
脚本混合了 Java 和 Groovy 的做法几乎适用于任何 Groovy 脚本。Groovy 建立在 Java 库的基础上。正如你将在 4.3 节 中看到的,它增强了这些库,但不需要重新发明轮子。8] Groovy 完全可以使用你提供的任何 Java 类,并且使许多类变得更好。
⁸ 重新发明轮子却做错了,这就是重新发明平轮胎的情况。
使用 groovyc 编译
每当你混合使用 Java 和 Groovy 时,请使用 groovyc 编译所有内容。让 groovyc 处理所有跨编译器问题。
在下一章中,我将探讨 Groovy 如何改进 Java 的几种方法。
不要将 Groovy 和 Java 类分开
使用两种不同的语言时,自然的倾向是将两个代码库分开,并独立编译它们。使用 Groovy 和 Java 可能会导致各种问题,尤其是在涉及循环依赖时(换句话说,Java 类A使用 Groovy 类B,它又调用了 Java 类A的另一个方法,依此类推)。特别是 Maven 项目会引导你走这条路,因为它们的默认布局自然建议将 Java 代码放在src/main/java下,将 Groovy 代码放在src/main/groovy下。然后的想法是使用javac编译 Java 代码,使用groovyc编译 Groovy 代码。
尽管你可能能让它工作,但这会让生活变得比必要的更困难。Groovy 的开发者多年来一直在努力解决交叉编译的问题。对我们这些两种语言的使用者来说,利用他们的进步会更好。
在同一个项目中编译 Groovy 和 Java 的最简单方法是由groovyc编译器处理这两个代码库。Groovy 对 Java 了如指掌,并且完全能够处理它。你通常发送给javac的任何编译器标志在groovyc中也同样适用。这实际上是一个好的通用原则。
在本书的项目中,我会让groovyc做所有的工作。我会在第五章中展示具体的例子,但你可以安全地假设我会在整个过程中使用groovyc。
3.3. 摘要
本章是关于基本的 Groovy/Java 集成,无论使用案例如何。在回顾了从 JSR-223 Script-Engine到 Groovy 中的GroovyShell和Eval类等各种从 Java 调用 Groovy 的不同方法之后,我转向了简单的方法,即将 Groovy 放入一个类中,像使用任何其他库类一样使用它。这种简单的 Java 和 Groovy 混合将从现在开始使用。
接下来,我回顾了许多 Groovy 在基本级别帮助 Java 的方法,从 POJO 增强到 AST 转换到构建 XML 等。我将在未来的章节中使用这些技术,只要它们有帮助。我还会在途中回顾其他有用的技术,尽管这些是大多数主要的技术。
第四章. 在 Java 中使用 Groovy 特性
本章涵盖
-
基本的代码级别简化
-
有用的 AST 转换
-
XML 处理
在第一章中,我回顾了许多 Java 的争议性弱点和缺点,并提出了 Groovy 可能有助于改善它们的方法。因为那一章旨在作为介绍,所以我只建议 Groovy 如何帮助,而没有展示很多代码示例。现在,既然我已经建立了将 Groovy 类添加到 Java 应用程序中的简便性,那么何时这样做是有帮助的呢?Groovy 为 Java 系统带来了哪些特性,使得它们更容易开发?
本章涵盖的技术指南如图 4.1 所示。[我将回顾几个 Groovy 的优势,如 POGs、操作符重载、Groovy JDK、AST 转换,以及如何使用 Groovy 处理 XML 和 JSON 数据。首先,我会展示从 Groovy 代码中,POJOs 可以被视为 POGs。]
图 4.1. 可添加到 Java 类中的 Groovy 特性

4.1. 将 POJOs 视为 POGs
POGOs 比 POJOs 具有更多功能。例如,所有 POGs 都有一个基于映射的构造函数,这对于设置属性非常方便。有趣的是,即使一个类是用 Java 编写的,只要从 Groovy 访问,许多便利性仍然适用。
考虑一个简单的 POJO,代表一个人,可能是在 Java 的领域模型中创建的,如下一列表所示。为了保持简单,我只包括 ID 和名称。我还会添加一个toString重写,但不会包括不可避免的equals和hashCode重写。
列表 4.1. 代表一个人的简单 POJO
public class Person {
private int id;
private String name;
public Person() {}
public Person(int id, String name) {
this.id = id;
this.name = name;
}
public void setId(int id) { this.id = id; }
public int getId() { return id; }
public void setName(String name) { this.name = name; }
public String getName() { return name; }
@Override
public String toString() {
return "Person [id=" + id + ", name=" + name + "]";
}
}
任何典型的 Java 持久层都有数十个这样的类,它们映射到关系数据库表(图 4.2)。
图 4.2. Groovy 为 Java 类添加了基于映射的构造函数,无论已经包含哪些构造函数。

如果我从 Groovy 中实例化这个类,我可以使用基于映射的构造函数([1])来这样做,尽管 Java 版本已经指定了两个构造函数,而且都不是我想要的。以下 Groovy 脚本使用三种不同的机制创建了一些Person实例,这些机制在 Java 类中都没有出现:
¹术语基于映射指的是使用 Groovy 映射中使用的键值符号设置属性。构造函数实际上并没有使用映射来完成其工作。
def buffy = new Person(name:'Buffy')
assert buffy.id == 0
assert buffy.name == 'Buffy'
def faith = new Person(name:'Faith',id:1)
assert faith.id == 1
assert faith.name == 'Faith'
def willow = [name:'Willow',id:2] as Person
assert willow.getId() == 2
assert willow.getName() == 'Willow'
buffy和faith实例是使用基于映射的构造函数创建的,首先只设置name,然后设置name和id。然后我能够使用 Groovy 的内置assert方法(省略其可选的括号)验证人的属性是否设置正确。
顺便提一下,所有看似直接访问类私有属性的assert语句实际上并不是。当看起来正在访问或分配属性时,Groovy 会通过 Java 类中提供的 getter 和 setter 方法进行操作。我可以通过修改 getter 方法的实现来证明这一点,使其返回不仅仅是名称:
public String getName() {
return "from getter: " + name;
}
现在,我必须修改每个断言,包括字符串"from getter:",以便它们仍然返回 true。
第三个人willow是使用 Groovy 中的as运算符构建的。这个运算符有几个用途,其中之一是将映射强制转换为对象,如这里所示。在这种情况下,运算符实例化一个人,并将映射作为属性提供给结果实例。
接下来,我还可以将人实例添加到 Groovy 集合中,这并不令人惊讶,但有一些额外的优点。例如,Groovy 集合支持运算符重载,这使得添加额外的个人和具有额外搜索方法变得容易:
def slayers = [buffy, faith]
assert ['Buffy','Faith'] == slayers*.name
assert slayers.class == java.util.ArrayList
def characters = slayers + willow
assert ['Buffy','Faith','Willow'] == characters*.name
def doubles = characters.findAll { it.name =~ /*([a-z])\1*/ }
assert ['Buffy','Willow'] == doubles*.name
Groovy 有一个用于集合的本地语法,这简化了 Java 代码。将引用放在方括号内创建了一个java.util.ArrayList类的实例,并将每个元素添加到集合中。然后,在assert语句中,我使用了所谓的“扩展点”运算符来从每个实例中提取name属性,并返回一个结果列表(换句话说,扩展点运算符的行为与collect相同)。顺便说一句,我将getName方法恢复到其原始形式,它只返回属性值。
我能够使用运算符重载将willow添加到slayers集合中,从而得到characters集合。最后,我利用了在 Groovy 中,java.util.Collection接口已经被扩展,具有一个findAll方法,该方法返回所有匹配提供的闭包中条件的集合实例。在这种情况下,闭包包含一个匹配任何重复小写字母的正则表达式。
许多现有的 Java 应用程序拥有广泛的领域模型。正如你所看到的,Groovy 代码可以直接与它们一起工作,甚至可以将它们视为 POGOs,并为你提供一种简陋的搜索能力。
现在来展示 Groovy 可以添加到 Java 中,而 Java 甚至不支持的功能:运算符重载。
4.2. 在 Java 中实现运算符重载
到目前为止,我已经使用了这样一个事实:在String类中,+和-运算符都被重载了。String中的重载+运算符对于 Java 开发者来说应该是熟悉的,因为它是 Java 中唯一的重载运算符;它用于字符串的连接和数值的加法。然而,Java 开发者并不能随意重载运算符。
在 Groovy 中情况不同。在 Groovy 中,所有运算符都由方法表示,就像plus方法用于+运算符或minus方法用于减法运算符一样。你可以通过在 Groovy 类中实现适当的方法来重载任何运算符。然而,不一定明显的是,你还可以在 Java 类中实现正确的方法,如果该类的实例在 Groovy 代码中使用,运算符也会在那里工作(参见图 4.3)。
² 顺便提一下,以这种方式改变运算符的行为通常被称为运算符重载,因为同一个运算符在不同的类中有不同的行为。然而,可以说,我实际上做的是运算符重写。实际上,在这里它们是同一件事,所以我会交替使用这些术语。
图 4.3. Groovy 运算符被实现为方法,所以如果 Java 类包含正确的方法,Groovy 脚本就可以在其实例上使用相关的运算符。

为了演示这一点,我将创建一个 Java 类,它封装一个映射。Department包含一组Employee实例,并将有一个hire方法来添加它们,以及一个layOff方法来移除它们(希望不会太频繁)。我将通过三个方法实现运算符重载:plus、minus和leftShift。直观地,plus将添加一个新员工,minus将移除现有员工,而leftShift将是一种添加的替代方式。所有三个方法都将允许链式调用,这意味着它们将返回修改后的Department实例。
这里是Employee类,它只是另一个名字的Person POJO:
public class Employee {
private int id;
private String name;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public int getId() { return id; }
public void setId(int id) { this.id = id; }
}
现在是Department类,如下所示,它在一个以员工id值为键的Map中维护员工集合。
列表 4.2. 一个包含Employees映射和运算符重载的Department

顺便说一下,请注意,plus方法不是添加两个Department实例;相反,它将一个Employee添加到Department中。Groovy 只关心方法名来执行运算符.^([3])
³ 作为 Groovy JDK 的一个例子,
java.util.Date类有一个plus方法,它接受一个表示天数的整数。还可以参考Collection中的multiply方法,它也接受一个整数。
为了测试这一点,我将使用 Spock 测试框架。正如第一章中所述,我将展示测试,而不会过多地详细介绍 Spock 框架本身,这将在第六章中处理。幸运的是,即使不了解细节,Spock 测试也很容易阅读。下一个列表显示了一个专注于运算符方法的 Spock 测试。
列表 4.3. 检查 Java 类中运算符重载方法的 Spock 测试
class DepartmentTest extends Specification {
private Department dept;
def setup() { dept = new Department(name:'IT') }
def "add employee to dept should increase total by 1"() {
given: Employee fred = new Employee(name:'Fred',id:1)
when: dept = dept + fred
then:
dept.employees.size() == old(dept.employees.size()) + 1
}
def "add two employees via chained plus"() {
given:
Employee fred = new Employee(name:'Fred',id:1)
Employee barney = new Employee(name:'Barney',id:2)
when:
dept = dept + fred + barney
then:
dept.employees.size() == 2
}
def "subtract emp from dept should decrease by 1"() {
given:
Employee fred = new Employee(name:'Fred',id:1)
dept.hire fred
when:
dept = dept - fred
then:
dept.employees.size() == old(dept.employees.size()) - 1
}
def "remove two employees via chained minus"() {
given:
Employee fred = new Employee(name:'Fred',id:1)
Employee barney = new Employee(name:'Barney',id:2)
dept.hire fred; dept.hire barney
when: dept = dept - fred - barney
then: dept.employees.size() == 0
}
def "left shift should increase employee total by 1"() {
given:
Employee fred = new Employee(name:'Fred',id:1)
when:
dept = dept << fred
then:
dept.employees.size() == old(dept.employees.size()) + 1
}
def "add two employees via chained left shift"() {
given:
Employee fred = new Employee(name:'Fred',id:1)
Employee barney = new Employee(name:'Barney',id:2)
when:
dept = dept << fred << barney
then:
dept.employees.size() == 2
}
}
Spock 测试是用 Groovy 编写的,因此我可以使用+、-和<<,并且知道将使用相关的方法,即使它们是在 Java 类中实现的。
Groovy 中可以重载的运算符列表包括plus、minus和leftShift,如列表所示,以及许多其他运算符。你可以通过实现getAt等来通过索引实现类似数组的访问。通过next和previous方法分别实现前置和后置递增。通过compareTo实现关系运算符<=>。甚至可以重载点运算符,信不信由你。酷的地方在于,你可以在 POJOs 或 POGs 中实现这些方法,Groovy 会利用它们。
Groovy 的下一个简化 Java 的功能是我已经多次利用过的:Groovy JDK。
4.3. 使 Java 库类更好的方法:Groovy JDK
每个 Groovy 类都包含一个元类。除了提供有关类的信息外,元类还包含在通过实例访问不存在的方法或属性时发挥作用的方法。通过拦截那些方法或属性“缺失”失败,开发者可以提供他们想要的任何内容。
这种应用之一是 Groovy 向现有类添加方法。当你想要向无法更改源代码的类添加方法时,这特别有用。如前所述,Groovy 广泛使用了现有的 Java 标准库。然而,它并不是简单地使用它们。在许多情况下,已经向 Java 库添加了一系列新方法,以便使它们更容易、更强大。
一起,这些增强的 Java 库集合被称为 Groovy JDK。Groovy 有两套 Javadoc 文档。一个是 Groovy API,其中包含有关包含的 Groovy 库的信息。另一个是 Groovy JDK,它只显示已添加到标准 Java 库中的方法和属性,以便,正如俗话所说,使它们更加“Groovy”(见图 4.4)。
图 4.4. Groovy 向 Java 标准库中的类添加便利方法。

例如,Groovy 向java.util.Collection接口添加了许多方法,包括collect、count、find、findAll、leftShift、max、min、sort和sum。这些方法随后在任何 Groovy 集合中都是可用的,无论它们是否包含 Java 或 Groovy 的对象。
尽管我已经在集合上花费了不少时间,但我在书中会经常回顾它们。因此,为了从一个不同的 Java 类中选择一个例子,让我们说明为什么在 HTTP 上使用基本身份验证是个糟糕的主意。
在基本身份验证中,用户名和密码以编码形式发送到服务器。基本身份验证将用户名和密码通过冒号连接起来,对生成的字符串进行 Base 64 编码,并将结果作为认证的 HTTP 请求头的一部分发送。
然而,编码和解密之间有很大的区别。编码后的字符串同样可以被解码。Groovy 使得演示这一点变得容易,因为 Groovy JDK 为字节数组添加了一个名为encodeBase64的方法。它还向String添加了一个decodeBase64方法。下面的列表演示了这两个方法。
列表 4.4. 基于 Base 64 编码和解码用户名/密码信息

在这个简短的脚本中有很多事情在进行。首先,用户名和密码被组合成一个 Groovy 字符串。然后对组合字符串调用 getBytes 方法,使用默认字符编码将字符串编码成一系列字节。这个方法来自 Java。结果是字节数组。检查 Groovy JDK,你会发现 Groovy 为 byte[] 添加了 encodeBase64 方法,它返回一个 groovy.lang.Writable 实例。在这里,我只是使用它的 toString 方法(当然,来自 Java,尽管在 Groovy 类中被覆盖)来查看结果值。实际上,我在一个链式方法调用中从 Java 到 Groovy 再到 Java。
要进行相反的操作,首先我使用 Groovy 添加到 java.lang.String 的 decodeBase64 方法,它再次返回一个 byte[]。然后 String 有一个构造函数,它接受一个字节数组,我使用 Java 的 split 方法再次将用户名和密码分开,并验证它们在转换过程中没有被修改。
除了展示 Groovy JDK 如何向标准 Java 数据类型添加新方法之外,这个例子还表明编码文本并没有加密。任何拦截请求并访问编码头的人都可以提取用户名和密码。因此,如果请求是通过未加密的连接(如 HTTP)传输的,使用基本身份验证就完全不安全。至少,请求应该通过 HTTPS 传输.^([4])
⁴ 几年来,Twitter 作为其 RESTful API 的一部分支持基本身份验证。希望所有使用它的许多 Twitter 客户端都通过安全套接字传输了它们的身份验证。如果没有,你可能需要考虑更改你的密码。如今,Twitter 已经切换到 OAuth,这可能过于复杂,但比基本身份验证要好得多。
Groovy JDK 中有很多有用的方法。作为另一个例子,日期操作在 Java 中总是很痛苦.^([5]) Groovy 并不一定解决了许多问题,但 Groovy JDK 为与日期相关的类添加了几个方法,使它们更强大。以下是一个示例,希望对一些读者来说既有趣又至少有点乐趣。
⁵ Java 8 终于要解决这个问题了。在此期间,Java 世界中首选的开源日期/时间库是 Joda 时间:
joda-time.sourceforge.net/.
在美国和加拿大,2 月 2 日被称为土拨鼠日。在土拨鼠日,土拨鼠会从它的洞里出来寻找它的影子。如果它看不到影子,它就会待在洞里,冬天就快结束了。如果它看到了自己的影子,它就会回到洞里睡觉,我们就不幸地要再忍受六个星期的冬天。
让我们检查一下下一个列表中的数学,如图所示。
列表 4.5. 土拨鼠日——Groovy JDK 中 Date 和 Calendar 的一个示例

我通过访问其 instance 属性来获取 Calendar 类的实例。当然,Calendar 中没有 instance 属性,但这个语法实际上意味着我使用无参数调用静态的 getInstance 方法。然后我使用适合土拨鼠日和春天第一天的适当参数调用 set。从 Calendar 中提取 Date 实例是通过 getTime 方法完成的( sigh^([6])),这同样是通过访问 time 属性来调用的。到目前为止,这完全是 Java,除了我通过属性调用方法并省略了可选的括号。
⁶ 真的,就不能使用
getDate方法从一个Calendar中提取一个Date吗?
然而,我可以进行日期相减,因为 Groovy JDK 显示 Date 中的 minus 方法返回它们之间的天数。Date 类有一个 next 方法和一个 previous 方法,并实现了 compareTo。这些都是一个类作为范围的一部分使用所必需的要求,因此我可以通过在范围上调用 size 方法来检查数学。范围的尺寸计算两端,所以我必须通过减去一来纠正潜在的偏移量错误。
重要的是,从土拨鼠日到春天的第一天(3 月 20 日)之间有六个星期和四天。换句话说,如果土拨鼠看到了自己的影子,接下来的六个星期冬天的结果实际上是一个(稍微)早春。^([7])
⁷ 是的,为了一个玩笑走这么远的路,但这确实清楚地展示了 Java 和 Groovy 的混合使用,利用了 Groovy JDK 方法和运算符重载。这个笑话只是一个额外的好处。
最后,还应注意的是一个便利之处。在 Java 中,数组有 length 属性,字符串有 length 方法,集合有 size 方法,NodeLists 有 getLength 方法,等等。在 Groovy 中,你可以对它们中的任何一个调用 size 来获得适当的行为。在这种情况下,Groovy JDK 已被用来纠正 Java 中的历史不一致性。
Groovy JDK 中充满了有用的方法。即使你的应用程序计划只使用 Java 库类,我也鼓励你检查 Groovy JDK,看看是否有可能的简化或增强。
我提到了运行时元编程,这是通过元类完成的。然而,Groovy 中更有趣的一个特性是通过 AST 转换进行的编译时元编程,这是下一节的主题。
4.4. 令人印象深刻的 AST 转换
Groovy 1.6 引入了抽象语法树(AST)转换。其思路是在 Groovy 类上放置注解并调用编译器,编译器会像往常一样构建一个语法树,然后以有趣的方式修改它。编写 AST 转换是通过各种构建器类完成的,但这不是我的主要关注点。相反,我想展示一些 Groovy 标准库中提供的 AST 转换,并证明它们也可以应用于 Java 类。
4.4.1. 将代理委托给包含对象
让我们从代理开始。当前的设计原则倾向于优先考虑代理而不是继承,认为继承耦合度太高。不是通过扩展一个类来支持所有其方法,而是使用代理,你将一个类的实例包裹在另一个类中。然后你在外部类中实现所有包含类提供的方法,并将每个调用委托给包含对象上的相应方法。这样,你的类具有与包含对象相同的接口,但除此之外与它没有其他关系。
写所有这些“透传”方法可能会很痛苦。Groovy 引入了 @Delegate 注解来为你处理所有这些工作。
手机变得越来越强大,以至于“手机”这个词现在有点名不副实。当前一代的“智能手机”包括相机、浏览器、联系人管理器、日历等等。8 如果你已经为所有组件开发了类,那么你可以通过代理来构建智能手机。有趣的部分是,组件类可以是 Java,容器可以是 Groovy。
⁸ 这里有一个归功于 C++ 发明者 Bjarne Stroustrup 的好引用:“我一直希望我的电脑像我的电话一样容易使用;我的愿望实现了,因为我现在再也想不出如何使用我的电话了。”
考虑一个简单的 Java Camera 类:
public class Camera {
public String takePicture() {
return "taking picture";
}
}
这里还有一个 Phone 类,用 Java 编写。
public class Phone {
public String dial(String number) {
return "dialing " + number;
}
}
现在来看看 Groovy 中的一个 SmartPhone 类,它使用 @Delegate 注解来通过 SmartPhone 类公开组件方法(见图 4.5):
class SmartPhone {
@Delegate Camera camera = new Camera()
@Delegate Phone phone = new Phone()
}
图 4.5. @Delegate AST 转换通过组合对象公开了所有代理中的方法。这个转换只在 Groovy 类中起作用,但代理本身可以是 Groovy、Java 或两者都是。

一个 JUnit 测试(这次是用 Groovy 编写的)演示了下一个列表中的代理方法。
列表 4.6. Groovy 中的一个 JUnit 测试,用于演示代理方法
class SmartPhoneTest {
SmartPhone sp = new SmartPhone()
@Test
void testPhone() {
assert 'dialing 555-1234' == sp.dial('555-1234')
}
@Test
void testCamera() {
assert 'taking picture' == sp.takePicture()
}
}
简单地添加所需的任何组件,@Delegate 注解将通过 SmartPhone 类公开它们的方法。我也可以添加所需的智能手机特定方法。@Delegate 注解使得包括功能变得容易,组件本身可以是 Java 或 Groovy,哪个更方便都可以。唯一的要求是 SmartPhone 类本身必须用 Groovy 编写,因为只有 Groovy 编译器理解 AST 转换。
我将在附录 C 中提供一个关于基于 SOAP 的 Web 服务的 @Delegate 的实际例子,但现在是时候继续创建不可变对象了。
4.4.2. 创建不可变对象
随着多核机器的兴起,处理并发性良好的程序变得越来越重要。处理操作的一种线程安全机制是尽可能使用不可变对象来共享信息。
与 C++不同,Java 没有内置的方式使对象无法修改。Java 中没有“const”关键字,将static和final组合应用于引用仅使引用成为常量,而不是它引用的对象。在 Java 中使对象不可变的唯一方法是移除所有改变它的方法。
这实际上比听起来要困难得多。移除所有 setter 方法是一个好的开始,但还有其他要求。使一个类支持不可变性需要
-
所有可变方法(setter)都必须移除。
-
类应该被标记为
final。 -
任何包含的字段都应该是
private和final。 -
可变组件(如数组)应该在输入(通过构造函数)和输出(通过 getter)时进行防御性复制。
-
equals、hashCode和toString都应该通过字段实现。
这听起来像是一项工作。幸运的是,Groovy 有一个@``Immutable AST 转换,它可以为你做所有事情(见图 4.6)。
图 4.6. @``Immutable AST 转换产生了一个不可变对象,该对象可以在 Java 和 Groovy 客户端中使用。

@``Immutable转换只能应用于 Groovy 类,但那些类可以用于 Java 应用程序。我将首先展示@``Immutable注解的工作方式和其局限性,然后在一个 Java 类中使用不可变对象。
这是一个不可变点类。它包含两个字段,x和y,它们代表点在二维空间中的位置:
@Immutable
class ImmutablePoint {
double x
double y
String toString() { "($x,$y)" }
}
@``Immutable注解应用于类本身。它仍然允许通过构造函数设置属性,但一旦设置,属性就再也不能修改。下一个列表展示了 Spock 测试来演示这一点。
列表 4.7. 测试ImmutablePoint类

在测试中,通过指定构造函数参数x和y的值来实例化ImmutablePoint类。这是必要的,因为没有可用的设置方法。我可以通过常规动态生成的 get 方法访问属性,但如果我尝试修改一个属性,尝试将抛出ReadOnlyPropertyException。
@``Immutable注解非常强大,但它也有局限性。你只能将其应用于包含原始数据类型或某些库类(如String或Date)的类。它也适用于包含也是不可变属性的类。例如,这里有一个ImmutableLine类,它包含两个ImmutablePoint实例:
@Immutable
class ImmutableLine {
ImmutablePoint start
ImmutablePoint end
def getLength() {
double dx = end.x - start.x
double dy = end.y - start.y
return Math.sqrt(dx*dx + dy*dy)
}
String toString() { "from $start to $end" }
}
start和end字段都是ImmutablePoint类型。我添加了一个方法来返回一个依赖的length属性,它使用通常的方式通过勾股定理计算。这意味着我可以访问ImmutableLine的length属性,访问将通过getLength`方法进行,但由于没有设置器,所以我不能从外部更改该值。这个类的对应测试如下所示。
列表 4.8. ImmutableLine类的 Spock 测试

为了创建一个ImmutableLine,我需要首先创建一对ImmutablePoint实例,这些实例可以用在ImmutableLine构造函数中。第一个测试检查包含的点是否被正确设置,然后通过访问length“字段”来检查getLength实现。最后,我确保不能重新分配线的start或end属性。
将这一步进一步,如果类中包含一个集合会发生什么?@Immutable注解将导致该集合被其不可修改的替代品之一包装。例如,假设路径是一系列线的集合,所以这里是ImmutablePath的定义:
@Immutable
class ImmutablePath {
List<ImmutableLine> segments = []
}
这次我无法仅使用def声明 segments 变量。如果我想让@Immutable注解工作,我需要指定我正在使用某种类型的集合。在segments定义的右侧,我仍然只有[],这通常意味着一个java.util.ArrayList的实例。然而,实际上我得到的是java.util.Collections$UnmodifiableRandomAccessList,信不信由你。Collections类有像unmodifiableList这样的实用方法,它接受一个常规列表并返回一个新的不可变列表,但说实话,我并不一定期望在这种情况下它是一个RandomAccessList。当然,只要契约得到维护,实际的类是什么并不重要。
说到这个契约,Collections中的不可修改方法并没有移除可用的修改器方法。相反,它们将它们包装起来,如果访问它们,则抛出UnsupportedOperationException。这可以说是实现接口的一种奇怪方式,但就是这样。这个类的 Spock 测试如下所示。构建所有必要的不可变对象以创建ImmutablePath实例需要一些工作,但一旦设置好,一切都会正常工作。
列表 4.9. ImmutablePath类的 Spock 测试
class ImmutablePathTest extends Specification {
ImmutablePath path
def setup() {
def lines = []
ImmutablePoint p1 = new ImmutablePoint(x:0,y:0)
ImmutablePoint p2 = new ImmutablePoint(x:3,y:0)
ImmutablePoint p3 = new ImmutablePoint(x:0,y:4)
lines << new ImmutableLine(start:p1,end:p2)
lines << new ImmutableLine(start:p2,end:p3)
lines << new ImmutableLine(start:p3,end:p1)
path = new ImmutablePath(segments:lines)
}
def "points should be set through ctor"() {
expect:
path.segments.collect { line -> line.start.x } == [0,3,0]
path.segments.collect { line -> line.start.y } == [0,0,4]
path.segments.collect { line -> line.end.x } == [3,0,0]
path.segments.collect { line -> line.end.y } == [0,4,0]
}
def "cant add new segments"() {
given:
ImmutablePoint a = new ImmutablePoint(x:5,y:5)
ImmutablePoint b = new ImmutablePoint(x:4,y:4)
when:
path.segments << new ImmutableLine(start:a,end:b)
then:
thrown UnsupportedOperationException
}
}
到目前为止,我所展示的关于@``Immutable注解的内容都属于好消息的范畴。然而,现在要说的是坏消息,尽管这并不是那么糟糕。首先,@``Immutable注解,就像许多 AST 转换一样,对集成开发环境(IDEs)造成了破坏。这些转换发生在编译时,这使得 IDEs 很难预测。尽管我到目前为止所做的一切都是合法的并且运行良好,但我的 IDE^([9])仍然不断为此挣扎。到目前为止,IDE 的问题主要很烦人,但修复这些问题确实是一个难题,而且可能不会很快消失。
⁹本章的大部分代码都是使用 Groovy / Grails 工具套件(STS)版本 3.2 编写的。
下一个问题出现在我尝试在 Java 程序中使用我的ImmutablePoint时。我该如何分配x和y的值呢?Groovy 给了我一个基于映射的构造函数,我到目前为止一直在使用,但 Java 看不到这一点。
幸运的是,@``Immutable的开发者预见到了这个问题。转换还生成一个元组构造函数,它按照定义的顺序接受每个属性。在这种情况下,ImmutablePoint类似乎有一个接受表示x和y的双精度浮点数的两个参数的构造函数。
这里有一个 JUnit 4 测试(用 Java 编写的,所以它本身就是一个 Java/Groovy 集成的例子)利用了那个构造函数:
public class ImmutablePointJUnitTest {
private ImmutablePoint p;
@Test
public void testImmutablePoint() {
p = new ImmutablePoint(3,4);
*assertEquals*(3.0, p.getX(), 0.0001);
*assertEquals*(4.0, p.getY(), 0.0001);
}
}
这同样运行得很好。目前,我的 IDE 甚至理解存在两个参数的构造函数,这真的很棒。顺便说一下,我正在使用Assert.assertEquals方法的三个参数版本,因为我正在比较双精度浮点数,而这需要指定一个精度。
由于从 Java 的角度来看,这个类没有可能改变x或y的方法,因此没有必要尝试检查不可变性。与展示的getX和getY方法不同,没有相应的 setter 方法。
正如我说的,这一切都运行得很好,但如果你试图使用生成的构造函数,而你的系统拒绝相信它存在,有一个简单的解决方案。只需在 Groovy 中添加一个可以以常规方式实例化点的工厂类:
class ImmutablePointFactory {
ImmutablePoint newImmutablePoint(xval,yval) {
return new ImmutablePoint(x:xval,y:yval)
}
}
现在,Java 客户端可以实例化ImmutablePointFactory,然后调用newImmutablePoint工厂方法,提供所需的x和y值。
一切都运行得很好,也就是说,直到你屈服于遵循 Java API 中标准实践的诱惑,将工厂类做成单例。这就是下一小节的主题。
4.4.3. 创建单例
当一位新的 Java 开发者首次发现广阔、美妙的设计模式世界时,他们往往会遇到 Singleton。这是一个容易学习的模式,因为它易于实现,并且只涉及一个类。如果您只想有一个类的实例,请将构造函数设为私有,添加一个 static final 类型的实例变量,并添加一个静态获取方法来检索它。这有多酷?
不幸的是,我们可怜的新开发者误入了一个充满怪物、攻击那些不小心的人的广阔丛林。首先,实现一个真正的 Singleton 并不像听起来那么简单。至少,还有线程安全问题需要担心,而且由于似乎没有 Java 程序是完全线程安全的,结果很快就变得很糟糕。
然后,还有这样一个事实,一小部分但非常直言不讳的开发者认为整个 Singleton 设计模式是一个反模式。他们出于各种原因对其进行抨击,并且他们往往对这种模式和任何愚蠢或天真到足以使用它的人都非常严厉。
幸运的是,我并不是来解决这个问题。我的工作是向您展示 Groovy 如何帮助您作为 Java 开发者,我可以在这里做到这一点。根据本节标题的标题,这里有一个名为 @Singleton 的 AST 转换。
要使用它,我只需将注释添加到我的类中。这里我将它添加到了之前的 ImmutablePointFactory:
@Singleton
class ImmutablePointFactory {
ImmutablePoint newImmutablePoint(xval,yval) {
return new ImmutablePoint(x:xval,y:yval)
}
}
再次,我忍不住要说:这很简单。结果是,这个类现在包含一个名为 instance 的静态属性,它自然地包含该类的一个且仅有一个实例。此外,转换的作者以尽可能正确的方式实现了所有内容。¹⁰。在 Groovy 代码中,我现在可以编写以下内容:
[¹⁰] Paul King,Groovy in Action(Manning,2007)的合著者之一,是一位出色的开发者。让我坦白地说:Paul King 写的每一篇东西都是好的。他倾向于将他的演示文稿添加到 SlideShare.net,所以请尽可能快地去阅读它们。
ImmutablePoint p = ImmutablePointFactory.instance.newImmutablePoint(3,4)
这一切都很正常。问题在于当我尝试在 Java 中做同样的事情时,我遇到了问题。再次,编译器理解,但我从未能够说服我的 IDE 相信工厂类中有一个名为 public static 的字段 instance。
尽管如此,注释仍然有效,IDEs 最终会理解如何处理它。实际上,所有酷炫的新 AST 转换都有效,我鼓励您考虑它们是编写应用程序的显著快捷方式。
有其他 AST 转换可用,并且还在不断编写中。我鼓励您密切关注它们,以防出现可以以与刚刚讨论的相同方式简化您的代码的转换。
尽管 AST 转换很酷,但我们的最后一个任务在 Groovy 中比在 Java 中容易得多,这几乎让 Groovy 本身就能吸引 Java 开发者。这个问题是解析和生成 XML。
4.5. 处理 XML
回到 20 世纪 90 年代末,当 XML 还年轻、新鲜且仍然流行(尽管现在看起来很难想象),XML 和 Java 的结合被认为将是非常有成效的。Java 是一种可移植的语言(一次编写,到处运行,对吧?),而 XML 是一种可移植的数据格式。不幸的是,如果你曾经尝试通过 Java 内置的 API 来处理 XML,你就会知道结果远远没有达到预期。为什么 Java 处理 XML 的 API 使用起来如此痛苦?
这里有一个简单的例子。我有一份 XML 格式的书籍列表,如下所示:
<books>
<book isbn="...">
<title>Groovy in Action</title>
<author>Dierk Koenig</author>
<author>Paul King</author>
...
</book>
<book isbn="...">
<title>Grails in Action</title>
<author>Glen Smith</author>
<author>Peter Ledbrook</author>
</book>
<book isbn="...">
<title>Making Java Groovy[11]</title>
<author>Ken Kousen</author>
</book>
</books>
^(11)我必须找到一种方法,让我的书加入那个显赫的行列,仅仅是为了沐浴在反射的荣光中。
现在假设我的任务是打印第二本书的标题。还有什么比这更容易的吗?这是一个基于将数据解析为文档对象模型(DOM)树并找到正确元素的 Java 解决方案:
public class ProcessBooks {
public static void main(String[] args) {
DocumentBuilderFactory factory =
DocumentBuilderFactory.*newInstance*();
Document doc = null;
try {
DocumentBuilder builder = factory.newDocumentBuilder();
doc = builder.parse("src/jag/xml/books.xml");
} catch (ParserConfigurationException e) {
e.printStackTrace();
} catch (SAXException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
if (doc == null) return;
NodeList titles = doc.getElementsByTagName("title");
Element titleNode = (Element) titles.item(1);
String title = titleNode.getFirstChild().getNodeValue();
System.out.println("The second title is " + title);
}
}
这实际上是所需程序的简短版本。为了使其更短,我必须将异常处理折叠为仅捕获Exception,或者向main方法添加throws子句。
许多 Java API 都是围绕一组接口设计的,假设将会有许多不同的替代实现。在 Java API for XML Processing(JAXP)的世界里,有许多解析器可用,因此 API 主要由接口主导。当然,你不能实例化一个接口,因此使用 API 归结为工厂和工厂方法。
因此,为了使用简单的 DOM 解析器解析 XML 文件,我首先需要获取相关的工厂,使用其newInstance方法。然后我使用工厂方法newDocumentBuilder,这确实是一个很好的工厂方法的名字。然后,通过parse方法解析文件,正如预期的那样。在 DOM 解析器内部,树是通过一个 SAX 解析器构建的,这也是为什么我需要准备 SAX 异常。
假设我做到了这一步,那么此时的结果是 DOM 树的引用。通过遍历树来找到我的答案坦白说是不可能的。遍历对空白节点的高度敏感,并且可用的方法(getFirstChild、getNextSibling 等等)并不是直接得到答案的方法。如果谁整理了 XML 文件,并且足够友好地为每个元素分配了一个 ID,我就可以使用伟大的 getElementByID 方法来提取我需要的节点,但很不幸没有这样的运气。相反,我只能通过 getElementsByTagName 收集相关的节点,它不会像你预期的那样返回 Collections 框架中的内容,而是一个 NodeList。NodeList 类有一个 item 方法,它接受一个整数,代表我想要的节点的零基索引,最终我得到了我的标题节点。
然后还有最后的侮辱,那就是节点的值并不是我想要的字符内容。不,我必须检索节点的第一个文本子节点,然后才能得到值,这返回了我需要的文本。
XML 和 Groovy
我曾经教过一门关于 Java 和 XML 的课程,其中一项练习是提取嵌套值。在让学生们通过那个笨拙、丑陋的 Java 解决方案后,教室后面的一位女士举手发言。
“我一直等着你说,‘这是困难的方法,’”她说,“现在这里有简单的方法,但你从未提到简单的方法。”
作为回应,我不得不说,“想看看简单的方法吗?让我们看看这个问题的 Groovy 解决方案。”
def root = new XmlSlurper().parse('books.xml')
println root.book[1].title
这难道不简单吗?我实例化了一个 XmlSlurper,在 XML 文件上调用它的 parse 方法,然后直接走到我想要的值。
如果我需要解析或生成 XML,我总是会添加一个 Groovy 模块来完成这项工作。
让我们看看另一个,相对更实用的例子。还记得第三章(kindle_split_013.html#ch03)中使用的 Google 地理编码器吗?当地理编码器升级到版本 3 时,Google 移除了注册密钥的要求(好事),但也移除了 CSV 输出类型(不幸)。现在可用的输出类型只有 JSON 或 XML。Google 还更改了访问 Web 服务的 URL(实际上在版本化 Web 服务时很典型),将两种可用的输出类型嵌入到新的 URL 中。在第九章(kindle_split_021.html#ch09)关于 RESTful Web 服务中,我将有很多关于输出类型选择(正式称为 内容协商)的讨论,但在这里类型是嵌入到 URL 中的。
从 Java 的角度来看,处理 JSON 输出有点复杂,因为它需要一个外部库来解析 JSON 数据。这并不是太大的负担,因为有几个好的 JSON 库可用,但你仍然需要选择一个并学习如何使用它。我们已经讨论了在 Java 中处理 XML 数据是多么复杂,所以这也不是一个好的替代方案。
然而,Groovy 对 XML 来说却是小菜一碟。让我们看看 Groovy 访问新的地理编码器并提取返回的经纬度数据有多容易。
首先,这是一个从网络服务返回的 XML 输出样本,用于 Google 家办公室的输入地址:
<GeocodeResponse>
<status>OK</status>
<result>
<type>street_address</type>
<formatted_address>1600 Amphitheatre Pkwy, Mountain View, CA 94043, USA</
formatted_address>
...
<geometry>
<location>
<lat>37.4217550</lat>
<lng>-122.0846330</lng>
</location>
...
</geometry>
</result>
</GeocodeResponse>
为了专注于我真正想要的内容,已经从这个响应中省略了很多子元素。经纬度值深藏在输出中。当然,对于 Groovy 来说,挖掘到那个点很容易。以下是一个脚本,它创建所需的 HTTP 请求,将其发送到 Google,并提取响应,所有这些都在不到一打行代码内完成:
String street = '1600 Ampitheatre Parkway'
String city = 'Mountain View'; state = 'CA'
String base = 'http://maps.google.com/maps/api/geocode/xml?'
String url = base + [sensor:false,
address:[street, city, state].collect { v ->
URLEncoder.encode(v,'UTF-8')
}.join(',')].collect {k,v -> "$k=$v"}.join('&')
def response = new XmlSlurper().parse(url)
latitude = response.result[0].geometry.location.lat
longitude = response.result[0].geometry.location.lng
代码与前面展示的版本 2 客户端非常相似,因为我有一个服务的基本 URL(注意,它将响应类型 XML 作为 URL 的一部分包含在内)和一个参数映射,我将它转换为查询字符串。发送请求和解析结果是在一行代码中完成的,因为 XmlSlurper 类有一个接受 URL 的 parse 方法。然后,提取经纬度只是遍历树的一个简单问题。
几次我编写了应用程序,将这个脚本转换为使用 Location 的类,并将其添加为服务。与相应的 Java 版本相比,代码节省的量实在太大,不容忽视。
解析是一回事,但生成呢?为此,Groovy 提供了一个名为 groovy.xml.MarkupBuilder 的构建器类。
考虑另一个表示 Song 的 POJO,如下所示:
public class Song {
private int id;
private String title;
private String artist;
private String year;
public Song() {}
public Song(int id, String title, String artist, String year) {
this.id = id;
this.title = title;
this.artist = artist;
this.year = year;
}
public int getId() { return id; }
public void setId(int id) { this.id = id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getArtist() { return artist; }
public void setArtist(String artist) { this.artist = artist; }
public String getYear() { return year; }
public void setYear(String year) { this.year = year; }
}
实现 Song 类的 Java 包含一个 id 和 title、artist、year 的字符串。其余的只是构造函数、获取器和设置器。在实际系统中,这个类可能还会覆盖 toString、equals 和 hashCode 方法,但在这里我不需要这些。
Song 实例应该如何在 XML 中表示?一个简单的想法是将 ID 作为歌曲的属性处理,并将 title、artist 和 year 作为子元素。在下面的列表中,我展示了部分 Groovy 类,该类将 Song 实例转换为 XML 并反向转换。
列表 4.10. 将歌曲转换为 XML 并反向转换


SongXMLConverter 类有四个方法:一个用于将单个歌曲转换为 XML,一个用于将 XML 转换为单个歌曲,以及两个用于将歌曲集合转换为 XML。从 XML 转换为 Song 实例是通过前面展示的 XmlSlurper 完成的。唯一的新部分是,slurper 使用 @id 表示法访问歌曲 ID 值,其中 @ 用于检索属性。图 4.7 展示了 XmlSlurper 或其类似类 XmlParser 的任务。
图 4.7. 使用 XmlSlurper 或 XmlParser 从 XML 数据填充对象

从歌曲到 XML 的反向转换使用 MarkupBuilder 完成。MarkupBuilder 类默认写入标准输出。在这个类中,我想返回一个字符串形式的 XML,因此我使用了带有一个 java.io.Writer 参数的重载 MarkupBuilder 构造函数。我向构造函数提供了一个 StringWriter,构建 XML,然后使用正常的 toString 方法将输出转换为字符串。
一旦我有了 MarkupBuilder,我就将歌曲的属性写出来,就像我正在构建 XML 一样。让我们专注于将单个歌曲转换为 XML 格式的转换,如下所示:
MarkupBuilder builder = new MarkupBuilder(sw)
builder.song(id:s.id) {
title s.title
artist s.artist
year s.year
}
MarkupBuilder 的作用在图 4.8 中展示。
图 4.8. 使用 groovy.xml.MarkupBuilder 生成对象的 XML 表示形式

这是一个 Groovy 元编程能力的示例,尽管一开始看起来并不像。想法是,在构建器内部,每当我写一个不存在的函数名时,构建器将其解释为创建 XML 元素的指令。例如,我通过传递一个键为 id、值为歌曲 ID 的映射作为参数,在构建器上调用 song 方法。当然,构建器没有 song 方法,因此它将方法调用解释为构建一个名为 song 的元素的命令,并将参数解释为向 song 元素添加一个 id 属性的指令,其值为歌曲 ID。然后,当它遇到花括号时,将其解释为开始子元素的指令。
我还有三个方法调用:一个用于 title,一个用于 artist,一个用于 year。在这种情况下,括号的缺失可能会造成误导,但实际上每个都是方法调用。再次强调,构建器将每个不存在的解释为创建 XML 元素的命令。这次,由于它们不是以映射形式存在,因此它们成为包含在元素中的字符数据。构建器处理的结果是下面的 XML:
<song id="...">
<title>...</title>
<artist>...</artist>
<year>...</year>
</song>
将歌曲列表转换为更大的 XML 文件的方法对每首歌曲都做了同样的事情。
XML 学习经验
-
Groovy 的
XmlParser和XmlSlurper使得解析 XML 变得非常简单,可以通过遍历生成的 DOM 树来提取值。 -
使用
MarkupBuilder生成 XML 一样简单。
Groovy Sweet Spot
Groovy 在解析和生成 XML 方面非常出色。如果你的 Java 应用程序与 XML 一起工作,强烈考虑委托给 Groovy 模块。
4.6. 处理 JSON 数据
Groovy 处理 JSON 数据与处理 XML 一样容易。为了结束本章,让我给出一个来自网络服务的 JSON 响应数据的简单示例。
该服务被称为 ICNDB:互联网 Chuck Norris 数据库。它位于 icndb.com,并提供 RESTful API 以检索相关的笑话。如果您向 http://api.icndb.com/jokes/random?limitTo=[nerdy] 发送 HTTP GET 请求,您将收到一个 JSON 格式的字符串。
Groovy 使得发送 GET 请求变得简单。在 Groovy JDK 中,String 类有一个 toURL 方法,它将其转换为 java.net.URL 的实例。然后 Groovy JDK 向 URL 类添加了一个名为 getText 的方法。因此,访问网络服务就像这样
String url = 'http://api.icndb.com/jokes/random?limitTo=[nerdy]'
String jsonTxt = url.toURL().text
println jsonTxt
执行此操作返回一个类似以下格式的 JSON 对象
{ "type": "success", "value": { "id": 563, "joke": "Chuck Norris causes the
Windows Blue Screen of Death.", "categories": ["nerdy"] } }
在本书中,我已经使用过的所有 Google 地理编码演示中,我介绍了 XmlSlurper 类,其 parse 方法接受字符串形式的 URL 并自动将结果转换为 DOM 树。从版本 1.8 开始,Groovy 也包括了一个 JsonSlurper,但它 parse 方法的重载比 XmlSlurper 少。然而,它确实包含一个 parseText 方法,可以处理前一段代码返回的 jsonTxt。
如果我将这些添加到前面的行中,完整的 ICNDB 脚本将在下一列表中显示。
列表 4.11. chuck_norris.groovy,处理 ICNDB 的数据
import groovy.json.JsonSlurper
String url = 'http://api.icndb.com/jokes/random?limitTo=[nerdy]'
String jsonTxt = url.toURL().text
def json = new JsonSlurper().parseText(jsonTxt)
def joke = json?.value?.joke
println joke
JsonSlurper 上的 parseText 方法将 JSON 数据转换为 Groovy 的映射和列表。然后我访问 json 对象的 value 属性,它是一个包含的 JSON 对象。它有一个 joke 属性,其中包含我正在寻找的字符串。
执行此脚本的结果类似于以下内容:
Chuck Norris can make a method abstract and final
正如通过 MarkupBuilder 脚本输出生成 XML 一样,生成 JSON 数据使用 groovy.json.JsonBuilder 类。请参阅 GroovyDocs 中的 JsonBuilder 以获取完整示例。
学习到的经验(JSON)
-
JsonSlurper类有一个parseText方法,用于处理 JSON 格式的字符串。 -
JsonBuilder类使用与XmlSlurper相同的机制生成 JSON 字符串。
这完成了对可以添加到 Java 应用程序中的 Groovy 特性的浏览,无论使用场景如何。
学习到的经验(在 Java 中使用的 Groovy 特性)
-
当 Groovy 访问 POJO 时,它可以像访问 POGO 一样使用基于映射的构造函数。
-
Groovy 中的每个运算符都委托给一个方法,如果该方法在 Java 类中实现,Groovy 中的运算符仍然会使用它。这意味着您甚至可以在 Java 类中进行运算符重载。
-
Groovy JDK 通过元编程记录了 Groovy 添加到 Java 标准 API 中的所有方法。
-
Groovy AST 转换只能应用于 Groovy 类,但这些类可以以有趣的方式与 Java 混合。本章包括
@Delegate、@Immutable和@Singleton的示例。
4.7. 摘要
本章回顾了许多 Groovy 在基本层面上帮助 Java 的方法,从 POJO 增强到 AST 转换,再到构建 XML 等。我将在未来的章节中使用这些技术,只要它们能有所帮助。我还会在途中回顾其他有用的技术,尽管这些是大多数主要的技术。
然而,接下来的几章将改变焦点。尽管混合使用 Java 和 Groovy 很容易,这也是本书的一个主要主题,但一些公司不愿意在生产代码中添加 Groovy,直到他们的开发者对这种语言达到一定的最低舒适度。事实上,有两个主要领域 Groovy 可以强烈影响并简化 Java 项目,而不需要直接集成。其中一个是企业开发中的主要痛点之一:构建过程。另一个是测试,开发者越好,测试的价值就越高。
通过在本书早期介绍这两项技术,我就可以在攻击 Java 开发者通常遇到的使用案例时,例如 Web 服务、数据库操作或与 Spring 框架协同工作时,使用 Gradle 构建和 Spock 测试。
第二部分。Groovy 工具
欢迎来到第二部分:“Groovy 工具。”在这两章中,我讨论了 Groovy 通常被引入组织的两种主要方式:即构建过程和测试。
第五章关于构建过程回顾了 Java 世界中的主导构建工具 Ant 和 Maven,并展示了如何将 Groovy 依赖项添加到每个工具中。它还涵盖了与 Groovy 一起工作的 Ant 任务和 Maven 的主要 Groovy 插件。最后,它介绍了 Gradle,这是 Groovy 世界中最重要项目之一,并包括涵盖几个有趣构建任务的示例。
第六章关于测试从 Java 和 Groovy 中的 JUnit 测试开始,然后探讨了 JUnit 子类GroovyTestCase及其后代,以及它们带来的额外功能。接着,它涵盖了 Groovy 库中的MockFor和StubFor类,这些是构建模拟对象的好方法,同时也提供了一些关于 Groovy 元编程的见解。最后,本章以 Spock 测试框架的良好概述结束,该框架本身也包含一些模拟功能。
第五章。构建过程
本章涵盖
-
将 Groovy 添加到 Ant 构建中
-
使用 Maven 与 Groovy
-
Groovy 葡萄和
@Grab -
未来:Gradle
在开发组织中,构建源代码几乎总是痛点。一个理想的构建过程是自动化端到端,包括编译、运行测试、生成报告和生成任何所需的工件。该过程需要足够快,以便可以频繁执行,尤其是在现代敏捷方法下,同时还需要足够灵活,以适应各个团队的紧急需求。
在 Java 世界中,随着时间的推移,已经出现了两种主要的自动化构建方法。两者都是 Apache 的开源项目。第一个是 Ant (ant.apache.org),它使用配置在 XML 中的任务库,由 Java 类支持。另一个是 Maven (maven.apache.org),它提供了一系列丰富的选项,承诺使整个过程简单化,但使用了一个高度有偏见的 API,需要一定的掌握才能有效使用。
首先,我想讨论任何构建过程的目标,然后看看各种工具是如何尝试满足这些目标的。
5.1。构建挑战
软件构建结合了几个功能,单独看起来似乎应该很容易,但在实践中却变得复杂。为了构建你的代码,你必须
-
下载任何必要的依赖项。
-
使用正确解析的依赖项编译源代码,处理可能出现的跨语言问题。
-
运行单元、集成和/或功能测试。
-
生成所需的工件,无论它们是什么。
可选的任务可能包括从源代码控制中检出代码、生成文档,甚至将结果部署到生产环境中。
IDE 构建
一些公司仍然在集成开发环境(IDE)内部进行构建。尽管这本身并不是一件坏事,但它往往会导致长期问题。迟早,这些公司会拥有一台特殊的计算机,没有人敢触摸,即使原始所有者很久以前就离开了或者转到了其他部门,因为它是唯一一个构建仍然可以工作的系统。
当前的观点是,源代码控制系统应该管理构建的所有方面,从所需的脚本到 JAR 依赖项。这样,你就可以始终确信构建是正确的并且自给自足的,这避免了整个“至少在我的机器上它能工作”的问题。
事实上,近年来在开发过程中的趋势是持续交付,其中一条命令就可以在一次操作中完成从构建到部署的整个序列。([2])
¹ 请参阅 Jez Humble 和 Dave Farley 的著作 持续交付(Addison Wesley,2010)以获取详细信息。(可通过其配套网站 http://continuousdelivery.com/ 获取。)
在 Java 世界中有两种主要的构建工具:Ant 和 Maven。Ant 较老,正在逐渐被取代,但它仍在行业中很常见,并且是之后所有事物的基石。Maven 在 Java 行业中得到了广泛的应用,但往往会在开发者中引起强烈的情感。
本章节涵盖的技术指南如图 5.1 所示。下一节,我将从 Apache Ant 项目开始介绍。
图 5.1. 本章节技术指南。Java 方法基于 Ant 或 Maven。Groovy 为编译和执行脚本提供了 Ant 任务。Gant 被用于 Grails,但最终将被 Gradle 取代。AntBuilder 类很有用,并且内置在 Gradle 中。有两个独立的插件可用于 Maven 构建。Groovy Grapes 使得在没有先编译的情况下向客户端交付代码(通常是脚本)变得容易。然而,最终,未来属于 Gradle。

5.2. Java 方法,第一部分:Ant
Apache Ant 是一个基于 Java 的构建工具,基于较老的“make”技术,但避免了其中许多困难。Ant 的名字代表“另一个整洁的工具”或一个能够承担远超自身重量的工具,这取决于你问的是谁。Ant 构建文件是用 XML 编写的,因此它们本质上是跨平台的,并且由于 Java 类实现了 XML 任务,因此单个 API 就足以适用于所有操作系统。
这是好消息。有些(多少有些)坏消息是,Ant 是一个非常底层的 API,因此许多构建文件由许多扭曲的小任务组成,它们都一样。([3])
² 是的,一个冒险(或 Zork)的引用。我的意思是它们很小,很多,而且很容易在其中迷路。
让我从下一列表中的“Hello, World”示例开始,这个示例基于 Apache 在 Ant 网站上提供的 Ant 教程中的样本。
列表 5.1. build.xml:一个简单的 Ant 构建“Hello, World” Java 应用程序的构建文件
<project name="HelloWorld" basedir="." default="main">
<property name="src.dir" value="src"/>
<property name="build.dir" value="build"/>
<property name="classes.dir" value="${build.dir}/classes"/>
<property name="jar.dir" value="${build.dir}/jar"/>
<property name="main-class" value="mjg.HelloWorld"/>
<target name="clean">
<delete dir="${build.dir}"/>
</target>
<target name="compile">
<mkdir dir="${classes.dir}"/>
<javac srcdir="${src.dir}" destdir="${classes.dir}"
includeantruntime="false"/>
</target>
<target name="jar" depends="compile">
<mkdir dir="${jar.dir}"/>
<jar destfile="${jar.dir}/${ant.project.name}.jar"
basedir="${classes.dir}">
<manifest>
<attribute name="Main-Class" value="${main-class}"/>
</manifest>
</jar>
</target>
<target name="run" depends="jar">
<java jar="${jar.dir}/${ant.project.name}.jar" fork="true"/>
</target>
<target name="clean-build" depends="clean,jar"/>
<target name="main" depends="clean,run"/>
</project>
默认情况下,此文件名为 build.xml,位于项目的根目录中。项目文件的根元素称为 <project>,它被赋予一个 name、一个基本目录以及一个默认任务,如果命令行中没有提供,则运行此任务。
在文件顶部设置了一系列属性,包括各种目录的位置。请注意,一个属性可以通过使用 ${...} 语法来引用另一个属性。
定义了一系列 <task> 元素(clean、compile、jar、run、clean-compile 和 main),以表示构建过程中的单个操作。一些任务依赖于其他任务,这通过 <task> 元素的 depends 属性来表示。
所定义的所有任务最终都会委托给一组预定义的 Ant 任务。这里这些任务包括基于文件的任务,如 mkdir 和 delete,以及与 Java 相关的任务,如 javac、jar 和 java。
不带参数执行此构建意味着在命令行中键入 ant,这将执行默认的 main 任务。因为 main 依赖于 clean 和 run,它将首先执行这些任务,这些任务将执行它们自己的依赖项,依此类推。结果如下所示。
列表 5.2. “Hello, World” Ant 构建中默认任务的执行
Buildfile: /.../build.xml
clean:
[delete] Deleting directory /.../build
compile:
[mkdir] Created dir: /.../build/classes
[javac] Compiling 1 source file to /.../build/classes
jar:
[mkdir] Created dir: /.../build/jar
[jar] Building jar: /.../build/jar/HelloWorld.jar
run:
[java] Hello, World!
main:
BUILD SUCCESSFUL
Total time: 1 second
每个任务都会输出其自己的名称,然后是包含在下面的缩进内置 Ant 任务。构建成功完成,尽管这可能具有误导性。最后的 BUILD SUCCESSFUL 语句表示 Ant 完成了所有任务。个别任务可能成功也可能失败。
这里选择的任务很典型,但没有标准。每个组织(甚至每个开发者)都可以自由选择自己的。在不同构建之间重用任务还需要一个 import 语句(或复制粘贴重用),以及一些确保任务不与特定项目结构绑定的努力。
再次强调,这里的优势是这一切都是完全可移植的。Ant 构建应该在 Mac OS X 上与在 Windows 或 Linux 上一样好。缺点是这个只是一个简单的 Hello World 应用程序,构建文件已经超过 35 行长。一旦添加了 junit 和 junitreport 任务,更不用说使用第三方库自定义类路径了,这个文件的大小会迅速增长。一个更广泛的构建文件,包括 JUnit 4 库和一个测试用例,可以在章节源代码中找到。
然而,在这里不做这个,而是让我向你展示如何将 Groovy 引入这个系统。
5.3. 将 Ant 与 Groovy 结合
Ant 在 Java 构建中不像以前那样常见,但切换构建工具对于大多数组织来说是一个重大的决定,不应轻率行事。如果你正在使用大量已安装的 Ant 构建,那么 Groovy 仍然可以做出贡献。
有四种方法可供选择:
-
Groovy 脚本代码可以直接添加到 Ant 构建文件中。
-
Groovy 脚本和类可以使用专门为此目的的 Ant 任务在 Ant 构建中编译和执行。
-
Groovy 标准库中包含一个名为
groovy.util.AntBuilder的特殊类,它可以替换执行相同功能的 Groovy 脚本 XML 构建文件。 -
可用 Groovy DSL,称为 Gant,它提供了对
AntBuilder的替代方案。
AntBuilder
即使您不使用 Ant,了解 AntBuilder 类也是值得的,因为它嵌入在其他构建工具中,如 Gant 和 Gradle。
以下小节将依次处理这些 Groovy 和 Ant 主题。
5.3.1. <groovy> Ant 任务
Ant 有两个钩子,允许您将 Groovy 添加到标准构建文件中。<groovy> 和 <groovyc> 任务分别使用 Groovy 库执行 Groovy 脚本和编译 Groovy 源文件。
首先从 <groovy> 开始,在 Ant 构建中定义相关任务,可以直接将 Groovy 代码写入构建文件。下面的列表显示了一个简单的示例。
列表 5.3. 一个简单的 Ant 构建,在任务中执行 Groovy 代码

environment 属性允许构建访问操作系统的系统属性。在这里,env 变量用于访问 GROOVY_HOME 的当前值,Groovy 的安装目录。<path> 元素将 groovy-all JAR 文件(位于可嵌入目录中)分配给 groovy.classpath ID。
<taskdef> 元素定义 groovy 任务为对 org.codehaus.groovy.ant.Groovy 类的引用,该类在 groovy-all JAR 文件中解析。一旦定义了 groovy 任务,就可以使用它来执行任意 Groovy 代码。执行了“Hello, World!”的直接打印,然后还调用了 Ant 的 echo 任务。
因此,很容易将 Groovy 代码添加到现有的 Ant 构建文件中,这在构建中需要循环或条件逻辑时非常有用。在 XML 中“编程”是出了名的困难,而且倾向于这个方向的技术(如 Ant 和 XSLT)往往会导致笨拙、复杂的构建文件。添加 Groovy 脚本代码可能有助于构建文件,而无需修改底层源代码。
5.3.2. <groovyc> Ant 任务
假如您遵循本书中的建议并决定将 Groovy 模块添加到您的实现代码中。如果您仍然使用 Ant 进行构建,您将需要一个类似于 <javac> 的编译任务来编译 Groovy。这个任务就是 <groovyc>。
<groovyc> 任务定义非常简单:
<taskdef name="groovyc"
classname="org.codehaus.groovy.ant.Groovyc"
classpathref="groovy.classpath"/>
任务的名称是 <groovyc>,它由 org.codehaus.groovy.ant 包中的 Groovyc 类支持。这个类是前面构建文件中引用的 Groovy Ant JAR 文件的一部分。
此任务定义的结果是,您可以在使用 <javac> 编译 Java 类的同时使用 <groovyc> 编译 Groovy 类。这种强制分离代码库的方法可能会导致困难,如果有交叉依赖关系。例如,一个 Groovy 类可能实现了 Java 接口并引用了一个 Java 类,而这个 Java 类又使用了一个 Groovy 类,依此类推。
解决这些问题的好方法是使用 联合编译 方法。Ant 允许您在 <groovyc> 任务内部嵌入 <javac> 任务。嵌套标签方法导致一个看起来像这样的 <groovyc> 任务:
<groovyc srcdir="${src.dir}" destdir="${classes.dir}"
classpathref="classpath">
<javac source="1.5" target="1.5" />
</groovyc>
嵌套的 <javac> 任务并不意味着 Java 编译器正在运行。作为 <groovyc> 任务的子任务,它允许 Groovy 联合编译器完成所有工作。
在 <groovyc> 任务中定义的源目录、目标目录和类路径变量被传递到嵌套的 <javac> 任务中。联合编译方法意味着 Groovy 将编译 Groovy 源代码并为它们创建存根,然后调用 Java 编译器为 Java 源代码做同样的事情,并使用 Groovy 编译器继续编译过程。结果是,您可以无问题地混合 Java 和 Groovy 源代码。
因此,为了将 Ant 构建文件扩展到包括 Groovy 文件,请按照下一个列表所示进行添加和更改。
列表 5.4. 将“Hello, World”构建扩展以混合 Java 和 Groovy 源代码
<path id="groovy.classpath">
<fileset dir="${env.GROOVY_HOME}/embeddable" />
</path>
<path id="classpath">
<fileset dir="${lib.dir}" includes="**/*.jar" />
</path>
<taskdef name="groovyc"
classname="org.codehaus.groovy.ant.Groovyc"
classpathref="groovy.classpath" />
...
<target name="compile">
<mkdir dir="${classes.dir}" />
<groovyc srcdir="${src.dir}" destdir="${classes.dir}"
classpathref="classpath">
<javac source="1.5" target="1.5" />
</groovyc>
</target>
其余的与之前相同。
如果您坚持使用 XML 进行 Ant 构建,那么这就是全部内容。然而,如果您愿意将构建语言切换到 Groovy,还有其他一些替代方案。接下来的两个小节使用 Groovy 作为构建语言,但仍然基于 Ant。
5.3.3. 使用 AntBuilder 在 Groovy 中编写构建
标准的 Groovy 库包括一个名为 groovy.util.AntBuilder 的类。要使用它,您需要将基于 Java 的 Ant JAR 库文件添加到您的类路径中,但一旦这样做,AntBuilder 就允许您用 Groovy 语法替换 XML 语法。
Ant 定义的任何任务都可以通过 AntBuilder 类使用。例如,以下列表显示了一个简单的脚本,该脚本复制其自身的源代码,验证其是否成功,然后删除副本。
列表 5.5. antbuilder.groovy,该脚本复制自身
def ant = new AntBuilder()
String dir = 'src/main/groovy'
assert !(new File("$dir/*antbuildercopy.groovy*").exists())
ant.*echo* 'about to copy the source code'
ant.*copy* file:"$dir/*antbuilder.groovy*",
tofile:"$dir/*antbuildercopy.groovy*"
assert (new File("$dir/*antbuildercopy.groovy*").exists())
ant.*echo* 'deleting the copied file'
ant.*delete* file:"$dir/*antbuildercopy.groovy*"
在此示例中,构建代码和常规 Groovy 代码可以自由混合。这里使用的 Ant 任务是 echo、copy 和 delete,但很容易使用其他任务,如 javac、junitreport,甚至是可选的 Ant 任务如 mail。只要所需的 Ant 库在类路径中,每个任务都可以正常工作。
实际上有一个简化的方法可用。with 语法是 Groovy 元编程能力的一部分。它可以简化前面的列表,使其变为下一个列表所示的内容。
列表 5.6. 使用 with 方法简化构建脚本
ant.*with* {
echo 'about to copy the source code'
copy file:"$dir/*antbuilder.groovy*",
tofile:"$dir/*antbuildercopy.groovy*"
echo 'deleting the copied file'
delete file:"$dir/*antbuildercopy.groovy*"
}
with 方法调用 Ant 构建器中的包含方法。
AntBuilder 可以用来编写整个构建文件。这对于快速创建构建文件非常有用,尤其是如果你已经非常熟悉相应的 Ant 任务。因为 AntBuilder 是标准 Groovy 库的一部分,所以你可以在需要执行与构建相关的任务的地方使用它。更好的是,Gradle 构建文件包含一个 AntBuilder 实例,这使得从 Ant 迁移到 Gradle 的路径变得更加简单。
下一个列表提供了一个更有趣的示例,它是 列表 5.1 中显示的原始 Ant 构建的移植。
列表 5.7. 将 列表 5.1 中的 build.xml 文件移植到 Groovy AntBuilder 脚本


你可以使用 groovy 命令来执行此脚本。在 with 块内部,所有像 mkdir、javac 和 junit 这样的方法都被传递给构建器实例。正式来说,这意味着 with 块的 delegate 属性是 AntBuilder 实例。因为这是一个 Groovy 脚本,你可以添加任何你想要的代码来进行其他处理。例如,在 XML 文件中安排循环和条件是众所周知的尴尬,但在这里这会变得很容易。
尽管如此,AntBuilder 在底层仍然是 Ant。如果没有特定领域的语言(DSL)替代品,Groovy 就不会是 Groovy。在本章后面将讨论的 Gradle 是最佳选择。然而,在实践中你可能会遇到另一种方法。为了完整性,下一个子节简要讨论了 Groovy Ant,也称为 Gant。
5.3.4. 使用 Gant 创建自定义构建脚本
尽管在 Groovy 中构建文件的未来属于 Gradle,但 Gant 仍然在 Groovy 生态系统中占据一个特殊的细分市场。截至本文写作时,Grails 框架的最新版本(2.3)^([3]) 仍然使用 Gant 实现其构建脚本.^([4)。如果你需要为 Grails 应用程序创建自定义构建脚本,Gant 仍然是有用的。如果你不打算这样做,你可以舒适地跳过这个子节。
³ Grails 在第八章数据库和第十章网络开发中被讨论。Grails 的主页是
grails.org。⁴ Gant 将至少在 Grails 2.3 版本中继续被包含。
Gant 用例
Grails 命令作为 Gant 脚本实现,所以如果你需要自定义一个 Grails 命令或创建一个新的命令,Gant 是首选的工具。
Grails 中的 Gant 脚本也是一个极好的示例代码选择。为了使本节内容简单,我将回顾一个现有的 Grails Gant 脚本的一部分,名为 Clean.groovy。该脚本位于 Grails 发行版的根目录下的 scripts 目录中。与所有 Grails Gant 脚本一样,它通过小写脚本名来调用,将驼峰式命名替换为连字符;因此,对于 Clean 脚本,命令将是 grails clean,而对于 CreateDomainObject 脚本,命令是 grails create-domain-object。
这是Clean脚本的完整内容(不包括版权声明):
includeTargets << grailsScript("_GrailsClean")
setDefaultTarget("cleanAll")
grailsScript命令加载了一个不同的 Gant 脚本,称为_GrailsClean。按照惯例(Grails 的一切都是关于惯例),以下划线开头的脚本是内部脚本,不能从命令行执行。因此,第一行加载了一系列任务,第二行将cleanAll任务设置为默认任务。
现在转向_GrailsClean脚本,让我从其中突出几个小节:
includeTargets << grailsScript("_GrailsEvents")
target (cleanAll: "Cleans a Grails project") {
clean()
cleanTestReports()
grailsConsole.updateStatus "Application cleaned."
}
target (clean: "Implementation of clean") {
depends(cleanCompiledSources, cleanWarFile)
}
与 Ant 的相似性并非偶然。Gant 脚本包含目标,目标可以被调用,就像方法调用一样。在这里,名为cleanAll的目标调用了两个其他任务(clean和cleanTestReports),然后调用了预定义的grailsConsole对象上的updateStatus方法。
clean任务使用depends方法(再次类似于 Ant 中的相同功能)来确保在调用clean任务时调用cleanCompiledSources和cleanWarFile任务。以下是cleanCompiledSources任务的片段:
target (cleanCompiledSources: "Cleans compiled Java and Groovy sources") {
def webInf = "${basedir}/web-app/WEB-INF"
ant.delete(dir:"${webInf}/classes")
ant.delete(file:webXmlFile.absolutePath, failonerror:false)
ant.delete(dir:"${projectWorkDir}/gspcompile", failonerror:false)
任务继续删除更多项目,每次都委托给一个内部的AntBuilder对象。cleanWarFile任务展示了如何在脚本中混合 Groovy 逻辑代码:
target (cleanWarFile: "Cleans the deployable .war file") {
if (buildConfig.grails.project.war.file) {
warName = buildConfig.grails.project.war.file
}
else {
def fileName = grailsAppName
def version = metadata.'app.version'
if (version) {
fileName += "-$version"
}
warName = "${basedir}/${fileName}.war"
}
ant.delete(file:warName, failonerror:false)
}
这是一段简单的 Groovy 代码,它只是定义了一些变量,并根据当前配置设置它们的属性,然后对ant对象调用delete方法。
这本书关于 Gant 的内容就到这里了.^([5])
⁵有关 Gant 的更多信息,可以在 Groovy 网站上找到。在 Peter Ledbrook 和 Glen Smith 合著的书籍Grails in Action(Manning,2009)中也有一个不错的教程。最后,Grails 用户指南有一个专门关于创建 Gant 脚本的章节。
5.3.5. Ant 总结
这也结束了关于 Ant 和基于 Ant 的方法的讨论,无论是 Java 还是 Groovy。在“经验教训”侧边栏中显示了详细信息。
经验教训(Ant)
-
如果你有一个现有的 Ant 构建,你可以向其中添加
<groovyc>和<groovy>任务。 -
Gant 只用于 Grails,并且不会使用很长时间。
-
AntBuilder本身很少见,但它内置在 Gradle 中,并且非常有用
现在是时候检查 Java 世界中的另一个主要构建工具:Maven 了。
Ant 的局限性
当它发布时,Ant 是比之前的构建过程的一个重大改进。然而,它仍然存在一些主要问题,这些问题使得生活复杂化,尤其是在较大的构建中。以下是使用 Ant 时与复杂性相关的一些简要列表。这并不是对 Ant 的批评,而是为了突出导致下一代工具的问题。
Ant 构建基于 XML,而 XML 不是一个脚本语言。构建不可避免地需要定制,并且通常取决于项目是在开发、测试还是生产模式。Ant 允许你设置属性,但属性不是变量。在 XML 文件中执行复杂的分支逻辑尤其困难。
Ant 没有提及 依赖管理。它假设你已经有所有必需的库可用,并且你可以构建一个文件集来保存它们,并将其用作类路径。Ivy 项目(也是来自 Apache)填补了这一空白,现在 Ant 和 Ivy 的组合比单独使用 Ant 更为常见。
XML 是为了被程序处理而设计的,而不是给人阅读的。阅读一个简短的 XML 文件并不难。阅读一个长而复杂的文件就困难多了,甚至在这个章节中展示的简单构建文件,当包含一些基本任务时,也有超过 50 行长。
内置的 Ant 任务非常低级。因此,Ant 构建文件很快就会变得又长又复杂,并且涉及大量的重复。
由于所有这些原因以及其他原因,Ant 已经准备好被一个更高级别的替代品所取代。这个角色由 Maven 项目填补,这取决于你对它的经验,要么是祝福,要么是诅咒。
5.4. Java 方法,第二部分:Maven
我将坦白地说 Maven 很难理性地讨论。它的最佳特性(建立传统项目布局、管理依赖项、提供丰富的插件架构)也被认为是最糟糕的特性(难以在其约定之外工作、难以管理传递依赖项、整个“下载整个互联网”问题)。我可以诚实地说我从未遇到过一种在行业中普遍存在,却又被像一千个太阳一样炽热地憎恨的技术。在开发者群体中提起 Maven,有人会拒绝讨论“M 话题”。然而,与此同时,另一个人会悄悄地说他们可以让它做任何事情,并且不理解为什么会有这么大的争议。
⁶ 除非是每一个微软技术。
我自己的经验并非如此黑白分明。我发现,如果一个项目从一开始就使用 Maven 设计,它通常与系统配合得很好。没有 Maven 也很难使用那个系统。另一方面,将 Maven 添加到一个没有从它开始的项目中可能会相当痛苦。此外,朋友们也向我保证,一旦系统规模超过一定程度,整个过程就会变得难以管理。
可能最好的办法是说明 Maven 有一个高度意见化的 API。为了成功,你必须按照 Maven 的方式做事。此外,就像 Ant 一样,你是在 XML 中编写构建代码,这从来都不是一件容易的事。多项目构建功能也很尴尬。^([7])
⁷ 虽然这听起来并不太“超然”,但至少我在尝试。
我要指出,标准的 Maven 项目布局(如图 5.2 所示)在业界已经变得很普遍。此外,人们可能会对 Maven 的依赖管理方法提出抱怨,但我还没有看到任何显著更好的方法。Gradle(本章后面将讨论的替代品)使用 Maven 仓库和 Ivy 依赖管理,并遭受相同的“从互联网下载”问题。无论你如何处理,依赖管理都是一件困难的事情。
图 5.2. 本节中应用程序使用的标准 Maven 项目结构。编译后的源代码位于 src/main/java,测试代码位于 src/test/java。

返回(终于)到本书的核心主题,本节的目标是向您展示如何将 Groovy 集成到 Maven 构建中。有两种方法可以实现这一点。我将从 Groovy-Eclipse 插件开始,然后使用 GMaven 项目构建相同的应用程序。
5.4.1. Maven 的 Groovy-Eclipse 插件
The Groovy-Eclipse compiler plugin (mng.bz/2rHY) is a standard compiler plugin for Maven. It emerged from the effort to build a good Eclipse plugin for Groovy that worked with combined Groovy and Java projects. The Maven plugin is a way to take advantage of that effort, whether you plan to use the Eclipse IDE or not.
为了演示其用法,我将构建一个小项目,该项目访问 Yahoo!天气网络服务并报告当前条件。这用 Java 做起来足够简单,但在 Groovy 中则变得特别简单。
The Yahoo! Weather web service (developer.yahoo.com/weather/) provides weather information in the form of an RSS feed. The web service is accessed from a URL of the form
http://weather.yahooapis.com/forecastrss
The URL has two parameters, one required and one optional. The required parameter is w, a so-called WOEID (Where On Earth ID), that Yahoo uses to identify a location. The other parameter is u, which is used to specify the temperature units in Fahrenheit (f, the default) or Celsius (c). For unknown reasons, there’s no way to programmatically look up a WOEID. Instead Yahoo! directs you to its own weather page and suggests you search for your city.
向正确的 URL 发送一个简单的 HTTP GET 请求会返回一个 RSS 格式的 XML 响应。一个示例包含在 Yahoo!的网页上。
假设我决定构建一个简单的应用程序来检索基于此服务的当前天气条件。Maven 建议您指定一个特定的工件以开始项目,所以我将使用经典的 maven-archetype-quickstart:
> mvn archetype:generate –DgroupId=mjg –DartifactId=weather
–DarchetypeArtifactId=maven-archetype-quickstart
-Dversion=1.0-SNAPSHOT –Dpackage=mjg
Maven 架构
The Groovy-Eclipse plugin uses regular Java archetypes and adds Groovy functionality. The GMaven approach in the next section includes a basic archetype to get started.
这将生成一个具有标准布局的 Java 项目,这意味着源代码目录是 src/main/java,测试目录是 src/test/java。快速入门原型在这些目录中分别包含一个简单的 App.java 和 AppTest.java,生成器还在根目录中添加了一个标准的 Maven POM 文件,其唯一依赖项是 JUnit,如下一列表所示。
列表 5.8. 标准 Java 项目的 Maven pom.xml 文件
<project
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>mjg</groupId>
<artifactId>weather</artifactId>
<packaging>jar</packaging>
<version>1.0-SNAPSHOT</version>
<name>weather</name>
<url>http://maven.apache.org</url>
<dependencies>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.10</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
我到目前为止所做的唯一更改是将 JUnit 依赖项从 3.8.1 升级到 4.10。
为了完成实际工作,我需要一个发送请求到 Yahoo 并解析响应的类,以及一个用于存储生成的天气信息的 POJO。从 POJO 开始,对于一个给定的城市、地区和国家,我想存储条件、温度、风寒和湿度。Web 服务返回的信息比这多得多,但这足以开始。
POJOs 是简单的数据容器,因此构造函数、getter 和 setter 方法以及任何必要的覆盖方法大多是杂乱无章的。因此,如果使用 POGO,我可以简化我的生活,如下一列表所示。
列表 5.9. Weather.groovy,一个用于存储来自 Web 服务的天气结果的 POGO
package mjg
class Weather {
String city
String region
String country
String condition
String temp
String chill
String humidity
String toString() {
"""
Weather for $city, $region, $country:
Condition : $condition
Temperature: $temp
Wind Chill : $chill
Humidity : $humidity
"""
}
}
toString方法是一种生成格式化输出的方式。Groovy 的多行字符串使得这特别容易。
我需要的另一个类是 Web 服务的解析器。因为我只需要一个 GET 请求,所以我可以像往常一样使用XmlSlurper类的parse方法,并钻入生成的 DOM 树以获取我想要的结果。这也很简单,如下一列表所示。
列表 5.10. YahooParser.groovy,它访问并解析天气服务
package mjg
class YahooParser {
final static String BASE = 'http://weather.yahooapis.com/forecastrss?'
Weather getWeather(String woeid) {
def root = new XmlSlurper().parse(BASE + "w=$woeid")
Weather w = new Weather(
city:root.channel.location.@city,
region:root.channel.location.@region,
country:root.channel.location.@country,
condition:root.channel.item.condition.@text,
temp:root.channel.item.condition.@temp,
chill:root.channel.wind.@chill,
humidity:root.channel.atmosphere.@humidity
)
}
}
给定一个 WOEID,服务构建 URL 并访问 Web 服务,解析生成的 RSS,并返回一个包含所有相关字段的Weather类的实例。
为了完成程序,我需要一个驱动程序,我可以将其编写为 Groovy 脚本。除非我想允许客户端在命令行上指定 WOEID,否则这是一个单行命令:
def woeid = args.size() ? args[0] : '2367105'
println new YahooParser().getWeather(woeid)
脚本中的默认 WOEID 是波士顿,马萨诸塞州,并存储在RunDemo.groovy中。为了演示当 Java 和 Groovy 源文件同时存在时的差异,我还添加了一个 Java 类来访问 Web 服务,该类存储在RunInJava.java文件中:
public class RunInJava {
public static void main(String[] args) {
String woeid = "2367105";
if (args.length > 0) woeid = args[0];
YahooParser yp = new YahooParser();
System.*out*.println(yp.getWeather(woeid));
}
}
现在是有趣的部分:我该如何让 Maven 处理所有的 Groovy 代码?Groovy-Eclipse 插件需要向 POM 文件添加两个修改。首先,我需要将 Groovy 添加为依赖项:
<dependencies>
...
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-all</artifactId>
<version>2.1.5</version>
</dependency>
</dependencies>
接下来,我需要在依赖项下方的build部分添加 Groovy-Eclipse 插件:
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>2.3.2</version>
<configuration>
<compilerId>groovy-eclipse-compiler</compilerId>
</configuration>
<dependencies>
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-eclipse-compiler</artifactId>
<version>2.7.0-01</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
在这两个添加之后,Maven 将适当地编译和使用 Groovy 代码,除了一个相当奇怪的问题。通常我会将我的 Groovy 类添加到 src/main/groovy,并将任何 Groovy 测试添加到 src/test/groovy。根据插件文档,我只能在以下情况下这样做:(1)src/main/java 中至少有一个 Java 类,或者(2)我添加更多的 XML 来指定额外的源目录。
源目录
对于 Groovy-Eclipse 插件,默认情况下,将 Java 和 Groovy 源文件放在 src/main/java 和 src/test/java 目录中。
我将我的 Groovy 文件放在 src/main/java 和 src/test/java。现在我可以使用以下方式构建项目:
mvn clean install
我甚至可以使用 exec:java (!) 任务执行项目,无论是使用默认的 WOEID 还是提供命令行参数:
> mvn exec:java -Dexec.mainClass=mjg.RunDemo
...
Weather for Boston, MA, United States:
Condition : Cloudy
Temperature: 58
Wind Chill : 58
Humidity : 84
我可以使用 –Dexec.args 来提供一个命令行参数:
> mvn exec:java -Dexec.mainClass=mjg.RunDemo -Dexec.args='44418'
...
Weather for London, , United Kingdom:
Condition : Cloudy
Temperature: 54
Wind Chill : 54
Humidity : 82
本书的一个指导原则是,Java 在工具、库和(现有)基础设施方面做得很好,而 Groovy 在其他所有方面都做得很好。很难想象比当前示例更好的证明。整个应用程序都是用 Groovy 编写的,代码节省量在 10 到 1 的数量级。基础设施将代码视为全部是 Java,我甚至能够使用 Java 的 exec 任务来执行 Groovy 脚本以驱动应用程序。
Groovy-Eclipse 编译器插件是一个资助项目,因为它被用于 SpringSource(VMware 的一个部门)提供的 IDE 中。因此,该插件的质量,特别是对于交叉编译,相当高。仅仅因为它名字中有“Eclipse”,并不意味着不能在 Maven 项目中使用它。插件并不是 IDE 独有的。你可以用它来任何地方,就像我在本节中用 Maven 项目做的那样。
⁸ 现在是 Pivotal 的一部分,由 VMware 拥有,而 VMware 又由 EMC 拥有...
将 Groovy 添加到使用 Maven 构建的项目中的另一种方法是使用 GMaven 项目,这在下一节中讨论。
5.4.2. GMaven 项目
GMaven 是将 Groovy 添加到 Maven 项目的另一种方法。它通过在构建序列中生成 Groovy 文件的存根,与结合 Java 和 Groovy 的源文件一起工作。
为了帮助用户入门,项目提供了一个名为 gmaven-archetype-basic 的 Maven 架构。要使用该架构,请在命令行中执行以下操作:
> mvn archetype:generate –DgroupId=mjg –DartifactId=weather
–DarchetypeArtifactId=gmaven-archetype-basic
-Dversion=1.0-SNAPSHOT –Dpackage=mjg
这又产生了一个标准 Maven 结构的项目,其中源文件位于 src/main/groovy,测试文件位于 src/test/groovy。插件期望 Java 和 Groovy 源文件都位于这些目录中。
生成的 POM 如下所示,其中一些修改在列表中讨论。
列表 5.11. GMaven 项目生成的 Maven pom.xml 文件


POM 需要 Groovy 依赖项。它不必是全局的,但在这里添加它同样简单。提供者已调整为 2.1.5,以便使用 Groovy 版本 2。
使用标准的 Maven install命令构建系统:
> mvn clean install
在构建过程中,为每个 Groovy 文件生成 Java 占位符。这些占位符本身相当简单;它们仅用于解决跨语言依赖关系,而不是执行。例如,以下是之前章节中显示的Weather类的占位符的一部分。
列表 5.12. 从Weather.groovy生成的 Java 占位符的一部分

任何 Java 类都可以通过实现GroovyObject接口来被视为 Groovy 源代码,就像这里的占位符所做的那样。占位符中的前五种方法为该接口中的所有方法提供了无操作实现。其余的占位符为剩余的方法提供了空实现,在这种情况下是获取器和设置器以及toString方法。
为RunDemo类生成的占位符略有不同,以一种有趣的方式。Groovy 实现只是几行脚本代码。正如在第三章演示中提到的,我从java命令执行了一个编译后的 Groovy 脚本,每个 Groovy 脚本最终都会被编译器转换为一个类,相应的 RunDemo.java 占位符说明了这一点:
public class RunDemo extends groovy.lang.Script {
public RunDemo() {}
public RunDemo(groovy.lang.Binding context) {}
public static void main(java.lang.String... args) { }
public java.lang.Object run() { return null;}
}
该类扩展了groovy.lang.Script,有一个默认构造函数和一个接受groovy.lang.Binding的构造函数,一个标准的 Java main方法,以及一个run方法。所有 Groovy 脚本对 JVM 来说看起来都像这样。运行脚本就像执行main方法一样,它将委托给这里的run操作。
如前所述,要使用 Maven 运行程序,需要使用带有正确参数的exec:java任务。在这种情况下,这意味着主类是RunDemo或RunInJava:
> mvn exec:java -Dexec.mainClass=mjg.RunDemo
> mvn exec:java -Dexec.mainClass=mjg.RunInJava
无论哪种方式,结果都与上一节相同。
最近 GMaven 项目比较安静,但它仍然存在。正如所示,原型工作正常,占位符生成允许插件将编译委托给标准的 Maven 工具。
教训(Maven)
-
添加 Groovy 到 Maven 构建有两种独立的方式,每种方式都有其优点和缺点:“Groovy Eclipse”插件和 GMaven。
-
如果可能的话,考虑迁移到 Gradle。
5.4.3. Maven 总结
向 Maven 项目添加 Groovy 依赖有两种方式:Groovy-Eclipse 插件和 GMaven 项目。我的建议(可能会随着项目的演变而改变)是
1. 对于已经存在的 Maven 构建,添加 Groovy-Eclipse 插件。它有效,并且有一家公司从财务上支持插件本身的发展。名字中包含单词Eclipse并不重要。
2. 对于新项目,任一插件都可以工作,但 Maven 架构的存在使得 GMaven 的启动变得特别容易。
3. 真是很有趣,这两个插件都期望 Java 和 Groovy 源文件放在一起。这里有一个重要的集成课程。
现在从混合方法转向纯 Groovy 解决方案,我将首先介绍简洁的 Grape 方法,然后再转向真正的目的地:Gradle。
5.5. Grape 和 @Grab
Grape 机制允许你在 Groovy 脚本中直接声明库依赖项。当你需要向客户端交付一个脚本,而客户端尚未拥有所需的依赖项,但愿意在构建过程中下载它们时,这很有用。
整个 API 被称为 Grape(Groovy 可适应/高级打包引擎),并以 groovy.lang.Grab 注解开始。它使用 Ivy 解析器来识别和下载依赖项。它的主要用例是脚本,这样它们就可以在没有任何设置要求(除了安装 Groovy)的情况下交付给客户端。在运行时,Groovy 将下载并安装任何声明的库及其传递依赖项,作为执行过程的一部分。
Grape 用例
Grape 允许你交付一个简单的脚本,客户端可以执行它,而无需进行任何设置,除了安装 Groovy,这使得它对于测试人员或 QA 人员特别方便。
为了演示 Grape 系统,让我选择 Apache Commons 项目中的 Math 库(commons.apache.org/math/)。具体来说,我想使用复数包。该包包含一个名为 Complex 的类,它表示复数。尽管这个类本身很有趣,但它也很好地展示了 Groovy 的元编程能力。
在 Maven 语法中,该库的组 ID 为 org.apache.commons,工件 ID 为 commons-math3,版本为 3.0。因此,@Grab 注解的格式如下所示:
import org.apache.commons.math3.complex.*
@Grab('org.apache.commons:commons-math3:3.0')
Complex first = new Complex(1.0, 3.0);
Complex second = new Complex(2.0, 5.0);
@Grab 注解下载了给定的库及其依赖项。语法使用 Maven 结构,使用冒号连接组 ID、工件 ID 和版本号。或者,你可以单独指定部分:
@Grab(group='org.apache.commons', module='commons-math3', version='3.0')
两种情况下的行为是等效的。
除了这些,Grapes 没有什么更多了。为了展示一个需要外部 Java 库的有趣示例,让我提出一个简单的 Groovy 元编程案例。这并不特别需要 Grape,但它展示了少量的元编程如何使 Java 库类更加 Groovy。在脚本中使用 Grape 允许我将其发送给客户端,而无需编译它或提供库依赖项。Grape 注解将处理其余部分。
Complex类表示一个复数,它结合了实部和虚部。该类包含一个两个参数的构造函数,如所示,它接受实部和虚部作为参数。该类定义了许多方法,以便将基本的数值计算推广到复数域。
回想一下,在 Groovy 中,每个操作符都委托给一个方法调用。有趣的是,Complex类已经有一个名为multiply的方法,用于计算两个复数的乘积。因为 Groovy 中的*操作符使用multiply方法,所以可以直接使用该操作符:
assert first.multiply(second) == first * second
再次强调,这是一个 Java 类。幸运的是,该类的开发者选择包含一个名为multiply的方法,因此 Groovy 可以使用*操作符与复数一起使用。
那么,其他所有数学运算呢?大多数都不那么整洁。例如,该类使用add而不是plus,使用subtract而不是minus。然而,通过向与Complex关联的元类添加适当的方法,可以轻松地将它们连接起来,通过 Groovy 查看。
作为提醒,通过 Groovy 访问的每个类都包含一个元类,元类是一个Expando。这意味着可以按需向元类添加方法和属性,结果成员将属于任何实例化的对象。以下是如何向Complex添加几个数学运算的方法:
Complex.*metaClass*.plus = { Complex c -> delegate.add c }
Complex.*metaClass*.minus = { Complex c -> delegate.subtract c }
Complex.*metaClass*.div = { Complex c -> delegate.divide c }
Complex.*metaClass*.power = { Complex c -> delegate.pow c }
Complex.*metaClass*.negative = { delegate.negate() }
这就解决了+、-、/、**和取反操作符的问题。在每种情况下,相关的方法都是通过将闭包设置为等于一个闭包来定义在元类上的。相关的闭包接受一个Complex参数(在二元操作符的情况下)并在闭包的委托上调用所需的现有方法,同时传递参数。
闭包委托
每个闭包都有一个delegate属性。默认情况下,委托指向闭包被调用的对象。
在将这些方法添加到元类之后,可以在 Groovy 脚本中使用操作符:
assert new Complex(3.0, 8.0) == first + second
assert new Complex(1.0, 2.0) == second - first
assert new Complex(0.5862068965517241, 0.03448275862068969) ==
first / second
assert new Complex(-0.007563724861696302, 0.01786136835085382) ==
first ** second
assert new Complex(-1.0, -3.0) == -first
为了完成这个故事的一部分,我想展示著名的欧拉恒等式,这个恒等式被称为欧拉公式,^([9]),它表达为
⁹ 莱昂哈德·欧拉(1707 – 1783)是有史以来最杰出的数学家之一。他的工作几乎涵盖了数学和科学的每一个领域,他的全集占据了 60 到 80 个四开本。超越数
e是以他的名字命名的。
e^(iπ)=–1
这个等式将虚数(i)、超越数(e和π)与负数(–1)联系起来。欧拉认为这个表达式非常深刻,以至于他在自己的墓碑上刻下了它。
java.lang.Math类包含常量Math.E和Math.PI,而Complex类有常量Complex.I。为了使公式看起来更好,我将使用静态导入来导入它们。
需要最后添加一个功能才能使其工作。Java 中的Math.E是 double 类型,我想将其提升到Complex的幂。最简单的方法是将 double 转换为Complex类的实例,然后使用Complex类中的pow方法。回到 Groovy 元编程,我需要一个power方法(对应于**运算符),它接受一个Complex参数:
Double.metaClass.power = { Complex c -> (new Complex(delegate,0)).pow(c) }
在所有这些机制就绪后,生成的代码略显平淡,但这是一件好事:
Complex result = *E* ** (*I* * *PI*)
assert result.real == -1
assert result.imaginary < 1.0e-15
在 Groovy 中,通常访问real或imaginary属性相当于调用getReal或getImaginary方法。该表达式确实生成了-1 的实部,但由于与 Java 双精度浮点数相关的舍入误差,虚部并不正好为零。在我的机器上,它评估为一个小于显示边界的数字,这当然足够接近了。
在 Grapes 系统中还有一些额外的注释可用。其中一个是@GrabConfig,在下一个示例中用于加载数据库驱动程序。以下脚本使用groovy.sql.Sql类生成一个 H2 数据库并向其中添加一些数据:
import groovy.sql.Sql
@GrabConfig(systemClassLoader=true)
@Grab(group='com.h2database', module='h2', version='1.2.140')
Sql sql = Sql.newInstance(url:'jdbc:h2:mem:',driver:'org.h2.Driver')
注释提供了驱动程序,因此可以使用Sql类正常工作。
由于类的成员只能有一个特定注释的单例,因此使用@Grapes注释来组合多个@Grab注释。下一个列表计算复数值并将它们存储在数据库表中。
列表 5.13. 使用 Apache Commons Math 和数据库驱动程序一起
@GrabConfig(systemClassLoader=true)
@Grapes([
@Grab('org.apache.commons:commons-math3:3.0'),
@Grab(group='com.h2database', module='h2', version='1.2.140')
])
import static java.lang.Math.*
import org.apache.commons.math3.complex.Complex
import org.apache.commons.math3.complex.ComplexUtils
import groovy.sql.Sql
*S*ql sql = Sql.newInstance(url:'jdbc:h2:mem:',driver:'org.h2.Driver')
sql.execute '''
create table coordinates (
id bigint generated by default as identity,
angle double not null,
x double not null,
y double not null,
primary key (id)
)
'''
int n = 20
def delta = 2*PI/n
(0..<n).*each* { num ->
Complex c = ComplexUtils.polar2Complex(1, num*delta)
sql.execute """
insert into coordinates(id,angle,x,y)
values(null, ${i*delta}, $c.real, $c.imaginary)
"""
}
sql.rows('select * from coordinates').*each* { row ->
println "$row.id, $row.angle, $row.x, $row.y"
}
脚本创建了一个表格,用于存储圆周上 20 个点的 x 和 y 坐标。ComplexUtils.polar2Complex方法接受一个半径(这里为了简单起见使用一个半径)和一个圆周上的角度(以弧度为单位),并生成一个复数,然后将其存储在数据库中。
Grapes 系统简单有效,但在实践中有限制。这些添加在脚本中工作,但对于更大的系统,更常见的是使用完整的构建工具,如 Gradle,这是下一节的主题。
5.6. Gradle 构建系统
提议 Gradle 作为下一代构建解决方案。Gradle 结合了 Groovy 构建的灵活性以及一个强大的领域特定语言(DSL),它可以配置丰富的类集。
与几乎所有有意义的 Groovy 项目一样,Gradle 是用 Java 和 Groovy 编写的。Gradle 本质上是一个用于构建的 DSL。10 它定义了一种语法和语义的语言,允许你快速轻松地编写构建文件。
^([10] 必须的 DSL 笑话:“JavaScript 是用于查找浏览器错误的 DSL”;“Java 是用于生成堆栈跟踪的 DSL”;“Maven 是用于下载互联网的 DSL。”)
Gradle 不附带安装程序。你只需下载一个 ZIP 文件,将 GRADLE_HOME 环境变量设置为解压缩的位置,并将 $GRADLE_HOME/bin 目录添加到你的路径中,你就可以开始了。实际上,你甚至不需要先安装 Groovy,因为 Gradle 自带其自己的 Groovy 版本。
Groovy 生态系统中的项目如何包含 Groovy
Groovy 的一个不为人知的秘密是,主版本并不总是二进制兼容的。用某个版本编译的代码不一定与任何其他版本兼容。
这意味着 Groovy 生态系统中的项目有选择权。它们可以选择用不同版本的 Groovy 进行编译,并将 Groovy 版本号作为它们自己版本的一部分,或者它们可以捆绑一个特定的 Groovy 版本。
Spock 框架(在第六章中讨论)采取了前一种方法。Spock 版本的形式为 0.7-groovy-2.0,这意味着 Spock 版本 0.7 是用 Groovy 版本 2.0 编译的。
Grails 和 Gradle 项目采取了另一种方法。例如,Grails 1.3.9 包含了 Groovy 1.7.8 的副本,Grails 2.0.3 包含 Groovy 1.8.6,而 Grails 2.2.1 包含 Groovy 2.0.8。要查看 Gradle 分发版中包含的 Groovy 版本,请运行 gradle –v 命令。
对于 Grails 来说,捆绑的 Groovy 版本会将整个应用程序锁定在该版本。然而,对于 Gradle 来说,捆绑的 Groovy 版本仅用于执行构建脚本本身。你可以在自己的项目中使用任何版本的 Groovy,Gradle 都会正确地构建它们。
当你运行 gradle –v 命令时,除了显示 Gradle 和 Groovy 版本外,Gradle 还会报告包含的 Ant 和 Ivy 版本,以及 JVM 和 OS。
Gradle 构建的范围从极其简单到相当强大。我将从一个最简单的例子开始,并在此基础上构建。
5.6.1. 基本 Gradle 构建
Gradle 是一个基于插件的架构。大多数 Gradle 教程都是从定义任务是什么以及如何调用一个任务开始的。在这里,我不再那样做,而是展示一个最小的构建文件,并从那里开始。
这里是一个 Java 项目的最小 Gradle 构建示例,在名为 build.gradle 的文件中:
apply plugin:'java'
apply 语法表示构建正在使用 Java 插件。当你使用此文件运行 build 命令时,Gradle 会分几个阶段执行任务,如下所示:
:compileJava UP-TO-DATE
:processResources UP-TO-DATE
:classes UP-TO-DATE
:jar
:assemble
:compileTestJava UP-TO-DATE
:processTestResources UP-TO-DATE
:testClasses UP-TO-DATE
:test
:check
:build
BUILD SUCCESSFUL
冒号后面的每个单词都是一个 Gradle 任务。Gradle 会根据指定的任务构建一个有向无环图(DAG),注意它们的依赖关系,然后按顺序执行它们。这个最小项目没有源代码,因此编译任务无需运行就已是最新状态。实际上,唯一执行任何操作的任务是 jar 任务,它在构建目录的 libs 中创建一个 JAR 文件。
如果你正在進行任何測試,你的項目將需要包含 JUnit 依賴。考慮一個使用標準 Maven 結構的簡單項目,這樣任何 Java 類別都包含在 src/main/java 中,任何測試都包含在 src/test/java 中。下一個列表展示了一個名為 Greeting 的 POJO,它有一個單一的 String 屬性名為 message。
列表 5.14. 用於展示 Gradle 建築的 Greeting POJO
public class Greeting {
private String message = "Hello, World!";
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
}
以下列表是一個名為 GreetingTest 的 JUnit 測試,它檢查 getter 和 setter。
列表 5.15. Greeting POJO 的 JUnit 測試
import static org.junit.Assert.*;
import org.junit.Test;
public class GreetingTests {
private Greeting greeting = new Greeting();
@Test
public void testGetGreeting() {
assertEquals("Hello, World!", greeting.getMessage());
}
@Test
public void testSetGreeting() {
greeting.setMessage("What up?");
assertEquals("What up?", greeting.getMessage());
}
}
下一個列表展示了一個在測試階段具有 JUnit 依賴的 Gradle 建築文件。這仍然是一個“Hello, World”示例,但它引入了一些基本概念。
列表 5.16. POJO 應用程序的 build.gradle 文件

repositories 和 dependencies 是 Gradle DSL 的部分。任何所需的庫都列在 dependencies 块中。有幾種合法的依賴列表形式。這裡使用的是由冒號分隔的字符串。使用 Maven 語法不是偶然的,如 repositories 部分所示。可以使用多種不同類型的存儲庫,但這裡聲明了標準 Maven 中央存儲庫。
本次執行建築時運行相同的任務系列,但現在任何測試都會執行,並在 build/reports/tests 目錄中生成 HTML 格式的 JUnit 报告。
這證明了 Gradle 构建可以應用於沒有 Groovy 依賴的 Java 项目。為了展示相同的過程在混合 Java/Groovy 項目上也能正常工作,我將在 src/test/groovy 目錄中添加一個名為 GroovyGreetingTests 的 Groovy 測試用例。測試用例在下一個列表中展示。
列表 5.17. POJO 的 Groovy 測試,使這成為一個混合 Java/Groovy 项
import static org.junit.Assert.*
import org.junit.Test
class GroovyGreetingTests {
Greeting greeting = new Greeting()
@Test
void testGetMessage(){
assert 'Hello, World!' == greeting.message
}
@Test
void testSetMessage() {
greeting.message = 'Yo, dude'
assert 'Yo, dude' == greeting.message
}
}
新的 build.gradle 文件需要 Groovy 依賴。在 Gradle 版本 1.6 之前,依賴的名称是“groovy”。現在,推薦的表示法是將 Groovy 依賴聲明為標準編譯時間要求。完整的 build.gradle 文件在以下列表中展示。
列表 5.18. 混合 Java/Groovy 项目的 build.gradle 文件
apply plugin:'groovy'
repositories {
mavenCentral()
}
dependencies {
compile 'org.codehaus.groovy:groovy-all:2.1.5'
testCompile 'junit:junit:4.10'
}
建築文件的其他更改是將 Java 插件替換為 Groovy 插件,該插件已經包含了 Java 任務。新的插件為建築添加了幾個任務,如下所示:
:compileJava
:compileGroovy UP-TO-DATE
:processResources UP-TO-DATE
:classes
:jar
:assemble
:compileTestJava
:compileTestGroovy
:processTestResources UP-TO-DATE
:testClasses
:test
:check
:build
BUILD SUCCESSFUL
compileGroovy 和 compileTestGroovy 任務都是新的,但其他一切正常進行。類別被編譯,測試運行,並生成了 HTML 測試報告。
那是处理 Java、Groovy 或混合 Java/Groovy 项目时的 Gradle 构建文件的基礎結構。類似的文件在這本書中都有展示。為了說明一些有趣的 Gradle 特性,我現在將考慮幾個在實踐中經常出現的用例。
5.6.2. 有趣的配置
本书使用了 Gradle 构建。当讨论上下文中的特定示例时,我会提出很多不同的选项,但在这里我可以讨论一些有趣的想法。
自定义源集
首先,本书中的一个主要主题是,将 Groovy 源代码与 Java 源代码分开相当不自然。如果你想要使用相同的源文件夹来处理两者,就像 Eclipse 项目可能做的那样?这里有一个简单的自定义项目布局来实现这一点:
sourceSets {
main {
java { srcDirs = [] }
groovy { srcDir 'src' }
}
test {
java { srcDirs = [] }
groovy { srcDir 'src' }
}
}
源集是 Gradle 构建中源代码的集合。在这里,通过将 src/main/java 和 src/test/java 文件夹的srcDirs属性分配给空列表,Java 编译器根本不会运行。相反,Groovy 编译器用于 src 目录中的所有类,这些类可能包含 Java 和 Groovy 类。
复制 JAR 文件
另一个有用的策略是制作依赖库的本地副本。以下任务就是这样做的:
task collectJars(type: Copy) {
into "$buildDir/output/lib"
from configurations.testRuntime
}
collectJars任务是一种Copy任务——Gradle 中内置的任务类型之一。运行collectJars会将运行时类路径中的 JAR 文件复制到构建目录中的输出/lib 文件夹。Spock 使用此任务来制作完整的发行版。
输入和输出
Gradle 的另一个出色功能是它可以跳过不必要的任务。它是通过创建文件和目录的哈希值并检查它们是否已更改来做到这一点的。以下列表显示了一个从 Gradle 附带示例中取出的示例。
^(11)请参阅下载分布中 userguide/tasks/incrementalBuild/inputsAndOutputs 目录。Gradle 附带大量非常简单的示例,就像这个一样。
列表 5.19. incrementalBuilds Gradle 示例中的输入/输出示例

脚本的srcFile和destDir属性被分配给ext映射,这使得它们位于项目中,但避免了与现有的Project属性可能发生的任何潜在冲突。inputs和outputs属性可以分配给文件或目录(换句话说,单词file被解释为java.io.File)。如果这两个属性与上次运行时相同,则doLast块内的代码将被跳过。
Ant 集成
Gradle 的一个很好的特性是它包含了一个groovy.ant.AntBuilder实例作为构建的一部分。这意味着任何可以用 Ant 完成的事情都可以在 Gradle 构建中处理。这有几个后果。首先,如果你已经有了 Ant 构建文件,你可以在 Gradle 构建中调用其任务。你甚至可以使 Gradle 任务依赖于 Ant 任务。
考虑这个例子,来自 Gradle 示例.^([12]) Ant 构建文件是 build.xml,它包含一个名为hello的单个任务:
^(12)请参阅分布中的 userguide/ant/dependsOnAntTarget。
<project>
<target name="hello">
<echo>Hello, from Ant</echo>
</target>
</project>
Gradle 构建在文件 build.gradle 中:
ant.importBuild 'build.xml'
task intro(dependsOn: hello) << {
println 'Hello, from Gradle'
}
intro任务依赖于 Ant 构建中的hello任务,该任务通过ant变量(AntBuilder的一个实例)导入。运行gradle intro会执行这两个任务:
:hello
[ant:echo] Hello, from Ant
:intro
Hello, from Gradle
BUILD SUCCESSFUL
包装器任务
最后,即使客户端没有安装 Gradle,也可以执行 Gradle 构建。Gradle 附带一个特殊的Wrapper任务,它有一个版本属性:
task wrapper(type: Wrapper) {
gradleVersion = '1.6'
}
运行此任务会为 Windows 和 Unix 生成脚本,分别称为gradlew.bat和gradlew,以及一个最小的 Gradle JAR 发行版。当执行时,包装器首先下载并安装 Gradle 的本地副本,然后执行构建。
Gradle 是一个非常强大的系统,对其彻底的调查超出了本书的范围.^([13]) 希望这一节能为你提供足够的介绍,让你开始。
^(13) 书籍《Gradle in Action》(Manning,2013)由 Benjamin Muschko 撰写,既写得很好又全面。我强烈推荐它。
学习到的经验(Grapes 和 Gradle)
-
@Grab对 Groovy 脚本很有帮助。 -
Gradle 使用 Groovy 构建文件来配置你的构建,但像 Maven 一样从互联网上下载。
-
Gradle 没有像 Maven 那样的工件,但人们正在研究为各种目标创建标准构建的方法。
-
除了本章的讨论之外,本书中的每个项目都包含一个 Gradle 构建,突出其各种功能。
5.7. 摘要
本章探讨了适用于 Groovy 和 Java 项目的构建工具。Ant 非常常见但处于较低级别。Groovy 提供了原始的groovy任务和groovyc编译任务,这在组合项目中可能很有用。
Maven 是一个高级工具,但可能难以定制。在本章中,我介绍了 GMaven 项目作为将 Groovy 添加到 Maven 的方法,以及 Groovy-Eclipse 插件方法,这种方法在跨编译问题时通常更稳健。
Groovy 包含一个@Grab注解,其所谓的 Grapes 功能可以用来直接将依赖项添加到 Groovy 脚本中。它功能强大,但仅限于 Groovy 构建。
最后,我介绍了 Gradle 构建工具。本章包括了对 Gradle 的基本讨论,并提到了几个更高级的功能。Gradle 在本书中被用来展示每个章节中的有趣机制。
第六章. 测试 Groovy 和 Java 项目
本章涵盖
-
使用
GroovyTestCase及其子类 -
测试脚本以及类
-
Groovy 库中的
MockFor和StubFor类 -
Spock 测试框架
自动化测试的兴起是过去 20 年中软件开发生产率最显著的提升之一。作为构建过程一部分运行的自动化测试很容易设置,可以立即发现问题,并给你在重构代码时无需担心破坏相关无关内容的自由。
测试是许多“敏捷”开发流程的基石,从更现代的技术如 SCRUM 到 Kanban,再到原始的极限编程(XP)运动。然而,自动化测试还有两个其他的好处,但它们并没有得到足够的宣传:
1. 测试是可执行的文档。
任何主要的开源项目都是世界上一些最好的开发者共同努力的结果,其中许多人是在自己的时间里工作的。他们高度致力于编写代码,而不是文档。结果是,文档的质量往往低于代码的质量,如果它甚至最初是更新的话。
我自己的经验是,开发者越好,他们就越关心测试。最好的开发者编写完整的测试,这些测试作为持续集成系统的一部分始终运行。如果一个测试失败,系统会立即通知项目提交者。因此,测试是开发者如何打算使用系统的绝佳例子。
无论何时您与一个主要的开源项目一起工作,请下载源代码。您可能不会查看细节,但测试是无价的。
2. 测试不是生产代码的一部分。
从开发者的角度来看,这并不是什么大问题,但对于管理者来说却是一个巨大的问题。公司不愿意采用新语言的一个原因是不确定它们在生产环境中表现如何。生产代码通常涉及复杂的审批流程和性能评估,这些评估可能非常保守。
如果您想在您的系统中尝试 Groovy,测试是一个简单的方法。Groovy 语言内置了许多测试功能,这些功能都可以与 Groovy 和 Java 代码一起工作。从管理的角度来看,最好的是,在运行时 Groovy 只是一个 JAR 文件。
本章回顾了使测试更简单的 Groovy API 和库。首先,我会回顾 Java 开发者通常如何测试应用程序,重点关注 JUnit 库。然后,我会展示 Groovy 如何通过其GroovyTestCase扩展来增强这个过程。接下来,我会展示如何使用GroovyTestCase的子类测试用 Groovy 编写的脚本。从那里,我会讨论使用模拟和存根来单独测试类。这涉及到 Groovy 内置的模拟和存根功能,无论是通过Expando类,还是通过 Groovy 的MockFor和StubFor类。最后,我会向您展示未来的一瞥,即强大的 Spock 框架,这是一个纯 Groovy 库,它简化了 Java 和 Groovy 项目的测试。
图 6.1 是本章讨论的技术的指南。
图 6.1. 本章中的 Java 测试来自 JUnit。标准的 Groovy 库包括 JUnit 的 TestCase 的一个子类,称为 GroovyTestCase,其子类也很有用。Spock 框架是一个非常流行的替代测试 API,它包括一个 JUnit 测试运行器。Groovy 通过 Expando、MockFor 和 StubFor 等库类使创建模拟对象变得容易。

6.1. 使用 JUnit
敏捷开发社区创建了 JUnit (junit.org) 作为自动化测试的伟大工具。虽然存在其他 Java 测试工具,但 JUnit 的影响如此之大,以至于我遇到的几乎每一位 Java 开发者都使用过它或听说过它。JUnit 的成功催生了一系列用于其他语言的类似工具(统称为“xUnit”)。JUnit 简单、易于使用,在 Java 世界中无处不在。正如我将在本章中展示的,可用的 Groovy 工具也易于使用和学习,其中一些直接基于 JUnit.^([1])
¹ 现在更常被称为“敏捷”开发,因为大多数财富 500 强公司不想与“极端”任何事情联系在一起。
将 JUnit 添加到您的项目中(第五章的回顾 chapter 5)
JUnit 是由极限编程的两位创始人 Erich Gamma 和 Kent Beck 创建的开源项目。JUnit 库可以从其主页(junit.org)下载,但它已内置到大多数常见的 IDE 中,包括 Eclipse、NetBeans 和 IntelliJ IDEA。它还可以通过 Maven 中央仓库检索,使用以下形式的 POM 依赖项
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.10</version>
</dependency>
作为替代,JUnit 4.5 及以上版本启用 artifact ID junit-dep,它不包括所谓的 Hamcrest 匹配器(code.google.com/p/hamcrest/),这些匹配器在某些情况下简化了语法。像大多数酷项目一样,JUnit 的源代码现在位于 GitHub 上,在 github.com/junit-team/junit.
本书中的大多数 Gradle 构建文件(特别是本章中的项目)都将 JUnit 作为“test-compile”依赖项。这意味着 API 中的类(如 org.junit.TestCase 和 org.junit.Assert)仅对测试类可用。
当在 Groovy 中编写 JUnit 测试时,你有两种选择。你可以像往常一样使用注解编写 JUnit 测试,但用 Groovy 实现,或者你可以扩展 GroovyTestCase 类。唯一的区别是 GroovyTestCase 为 JUnit 的 TestCase 类添加了一些额外的方法。
因为这本书全部关于集成,我想检查以下情况:
-
使用标准的 Groovy JUnit 测试来检查 Java 实现。
-
使用标准的 Java JUnit 测试来检查 Groovy 实现。
-
编写一个扩展
GroovyTestCase的 Groovy 测试,以查看它提供了哪些新增功能。
在每种情况下,我都需要一些东西来测试。因为我计划混合使用语言,我找到的一种使这更容易的方法是在 Java 接口中声明我的方法,然后在两种语言中实现它。这实际上是一个相当通用的规则。
Groovy 实现了 Java
Groovy 类可以像 Java 类一样轻松地实现 Java 接口。
下一个列表显示了一个 Java 接口,称为UtilityMethods,包含三个方法声明。
列表 6.1. 一个包含三个方法的 Java 接口
public interface UtilityMethods {
int[] getPositives(int... values);
boolean isPrime(int x);
boolean isPalindrome(String s);
}
在真正的测试驱动开发(TDD)中,我现在会编写测试,观察它们失败,然后编写正确的实现。因为本章的主题是测试而不是实现,让我先展示实现。^([2])
我尝试使用 TDD(测试驱动开发),但更经常使用 GDD(罪恶驱动开发)。如果我编写了代码但没有进行测试,我会感到内疚,并为它编写一个测试。
以下列表是UtilityMethods接口的 Java 实现。
列表 6.2. UtilityMethods接口的 Java 实现
import java.util.ArrayList;
import java.util.List;
public class JavaUtilityMethods implements UtilityMethods {
public int[] getPositives(int... values) {
List<Integer> results = new ArrayList<Integer>();
for (Integer i : values) {
if (i > 0) results.add(i);
}
int[] answer = new int[results.size()];
for (int i = 0; i < results.size(); i++) {
answer[i] = results.get(i);
}
return answer;
}
public boolean isPrime(int x) {
if (x < 0) throw new IllegalArgumentException("argument must be > 0");
if (x == 2) return true;
for (int i = 2; i < Math.sqrt(x) + 1; i++) {
if (x % i == 0) return false;
}
return true;
}
public boolean isPalindrome(String s) {
StringBuilder sb = new StringBuilder();
for (char c : s.toCharArray()) {
if (Character.isLetter(c)) {
sb.append(c);
}
}
String forward = sb.toString().toLowerCase();
String backward = sb.reverse().toString().toLowerCase();
return forward.equals(backward);
}
}
对于有 Java 背景的人来说,实现不会令人惊讶。下一个列表中显示的 Groovy 实现要短一些。
列表 6.3. UtilityMethods接口的 Groovy 实现

实际上,isPrime方法的实现中有一个微妙的错误。测试将检测到它,并给我一个机会来解释陷阱。
在下一个子节中,我将使用 Java 来测试 Groovy 实现并修复错误。然后我将使用 Groovy 来测试 Java 实现,最后我将编写一个作为GroovyTestCase子类的测试,看看这能帮到什么。
6.1.1. 一个针对 Groovy 实现的 Java 测试
下面的列表包含一个 JUnit 4 测试,用 Java 编写,用于测试 Groovy 实现。它包括对org.junit.Assert类中方法的静态导入和单个测试的@Test注解。
列表 6.4. 一个用于检查 Groovy 实现的 Java JUnit 测试


在 JUnit 3 中,测试扩展了org.junit.TestCase类,测试方法是通过反射检测的。TestCase中包含了所有需要的断言方法。现在,在 JUnit 4 中,测试没有超类,并且通过@Test注解进行检测。断言方法现在是Assert类中的静态方法,这导致了 Java 中最常见的静态导入使用。如果你对Assert类进行静态导入,你可以像在旧版本中一样编写断言方法。
这里的另一个有趣的部分是@Test注解的expected属性的用法,它声明只有当抛出预期异常时测试才通过。图 6.2 显示了结果。
图 6.2. isPrime方法有一个错误,但其他部分都正常。

测试检测到 Groovy 实现对所有情况都返回 true。Groovy 实现将给定的数字除以从 2 到数字平方根减 1 的所有整数,寻找任何能被整除的数。这个算法是好的。问题是如果检测到合数(非质数),该方法应该返回 false。
不幸的是,从闭包内部返回的行为并不像常规 Java 开发者所期望的那样。实际上,当你从闭包返回时,就像是从另一个方法内部的方法返回一样。它只从闭包返回,而不是包含它的方法。
这是一个值得注意的陷阱:
从闭包返回
从闭包内部返回只会从闭包返回,而不会从包含它的方法返回。
可能最简单的修复方法是切换到循环,其中返回行为如预期。下面是一个正确的实现:
boolean isPrime(int x) {
if (x < 0) throw new IllegalArgumentException('argument must be > 0')
if (x == 2) return true
for (num in 2..< Math.sqrt(x) + 1) {
if (x % num == 0) {
return false
}
}
return true
}
现在测试通过了。接下来我将展示一个针对 Java 实现的 Groovy 测试。
6.1.2. 一个针对 Java 实现的 Groovy 测试
你可以使用 Groovy 实现 JUnit 测试,就像使用 Java 一样简单,并且伴随着代码简化。下面的列表展示了这样一个测试。
列表 6.5. 一个针对 Java 实现的 Groovy JUnit 测试

这里有一些代码简化,但这仍然是一个可识别的标准 JUnit 测试。可以通过将范围强制转换为整数数组来提供初始数据。Collection 中的 every 方法让我可以在一个语句中检查所有返回值。否则这与之前相同。
另一个注意事项:由于 Groovy 的真值规则,^([3]) Groovy 中的 assert 与 assertTrue 和 assertNotNull 相同。此外,Groovy 的 assert 具有出色的调试输出。因此,大多数 Groovy 开发者更倾向于在测试中使用 assert 而不是 org.junit.Assert 类中的任何断言方法。
³ 非空引用为真,非零数字为真,非空集合为真,非空字符串为真,等等。
最后,让我展示一个扩展 GroovyTestCase 的测试类,看看它带来了哪些额外功能。
6.1.3. 一个针对 Java 实现的 GroovyTestCase 测试
Groovy 通过其标准库提供 groovy.util.GroovyTestCase 类。如前所述,它扩展了 org.junit.TestCase。下面的列表展示了一个针对 Java 实现的此类测试。
列表 6.6. 一个针对 Java 实现的 GroovyTestCase 测试


这里有一些新特性。首先,GroovyTestCase 包含一个静态的受保护属性 log,其类型为 java.util.logging.Logger。自己添加一个日志记录器到测试中并不难,但自动提供一个是方便的。
接下来,该类添加了一个assertLength方法。它有三个重载版本。在每个版本中,第一个参数是数组的预期长度。第二个参数是一个整数数组、字符数组或Object类型的数组。在这里,我使用这个方法来检查返回的正整数数量是否符合预期。
该类还提供了一个assertArrayEquals方法,它接受两个Object数组作为参数。文档说明,此方法检查数组是否等效且包含相同的元素.^([4])
⁴ 这听起来像是冗余部门的,但并非如此。
另外添加了一个方法assertContains。该方法有两个重载版本,一个用于字符,一个用于整数,因此仅在那些情况下有用。
最后,超类还提供了一个shouldFail方法,它接受一个异常类型和一个闭包,或者只接受一个闭包。它期望在运行闭包时抛出异常,因此它的行为与具有预期属性的@Test注解非常相似。
GroovyTestCase类还有一些额外的没有在这里列出的方法,如assertScript、shouldFailWithCause和广受欢迎的notYetImplemented。有关详细信息,请参阅 GroovyDocs。
有趣的是,这个测试可以从命令行运行。groovy命令充当基于文本的 JUnit 运行器,用于GroovyTestCase子类。结果看起来像这样:
$ groovy -cp bin src/test/groovy/mjg/JavaImplGTCTest.groovy
.Jun 23, 2013 5:53:05 PM java_util_logging_Logger$info call
INFO: inside testGetPositives
...
Time: 0.179
OK (4 tests)
Java 接口和实现类被编译并存储在项目的 bin 目录中,因此在运行 Groovy 脚本时需要将它们添加到类路径中。
Lessons learned (JUnit)^([5])
1. JUnit 是业界最常用的 Java 单元测试框架。
2. 正常的 JUnit 测试基于注解。
@Test注解有一个名为expected的属性。这样的测试只有在抛出预期异常时才会通过。3. 版本 4 的测试没有超类。相反,所有的断言方法都是
org.junit.Assert类中的静态方法。4. 根据 Groovy 的真理,
assert、assertTrue和assertNotNull都是相同的。5. 因为 Groovy 的
assert在失败时提供了大量的调试信息,所以通常比标准的 JUnitassertEquals方法更受欢迎。6.
GroovyTestCase扩展了 JUnit 中的TestCase类,并添加了一些便利方法,如assertLength和shouldFail。
⁵ 在我离开这个部分之前,我应该提到,示例中使用的回文来自www.derf.net/palindromes/old.palindrome.html上的“巨大的回文列表”页面。
测试用 Groovy 编写的脚本涉及特殊的情况,特别是如果输入数据来自外部时。这是下一节的主题。
6.2. 使用 Groovy 编写的脚本测试
测试脚本与测试类略有不同。你通常不会实例化一个脚本并在其上调用方法,尽管你可以这样做。相反,最简单的方法是执行脚本,并让它的内部assert语句进行任何正确性检查。
使用断言
当 Groovy 开发者编写脚本时,他们通常会添加断言来演示脚本是否正常工作。
如果脚本中没有涉及输入或输出变量,在测试用例中运行脚本就足够简单了。因为脚本通常包含验证其正确性的assert语句,所以关键是简单地以编程方式执行脚本。这就是GroovyShell类的作用。
这里有一个简单的例子。考虑一个访问互联网 Chuck Norris 数据库的简短但强大的脚本,该脚本从第四章复制而来:
⁶ 这可能是互联网被发明的原因。
import groovy.json.JsonSlurper
def result = 'http://api.icndb.com/jokes/random'.toURL().text
def json = new JsonSlurper().parseText(result)
def joke = json?.value?.joke
assert joke
println joke
Chuck Norris can instantiate an interface
当这个脚本执行时,它会访问显示的 URL 上的 RESTful 网络服务,以 JavaScript 对象表示法(JSON)的形式检索一个随机笑话,解析(或者更确切地说,是吞噬)它,并打印出结果笑话。脚本使用安全的解引用操作符来避免NullPointerException,以防出现错误,但它有一个assert语句来检查是否确实检索到了某些内容。当执行时,结果类似于:为了测试这个脚本,我只需要执行它,让嵌入的assert语句来完成工作。我可以像以下列表中那样以编程方式执行它。
列表 6.7. 用于存储所有脚本测试的类
class ScriptTests {
@Test
void testChuckNorrisScript() {
GroovyShell shell = new GroovyShell()
shell.evaluate(new File('src/main/groovy/mjg/chuck_norris.groovy'))
}
}
在第三章中讨论的GroovyShell类有一个evaluate方法,它接受一个File参数。我只需将File指向相关的脚本,shell 上的evaluate方法就会执行它。
如果我想检查结果呢?在这种情况下,结果是随机的,但如果我的脚本基于输入值有实际的结果,那么可以做什么呢?
为了处理这个问题,我需要一个脚本的绑定(再次在第三章中讨论)。绑定是一个对象,它允许从脚本中访问输入和输出变量。
脚本绑定
任何在脚本中未声明的变量都是绑定的一部分,可以从外部访问。
考虑 Groovy 中的经典“Hello, World!”脚本。我将在下一个列表中将其放入一个包中,但除此之外,它与附录 B,“Groovy by feature”中描述的脚本相同。
列表 6.8. “Hello, World!”脚本
package mjg
println 'Hello, World!'
此脚本不包含任何 assert 语句,但由于它打印到控制台,我希望能够检查输出。为此,我可以将对应绑定的 out 属性分配给一个 StringBuffer,我可以在脚本执行后访问它。⁷ 以下测试已被添加到在 列表 6.7 中开始的 ScriptTests 类。
⁷ 这部分内容没有很好地记录,所以请考虑这是通过阅读这本书为你提供的额外价值。Guillaume Laforge 告诉了我关于它的事情(并且也写了它),所以他应该得到真正的荣誉。
列表 6.9. 捕获脚本输出的测试
@Test
void testHelloWorld() {
Binding binding = new Binding()
def content = new StringWriter()
binding.out = new PrintWriter(content)
GroovyShell shell = new GroovyShell(binding)
shell.evaluate(new File('src/main/groovy/mjg/hello_world.groovy'))
*assert* "Hello, World!" == content.toString().trim()
}
绑定的 out 属性被分配给一个围绕 StringWriter 的 PrintWriter,这样当脚本中的 println 方法执行时,输出就会流向该写入器而不是控制台。然后,在通过 shell 执行脚本后,我可以通过访问写入器并修剪其输出,来检查是否打印了正确的语句。
通常,绑定用于将输入变量传递到脚本中。以下是对上一个示例的微小变化,使用了 name 变量。
列表 6.10. 带有绑定变量的脚本
package mjg
println "Hello, $name!"
再次强调,这里唯一的真正区别是 print 语句使用了一个在脚本内部未声明的 name 变量。这意味着它可以像以下测试中所示的那样从外部传递。
列表 6.11. 设置绑定变量以测试脚本
@Test
void testHelloName() {
Binding binding = new Binding()
binding.name = 'Dolly'
def content = new StringWriter()
binding.out = new PrintWriter(content)
GroovyShell shell = new GroovyShell(binding)
shell.evaluate(new File('src/main/groovy/mjg/hello_name.groovy'))
*assert* "Hello, Dolly!" == content.toString().trim()
}
name 变量被设置为 Dolly,结果与之前确认一致。
6.2.1. GroovyTestCase 的有用子类:GroovyShellTestCase
脚本和绑定的组合足够常见,以至于 Groovy API 现在包括了类 groovy.util.GroovyShellTestCase。这是一个 GroovyTestCase 的子类,它在 setUp 方法中实例化了一个 GroovyShell。shell 被作为一个受保护的属性提供,但该类还包括一个 withBinding 方法,该方法接受一个参数 Map 和一个要执行的闭包。以下列表展示了本节中 Groovy 脚本的测试。
列表 6.12. 使用 GroovyShellTestCase 测试 Groovy 脚本

第一个测试找到要运行的脚本,并使用在超类中实例化的 shell 来执行它。其他测试使用 withBinding 方法覆盖 out 变量并提供一个输入参数。结果与直接实例化 GroovyShell 和 Binding 类相同。
之前的示例展示了如何从脚本中捕获标准输出,但通常脚本会返回具体的值。withBinding 方法返回脚本返回的任何内容。作为一个简单的例子,考虑以下强大的 Groovy 计算器,保存在一个名为 calc.groovy 的文件中:
z = x + y
由于三个变量(x、y 和 z)都没有声明,它们都可以通过脚本的绑定来访问。下一个列表展示了对此脚本进行测试的示例,该测试验证了返回的值。
列表 6.13. 对加法脚本 calc.groovy 的测试
void testAddition() {
def result = withBinding( [x:3,y:4] ) {
shell.evaluate(new File('src/main/groovy/mjg/calc.groovy'))
shell.context.z
}
*assert* 7 == result
}
闭包的最后一行访问了 z 变量,其值是从绑定中检索的。
标准库中还有一个名为 GroovyLogTestCase 的 GroovyTestCase 子类,用于测试日志记录。这个类是下一小节的主题。
6.2.2. GroovyTestCase 的有用子类:GroovyLogTestCase
良好的开发者不会依赖于捕获标准输出。相反,他们使用日志记录器将输出定向到可以稍后访问的位置。Java 早已内置了基本的日志记录功能,可以作为日志 API 实现的前端。
Java 日志类,如 Logger 和 Level,位于 java.util.logging 包中。以下是一个使用这些类的例子,它是上一节中计算器脚本的微小变化,存储在一个名为 calc_with_logger.groovy 的文件中。
列表 6.14. 使用日志记录器的脚本
import java.util.logging.Logger
Logger log = Logger.*getLogger*(this.class.name)
log.info("Received (x,y) = ($x,$y)")
z = x + y
Logger 类的静态 getLogger 方法是一个工厂方法,用于为这个特定组件创建一个 Logger 实例。在这里,我使用了脚本的名称,它将成为生成的类的名称。再次强调,变量 x、y 和 z 是脚本绑定的部分。日志提供与各种日志级别相对应的方法。在标准中,内置的级别包括 finest、finer、fine、info、warning 和 severe。在这个特定的情况下,输入参数正在以信息级别进行日志记录。要将此脚本执行为 x 和 y 设置为 3 和 4,请使用以下代码:
Binding b = new Binding(x:3, y:4)
GroovyShell shell = new GroovyShell(b)
shell.evaluate(new File('src/main/groovy/mjg/calc_with_logger.groovy'))
println shell.context.z
结果类似于这个(日期和时间可能有所不同):
Jun 24, 2013 12:21:19 AM
org.codehaus.groovy.runtime.callsite.PojoMetaMethodSite$PojoCachedMethod
SiteNoUnwrap invoke
INFO: Received (x,y) = (3,4)
7
默认的日志记录器包括一个控制台“附加器”,将所有日志输出定向到控制台。但是,捕获标准输出的机制在这里不起作用。相反,Groovy 提供了一个名为 GroovyLogTestCase 的类,其中包含一个名为 stringLog 的静态方法,用于此目的。下面的列表展示了演示其使用的一个测试。
列表 6.15. 在测试用例中捕获日志输出
class CalcWithLoggerTests extends GroovyLogTestCase {
void testAddition() {
def result = stringLog(Level.INFO, calc_with_logger.class.name) {
Binding b = new Binding()
b.x = 3; b.y = 4
GroovyShell shell = new GroovyShell(b)
shell.evaluate(
new File('src/main/groovy/mjg/calc_with_logger.groovy'))
assert 7 == shell.context.z
}
assert result.contains('INFO: Received (x,y) = (3,4)')
}
}
stringLog 方法返回日志输出作为字符串,用于检查日志记录器是否正常工作。
本书中的大多数脚本都是使用本节中描述的技术进行测试的。然而,如果脚本(或任何类)有依赖项,则需要做更多的工作。
Lessons learned (testing scripts)
1. Groovy 脚本提供自己的挑战,尤其是在尝试捕获输入或输出数据以及记录结果时。
2. Groovy 源代码可以通过
GroovyShell和Binding类程序化执行,然后执行任何包含的assert方法。3. 可用于简化脚本测试的
GroovyTestCase的特殊子类。
真正的单元测试意味着测试一个独立的类。测试的成功或失败不应依赖于任何相关对象。任何依赖对象都应被模拟或存根对象替换,当访问时返回预定的值。
这是在使用 Groovy 时比使用 Java 更容易处理的一个领域。Groovy 有几个内置机制用于创建模拟或存根对象,我将在下一节中对其进行回顾。
6.3. 隔离测试类
在面向对象的程序中,没有哪个类是孤岛。类通常有依赖关系。集成测试使用所有依赖关系以及正在测试的类(通常称为 CUT,即原因),但要真正测试一个给定的类,你需要将其从其环境中隔离出来。
为了隔离一个类,你需要提供它从依赖关系中需要的东西,以便它能够完成其工作。例如,如果一个类处理从系统其余部分提取的数据,你需要以受控的方式提供这些数据,而不涉及系统的其余部分。
形式上,正在被测试的类被称为调用者,它所依赖的类被称为协作者。目标是提供所有协作者的受控实现,以便调用者可以单独进行测试。
在本节中,我们将查看一个与第七章中展示的类似示例。它足够简单,易于理解,但又不完全是人为的。示例是一个经典的银行账户。有一个Account类,一个AccountDAO接口,一个File-AccountDAO实现,以及一个AccountService类。布局如图 6.3 所示。想法是服务将有一个名为transferFunds的方法,该方法设置事务边界,DAO 类为Account执行持久化,而Account本身只是一个将被保存和从某些持久化结构中恢复的实体。
图 6.3. 使用服务和一个基于平面文件的 DAO 实现的简单银行系统的 UML 图。虚线开放箭头表示依赖关系,实线开放箭头是关联,虚线闭合箭头表示实现。

在这个例子中,我将使用一个简单的文件进行持久化。通常我会使用数据库,但我想说明如何使用代表文件的存根在 Groovy 中进行单元测试。在这个过程中,我将讨论单元测试和集成测试之间的区别。到目前为止,本章中的测试还没有尝试模拟任何依赖对象,因此它们可以被认为是集成测试。现在我将查看如何进行真正的单元测试。
除了基本类之外,图 6.3 还显示了以下小节(强制闭包和展开)中将要使用的技术。
(程序性)客户端会通过在服务类AccountService上调用方法来使用银行系统,这个服务类可能具有事务性。服务类使用AccountDAO接口的实现来处理单个账户。Account类本身是一个简单的 POJO。
下面的几节展示了服务和 DAO 的实现代码,并说明了如何使用强制闭包和扩展来表示依赖对象。具体来说,当在服务类中测试逻辑时,使用闭包来表示 DAO。当测试 DAO 实现时,扩展体代替了File类。
6.3.1. 强制闭包
让我从下一列表中的AccountService开始。
列表 6.16. AccountService(Java):使用AccountDAO查找Accounts

再次强调,为了保持简单,AccountService只有两个业务方法:一个transferFunds方法用于将资金从一个账户转移到另一个账户,一个get-Balance方法委托给Account中的相应方法。这两个方法都接受整数id作为参数,并使用AccountDAO查找相应的账户。因此,为了完成其工作,AccountService需要一个AccountDAO实例。
AccountService与AccountDAO相关联。真正的单元测试会单独测试这个类,这意味着我需要为AccountDAO类提供一个某种存根。AccountDAO实际上是一个接口,如下一列表所示。
列表 6.17. AccountDAO接口,包含Account类的 CRUD 方法
public interface AccountDAO {
Account findAccountById(int id);
Collection<Account> findAllAccounts();
int createNewAccount(double balance);
void deleteAccount(int id);
}
如果我创建了一个AccountDAO接口的存根实现,我需要实现所有这些方法。但是请注意,AccountService只使用接口中的一个方法:findAccountById。这正是我实际需要的方法。不幸的是,我无法只实现这个方法。在实现接口时,我需要实现所有它的方法,无论我是否打算使用它们。
我可以使用 Groovy 技术来避免所有额外的劳动。如果我为关心的方法提供具有相同参数列表的闭包,我就可以将闭包“强制”为接口。闭包成为具有相同参数列表的接口中所有方法的实现。
在这种情况下,我想为findAccountById方法提供一个实现,该方法接受一个整数id并返回一个Account。我将使用一个映射来完成这个任务:
Account a1 = new Account(1,100)
Account a2 = new Account(2,100)
def accounts = [1:a1, 2:a2]
Account类(未显示,但它是一个简单的 POJO,包含在书籍源代码中)有一个接受id和初始balance的两个参数的构造函数。我创建了两个 ID 为 1 和 2 的账户,并将它们添加到以 ID 值为基础的映射中。现在我需要实现我的方法的闭包:
{ id -> accounts[id] }
这是一个单参数闭包,其占位符变量再次称为id,返回存储在该 ID 下的Account。有了这个机制,我可以为服务类提供一个 DAO 的存根实现,如以下列表所示。
列表 6.18. AccountService的 JUnit 4 测试用例,使用 Groovy 编写,并带有存根化的 DAO

在setUp方法(带有@Before注解)中,我使用as运算符将闭包视为AccountDAO接口。这意味着闭包将被用作接口中所有方法的实现。因为 DAO 接口中使用的唯一方法是findAccountById,所以我可以将一个转换后的闭包分配给服务中的dao属性(通常通过setDao方法进行),然后完成。testTransferFunds方法验证两个账户的初始余额是否符合预期,执行转账操作,然后检查更新的余额是否正确,考虑到比较双精度浮点数需要一个表示精度的第三个参数。
如果我需要在接口中使用闭包实现多个方法,我可以提供一个闭包到方法名的映射,其中每个闭包都有正确的参数列表。例如,以下列表显示了一个表示整个AccountDAO接口的闭包映射和一些展示其工作方式的测试。
列表 6.19. 使用闭包映射实现接口

核心观点是闭包可以用作接口的实现,而且这是一种简单且非常强大的技术,用于提供协作者的存根实现。
接下来我想测试使用平面文件存储账户的 DAO 实现类。在这种情况下,目标将是提供一个存根来代替java.io.File类。
6.3.2. Expando 类
我打算使用文件作为我的持久化机制,但在测试环境中,我将保持一个账户的缓存映射。这意味着当我初始化 DAO 时,我需要读取文件并将找到的账户存储在映射中,每次我对账户进行更改时,我需要将结果写回文件。在读取数据时,我只需使用映射——除非文件已被更改,在这种情况下,我必须重新读取文件.^([8])
我从 Jeff Brown 那里得到了这种使用 expando 的想法,他是《Grails 2 definitive guide》(Apress,2013 年)不知疲倦的合著者。
首先,这是我的FileAccountDAO类中的属性:
def accountsFile
Map<Integer, Account> accounts = [:]
private static int *nextId*
boolean dirty
我故意将表示账户文件的变量声明为 def 类型而不是 File 类型,原因将在创建存根时解释。其他属性是一个映射,用于表示账户缓存(使用泛型,Groovy 可以成功编译但不会强制执行^([9])),一个 private static 整数,将作为我的主要键生成器,以及一个布尔标志,用于指示账户缓存是否需要刷新。
⁹ 这又是一个微妙的陷阱。Java 泛型的语法在 Groovy 中可以编译,但仅仅因为你声明了一个
List<Integer>并不意味着你不能添加String、Date或Employee的实例。在 Groovy 中,将泛型声明视为仅仅是文档。
这是用来从文件中读取账户的方法:
void readAccountsFromFile() {
accountsFile.splitEachLine(',') { line ->
int id = line[0].*toInteger*()
double balance = line[1].*toDouble*()
accounts[id] = new Account(id:id,balance:balance)
}
*nextId* = accounts?.keySet().*max*() ?: 0
*nextId*++
dirty = false
}
每个账户都以纯文本形式存储,用逗号将 id 和 balance 分隔开。读取账户使用 splitEachLine 方法,该方法接受两个参数:分隔符(在这种情况下是逗号)和一个闭包,该闭包定义了对结果列表的操作。闭包指示将 ID 和余额解析到适当的数据类型,使用结果值实例化一个账户,并将其保存到映射中。然后我需要将 nextId 变量设置为迄今为止使用的 ID 的最大值加一,这给了我使用酷炫的 Elvis 操作符的机会.^([10]) 最后,因为此方法刷新了缓存,我可以将 dirty flag 设置为 false。
¹⁰ 我并不是刻意寻找使用酷炫的 Elvis 操作符的借口,但当我遇到它们时,也不会错过。
下一个示例显示了写入账户的相应方法:
void writeAccountsToFile() {
accountsFile.withWriter { w ->
accounts.each { id, account ->
*w.println*("$id,$account.balance")
}
}
dirty = true
}
withWriter 方法来自 Groovy JDK,并被添加到 java.io.File 类中。它提供了一个包装在文件周围的输出写入器,当闭包参数完成时自动关闭。闭包将每个账户的 ID 和余额写入文件的同一行,用逗号分隔。因为此方法会更改文件,所以它将 dirty 标志设置为 true,以便类知道缓存需要刷新。
在这些方法到位之后,下一列表显示了完整的 DAO 实现。
列表 6.20. 完整的 FileAccountDAO 实现示例,使用 Groovy

业务方法很简单,基于账户缓存(映射)。唯一的复杂性是确定在返回值之前是否需要刷新缓存。更改账户的方法会强制写入文件。检索它们的方法只需要检查是否需要读取。
这是一段相当多的代码,如果它没有被测试,我会感到非常不舒服。集成测试会简单地向 DAO 提供一个实际的文件,我在书中的源代码中就有这样一个测试。然而,单元测试会消除对 File 类的依赖。这就是 Expando 发挥作用的地方。
groovy.util.Expando 类创建了一个没有任何属性或自己方法的对象,除了它继承的方法。酷的地方在于你可以将 Expando 的实例当作一个映射来处理,其中键是属性或方法的名称,值是属性值或方法实现。
Expando
groovy.util.Expando 是一个类,你可以创建一个空对象,然后根据需要向其中添加属性和方法。
为了看到这个动作,让我创建一个 Expando 来作为我的 DAO 中文件的替代品。首先,我必须看到 File 中哪些方法需要表示。
这里是 AccountDAO 中使用 accountsFile 依赖项的方法。我需要模拟的方法用粗体标出:
private void readAccountsFromFile() {
accountsFile.splitEachLine(',') { line ->
int id = line[0].*toInteger*()
double balance = line[1].*toDouble*()
accounts[id] = new Account(id:id,balance:balance)
}
*nextId* = accounts?.keySet().*max*() ?: 0
*nextId*++
dirty = false
}
private void writeAccountsToFile() {
accountsFile.withWriter { w ->
accounts.*each* { id, account ->
w.println("$id,$account.balance")
}
}
dirty = true
}
检查前面的列表显示,我在 File 类中使用了 splitEachLine 和 withWriter,以及 Writer 类的 println 方法,因此这些方法需要在 Expando 中实现。
所有这些方法已经在 String 类中实现了。因此,为什么不使用字符串来表示文件呢?我将在 Expando 中添加一个字符串属性,然后实现所有需要的功能,以便它们委托到字符串上的相应方法。以下是生成的代码:
Expando ex = new Expando()
ex.data = ''
ex.*println* = { data.append(it) }
ex.withWriter = { new StringWriter() }
ex.splitEachLine = { pattern, clos ->
data.splitEachLine(pattern, clos) }
首先,我实例化 Expando。然后,我向其中添加一个 data 属性并将其分配给一个空字符串。然后通过 String 上的 append 方法实现 println 方法。withWriter 方法被分配一个返回新的 StringWriter 的闭包。最后,splitEachLine 方法被分配给一个接受两个参数的闭包,该闭包委托到 String 上对应现有的方法。
剩下的就是用 Expando 替换 DAO 中的文件:
FileAccountDAO dao = new FileAccountDAO(accountsFile:ex)
最后,这就是为什么我需要用 def 而不是 File 来声明 accountsFile 变量的原因。Expando 不是一个文件,并且与 File 类没有任何关系,所以 File 引用会是一个问题。如果我用 def 来声明变量,我就可以自由地将 Expando 变量分配给我的变量。鸭子类型会完成剩下的工作;每次在变量上调用方法时,都会在 Expando 上调用相应的方法。
动态类型来拯救
如果我使用 def 声明一个引用,我可以将其分配给任何东西。当我调用它的方法时,我依赖于在所使用的任何类中都有这些方法。
下一个列表显示了文件 DAO 的完整单元测试。
列表 6.21. 使用 Expando 模拟 File 的 FileAccountDAO 单元测试
class FileAccountDAOUnitTests {
FileAccountDAO dao
@Before
void setUp() {
Expando ex = new Expando()
ex.data = ''
ex.splitEachLine = { pattern, clos ->
data.splitEachLine(pattern, clos) }
ex.withWriter = { new StringWriter() }
ex.*println* = { data.append(it) }
dao = new FileAccountDAO(accountsFile:ex)
}
@Test
void testCreateAndFindNewAccount() {
int id = dao.createNewAccount(100.0)
Account local = new Account(id:id,balance:100.0)
Account fromDao = dao.findAccountById(id)
assertEquals local.id, fromDao.id
assertEquals local.balance, fromDao.balance, 0.01
}
@Test
void testFindAllAccounts() {
(1..10).*each* { num -> dao.createNewAccount(num*100) }
def accounts = dao.findAllAccounts()
assertEquals 10, accounts.size()
accounts*.balance.*each* { it in (100..1000) }
}
@Test
void testDeleteAccount() {
(1..10).*each* { num -> dao.createNewAccount(num*100) }
def accounts = dao.findAllAccounts()
assertEquals 10, accounts.size()
accounts.*each* { account -> dao.deleteAccount(account.id) }
assert 0 == dao.findAllAccounts().size()
}
}
在某种程度上,我在这个例子中很幸运。我需要模拟的变量 accountsFile 被公开为一个属性,所以我可以从外部将其分配给它。如果情况不是这样呢?如果变量是在类内部实例化的呢?那时能做些什么呢?
如果我受限于 Java,我就没有运气了.^(11)) 事实上,甚至模拟框架也难以处理这种情况。幸运的是,Groovy 有一个内置机制来处理这个问题。我需要的类被称为StubFor和MockFor。
^(11) 除非我有 AspectJ 可用,但即使那样,解决方案也很复杂。
6.3.3. StubFor 和 MockFor
一个典型的 Groovy 开发者不一定花很多时间进行元编程,但他们确实享受到了它的好处。我在这本书的几个地方使用了构建器。像 GORM 这样的领域特定语言(DSL)是通过元编程技术构建的。整个 Groovy JDK 是通过元类操作创建的。在上一个章节中,我使用了一个Expando来创建测试对象,而这只适用于支持元编程的语言。过了一段时间,你会习惯于元编程的能力,并且不再对它们的益处感到惊讶。
在本节中,我将展示一种技巧,即使在我多年的 Groovy 编程经验之后,它仍然感觉像是魔法。我知道它有效,并且我会在任何可能的地方使用它,但每次发生这种情况,我都要花点时间坐下来,微笑着欣赏它的酷炫。
让我直接展示我想展示的例子,然后解释存根技术。与其使用迄今为止描述的银行账户系统,不如让我提醒你我在本书的几个章节中使用的地理编码器示例。下面的列表显示了 Groovy 棒球系统中的一部分Geocoder类,该系统在第二章中描述。
列表 6.22。回顾 Groovy 棒球Geocoder类

我为这个类编写了一个测试,但毫无疑问,它是一个集成测试。下面的列表显示了一个用 Groovy 编写的 JUnit 4 测试,用于地理编码器。
列表 6.23。GeocoderIntegrationTests.groovy:地理编码器的 JUnit 4 测试

一个Stadium(体育场)有街道、城市、州、纬度和经度,地理编码器的任务是获取地址,使用 Google 的地理编码服务,并使用结果更新纬度和经度。在设置与 Google 总部办公室对应的Stadium实例后,测试调用fillInLatLng方法,并检查更新的纬度和经度值是否在公差范围内。
这一切都很正常,但它要完成其工作,必须访问 Google 地理编码服务。这就是为什么它是一个集成测试.^(12))
^(12) 请参阅关于集成测试的定义。
如果我没有在线怎么办?更正式地说,有没有任何方法可以在不依赖外部 URL 的情况下测试fillInLatLng方法中的逻辑?
在线访问是通过XmlSlurper的parse方法处理的。该方法接受一个 URL,访问它,下载 XML 响应,将其解析为 DOM 树,并返回根元素。在一个正常的模拟中,我想用预定义的 DOM 树替换表达式“new XmlSlurper().parse(url)”。如果slurper是从外部提供到这个类中的,我可以创建一个存根并强制parse方法返回我想要的。不幸的是,slurper是在方法内部实例化的。
这里 Groovy 的MockFor和StubFor类就派上用场了。
存根与模拟
模拟对象有强烈的期望,而存根则没有。这意味着如果协作方法没有被正确次数和顺序调用,涉及模拟对象的测试将失败。对于存根,期望更宽松;你不需要按正确的顺序调用方法,尽管它确实强制了多重性要求。
从概念上讲,存根只是协作对象的替代品,因此重点是调用者。因为模拟的期望很强,你实际上是在测试调用者和协作者之间的交互,这被称为协议。
图 6.4 展示了我希望存根如何工作。
图 6.4。地理编码器依赖于在本地实例化的XmlSlurper来完成其工作。目标是修改其parse方法以返回所需值,即使slurper是测试方法中的局部变量。

我想让slurper的parse方法返回一个 DOM 树的根,这个树看起来就像谷歌地理编码器返回的那样,如果我能够访问它的话。获取这个值的最简单方法是在测试之前设置正确的 XML 树并解析它:
String xml = '''
<root><result><geometry>
<location>
<lat>37.422</lat>
<lng>-122.083</lng>
</location>
</geometry></result></root>'''
def correctRoot = new XmlSlurper().parseText(xml)
XML 字符串具有谷歌总部办公室的正确纬度和经度。为了获取正确的根值,我调用XmlSlurper的parseText方法。我的Geocoder类可以接受这个根并像通常一样遍历树以获取纬度和经度。
挑战是:在没有明显方法注入的情况下,我如何让我的Geocoder使用这个实现?解决方案是使用StubFor类并围绕它设置期望:
def stub = new StubFor(XmlSlurper)
stub.demand.parse { correctRoot }
StubFor 构造函数接受一个类引用并在其周围构建一个存根。然后我“要求”parse方法返回之前片段中计算出的树的根。
要让 Groovy 使用新的存根,请在use方法的闭包参数中调用测试:
stub.use {
geocoder.fillInLatLng(stadium)
}
use闭包是关键。通过 Groovy 元编程的魔力,当在fillInLatLng方法内部访问XmlSlurper的parse方法时,使用的是所需版本而不是实际实现。结果是测试了fillInLatLng方法的业务逻辑,而不依赖于slurper本身。
下一个列表显示了完整的测试。为了确保绝对不使用地理编码器的在线版本,我创建了一个地址错误的体育场。测试通过的唯一方式是如果 slurper 返回了预设的值。
列表 6.24. GeocoderUnitTest.groovy: 测试地理编码器即使不在线

测试设置了一个地址错误的 Stadium 实例。使用字符串数据生成正确的 DOM 树根,并使用存根的 demand 属性返回它。通过在 use 块内执行测试,正确的答案在适当的时候提供,测试成功。
StubFor 和 MockFor API 的功能远比这里展示的要多。你可以要求每次调用方法时返回不同的预设值。你可以通过在 StubFor 上使用 verify 方法(MockFor 会自动这样做)来验证方法是否以正确的次数和顺序被调用。有关详细信息,请参阅 API。
StubFor 和 MockFor 类的唯一真正限制是它们只能用来替换 Groovy 实现。你不能提供一个 Java 类并让它工作。尽管如此,如果你的服务是用 Groovy 实现的,它们仍然是你的测试工具箱中的无价之宝。^([13])
^(13)坦白地说,我花了几天时间才弄清楚如何使用
StubFor和MockFor,然后做了我最初应该做的事情:在 Groovy in Action 中查找它们。GinA(那时它被称为;第二版是 ReGinA)在几页纸上详细介绍了,非常整洁。这就是为什么 Groovy in Action 仍然是我有史以来最喜欢的技术书籍。
学习到的经验(模拟依赖项)
1. 要轻松创建接口的存根,使用闭包来实现方法。这被称为 闭包强制转换。
2.
Expando类没有属性或方法,但两者都可以在运行时添加以配置对象执行你想要的操作。3. 在标准库中的
StubFor和MockFor类可以用来创建模拟对象,即使它们在测试用例中替换了局部变量。^([14])^(14)如果你在这个章节中什么都不读,那就看看这一点。
到目前为止,本章中介绍的所有技术都是基于 Groovy 标准库中的现有类。然而,有一个新的测试库在 Groovy 社区中越来越受欢迎,不仅仅是因为它有一个巧妙的名称。Spock 框架易于学习,易于使用,并且是下一节的主题。
6.4. 测试的未来:Spock
与我遇到的其他任何框架相比,Spock 框架以更少的努力带来更高的生产力。花一点时间与 Spock 一起(例如,通过本节中的讨论),你就可以立即变得高效。Spock 提供了测试和强大的模拟能力,易于使用。
根据框架的开发者,^(15),Spock 这个名字是“specification”和“mock”的结合。这可能甚至是真的。然而,似乎更有可能的是,有人只是喜欢 Spock 这个名字,其余的都是巧妙的合理化。不可避免的结果是,关于该框架的任何讨论都会导致一系列与《星际迷航》相关的双关语。我最初的计划是避免它们,但事实上这是不可能的。(16)(17)
^(15) 活跃并乐于助人的 Peter Niederweiser,他在 Spock 邮件列表上。
^(16) 我完全赞同。
^(17) 例如,Spock 是一个用于企业测试的逻辑框架。测试得好,就能繁荣昌盛。我一直是,也永远将是,你友好的测试框架。
6.4.1. 寻找 Spock
Spock 的主要网站是spockframework.org, 实际上重定向到code.google.com/p/spock/的 Google 代码项目。在那里你可以找到包含大量有用信息的维基页面。像大多数现代酷项目一样,源代码托管在 GitHub 上,网址为github.com/spockframework/spock.你可以克隆存储库并手动构建,或者你可以从标准的 Maven 仓库安装发行版。
Spock 版本与 Groovy 版本相关联。Spock 的最新发布版本是 0.7-groovy-2.0。不要让低版本号让你气馁。^(18) Spock API 简单易用易懂,其采用速度非常快。^(19)
^(18) 当这本书印刷出版时,1.0 版本应该已经发布。
^(19) 从版本 2.3 开始,Spock 插件将默认包含在 Grails 中。
下一个列表中的 Gradle 文件显示了构建本章源代码所需的适当依赖关系。
列表 6.25. 使用 Gradle 构建和测试 Spock
apply plugin: "groovy"
repositories {
mavenCentral()
}
dependencies {
groovy "org.codehaus.groovy:groovy-all:2.1.5
testCompile "org.spockframework:spock-core:0.7-groovy-2.0"
}
Maven 中央仓库保存了 Groovy 发行版和 Spock 发布版本。依赖关系以通常的方式解码,组为“org.spockframework”,名称(或 Maven 中的工件 ID)为“spock-core”,版本号为 0.7-groovy-2.0。请注意,Spock 版本与 Groovy 版本相关联。
6.4.2. 测试得好,就能繁荣昌盛
Spock 测试都扩展了一个名为spock.lang.Specification的父类。除了它自己的方法外,Specification类还包括 JUnit 的@RunWith注解。结果是,Spock 测试可以在正常的 JUnit 测试基础设施中运行。
测试本身都有一种常见的格式。每个测试方法(称为固定装置)都使用def关键字声明,后面跟着一个描述测试预期完成什么的字符串。固定装置方法通常不接收任何参数。
列表 6.26 展示了一个简单的 Spock 测试,用于验证一些 String 的行为。按照惯例,Spock 测试用例以 Spec 结尾。这并不是一个要求,^([20]) 但它确实有助于使 Spock 测试易于识别,尤其是在你的系统同时使用 Spock 和 JUnit 测试时。
^(20) 在 Grails 中,Spock 测试确实必须以
Spec结尾。
列表 6.26. 一个验证基本 java.lang.String 行为的规范
import spock.lang.Specification;
class StringSpec extends Specification {
String llap
def setup() { llap = "Live Long and Prosper" }
def "LLaP has 21 characters"() {
expect: llap.*size*() == 21
}
def "LLaP has 4 words"() {
expect: llap.split(/*\W/*).size() == 4
}
def "LLaP has 6 vowels"() {
expect: llap.*findAll*(/*[aeiou]*/).size() == 6
}
}
该类扩展了 spock.lang.Specification,这使得它成为一个 Spock 测试。该规范正在测试一个 String,因此它有一个名为 llap 的属性。在 setup 方法中,llap 变量被赋值为字符串 “Live Long and Prosper。” setup 方法在每次测试之前运行,类似于 JUnit 4 中的 @Before。JUnit 3 包含一个名为 setUp 的方法,它执行相同的功能,但在 Spock 中,setup 方法以小写形式编写,并使用 def 关键字。
测试方法,在 Spock 文档中被称为特性方法,是以块结构编写的。在下面显示的每个测试方法中,都有一个名为 expect 的单个块。expect 块由一系列布尔表达式组成,每个表达式都必须评估为真,测试才能通过。
这三个示例测试检查(1)测试字符串中的字符数;(2)基于在非单词边界处拆分字符串,测试字符串中有四个单词;(3)测试字符串总共有六个元音,再次基于正则表达式。
与 JUnit 4 类似,Spock 测试可以验证是否抛出了异常。Spock 测试还可以验证没有抛出异常。考虑以下两个测试,它们被添加到之前的列表中:
def "Access inside the string doesn't throw an exception"() {
when: s.charAt(s.size() – 1)
then: notThrown(IndexOutOfBoundsException)
}
def "Access beyond the end of the string throws exception"() {
when: s.charAt(s.size() + 1)
then: thrown(IndexOutOfBoundsException)
}
这些测试使用 when/then 块,这些块用作刺激/响应对。when 块中可以添加任何代码,但 then 块必须由布尔表达式组成,就像 expect 一样。这些表达式会自动使用 Groovy 真值进行评估。这意味着非空引用、非空字符串和非零数字都会评估为真。
String 类中的 charAt 方法如果其参数是负数或者超出了字符串的末尾,将会抛出一个异常。前两个测试展示了这两种情况,使用了 thrown() 和 notThrown() 方法。如果你想进一步处理异常,可以使用 thrown 方法返回异常,语法上有两种变体。
Exception e = thrown()
或者
e = thrown(Exception)
其中 Exception 可以是任何特定的异常类。
考虑以下测试,它还介绍了极其有用的 old 方法。
列表 6.27. 另一个示例,展示了 old 方法
class QuoteSpec extends Specification {
String quote = """I am endeavoring, ma'am, to construct a
mnemonic memory circuit, using stone knives and bear skins."""
List<String> strings
def setup() { strings = quote.*tokenize*(" ,.") }
def "test string has 16 words"() {
expect: strings.size() == 16
}
def "adding a word increases total by 1"() {
when: strings << 'Fascinating'
then: strings.size() == old(strings.size()) + 1
}
}
tokenize 方法接受一组分隔符作为参数,并在这些位置分割字符串。结果是单词的 ArrayList。这已经很有趣了,但酷的部分在于测试中向列表添加新单词的部分。在这种情况下,列表的大小被评估了两次,一次是在 when 块执行之前,一次是在之后。表达式显示,之后的结果是之前的结果加上一。
6.4.3. 数据驱动规范
Spock 测试除了在其他测试框架中出现的特性外,还有一个额外的特性:数据驱动的^([21])规范。其理念是,如果你以 Groovy 可以迭代的格式提供一组数据,那么测试将运行每个条目通过任何提供的布尔条件。
²¹ 数据应该在 Android 上运行吗?(是的,那特别糟糕。抱歉。)
这比描述更容易展示。考虑 Spock 网站主页上显示的测试,在下一个列表中重复。它使用数据表中的名称输入到 expect 中,使用三个不同的数据源。
列表 6.28. 数据驱动 Spock 测试
class HelloSpock extends spock.lang.Specification {
@Unroll
def "#name should be #length"() {
expect:
name.size() == length
where:
name | length
"Spock" | 5
"Kirk" | 4
"Scotty" | 6
'McCoy' | 5
}
def "check lengths using arrays"() {
expect: name.size() == length
where:
name << ["Spock","Kirk","Scotty"]
length << [5,4,6]
}
def "check lengths using pairs"() {
expect: name.size() == length
where:
[name,length] << [["Spock",5],["Kirk",4],["Scotty",6]]
}
}
第一个测试中的 where 块包含一个数据表。列名(name 和 length)是变量,在 expect 块中引用。Groovy 会评估表中的每一行,并评估 expect 条件。这是一个易于理解且功能强大的优雅系统。虽然数据表是一个强大的结构,但实际上 Groovy 知道的任何可迭代的集合都可以工作。
第二个和第三个测试说明了相同的过程,但通过集合提供数据。第二个测试使用单独的列表来存储 name 和 length 的值。这意味着要理解测试数据,你必须匹配集合的索引。例如,“Spock”对应 5,“Kirk”对应 4,依此类推。第三个测试更容易可视化,因为数据被组织成有序对。你使用哪种机制(数据表、有序对集合、单个集合等)纯粹是一个风格问题。
Spock 的另一个有趣的部分是 @Unroll 注解。没有它,测试输出中列出的名称将是测试本身的名称。有了它,where 块的每一行都会创建一个不同的名称。
图 6.5 展示了在 Groovy 和 Grails 工具套件(这仅仅是 Eclipse 加上许多插件)中执行此测试的结果,作为一个 JUnit 测试。除了演示 Spock 测试与现有的 JUnit 基础设施一起运行外,测试还显示了使用 @Unroll 注解产生的输出差异。第二个和第三个测试使用方法名作为它们的输出。标记有 @Unroll 的第一个测试出现在“未根测试”下,其中每个测试都根据测试数据获得其唯一的名称。
图 6.5。Spock 数据驱动测试的结果。带有@Unroll注解的测试在 Eclipse 输出中显示为“未根植”,显示针对每组数据的不同输出消息。

如果你打算测试的类有依赖关系怎么办?这些依赖关系需要像之前讨论的那样进行存根或模拟。幸运的是,Spock 内置了自己的模拟能力。
6.4.4。三文鱼的问题
Spock 中的Specification类包含一个名为Mock的方法,用于创建模拟对象。如果你的依赖基于接口,Mock方法可以直接生成模拟对象,使用 Java 的动态代理机制。如果是类,Mock将使用 CGLIB 库扩展该类。
是时候举一个相对简单(而且相对愚蠢)的例子了。三文鱼是一种小型、毛茸茸的动物,繁殖力强,喜欢伏尔甘人,讨厌克林贡人。下面是一个用 Groovy 编写的Tribble类。
^(22) 有关详细信息,请参阅
en.wikipedia.org/wiki/The_Trouble_With_Tribbles,除非你还没有看过那个特定的星际迷航(原版系列)剧集。35 年(!)后仍然非常出色。
列表 6.29。Groovy 中的Tribble类
class Tribble {
String react(Vulcan vulcan) {
vulcan.soothe()
"purr, purr"
}
String react(Klingon klingon) {
klingon.annoy()
"wheep! wheep!"
}
def feed() {
def tribbles = [this]
10.*times* { tribbles << new Tribble() }
return tribbles
}
}
给三文鱼喂食会得到什么?不是胖三文鱼,而是一大群饥饿的小三文鱼。feed方法返回一个包含原始三文鱼加上 10 个更多三文鱼的列表。
重载的react方法接受伏尔甘人或克林贡人作为参数。如果是伏尔甘人,三文鱼会安抚伏尔甘人并满意地咕噜。如果是克林贡人,三文鱼会惹恼克林贡人并做出不良反应。Tribble类依赖于Vulcan和Klingon。
为了保持简单,Vulcan和Klingon都是接口。这里显示了Vulcan接口:
interface Vulcan {
def soothe()
def decideIfLogical()
}
伏尔甘人有一种被称为“安抚”的方法,由三文鱼触发,还有一种decideIfLogical方法,这个方法对这个测试来说并不必要。顺便说一句,实现存根的一个问题是你必须实现所有接口方法,即使这些方法与所讨论的测试无关。
克林贡人有点不同:
interface Klingon {
def annoy()
def fight()
def howlAtDeath()
}
三文鱼会“惹恼”克林贡人。克林贡人也会“战斗”和howlAtDeath,^(23) 这两个方法在这里并不需要。为了测试Tribble类,我需要为Vulcan和Klingon类创建模拟对象,适当地设置它们的期望,并测试三文鱼在每种情况下的行为是否适当。
^(23) 在星际迷航:下一代中,克林贡人在死亡时会嚎叫。据我所知,在原版系列中他们并没有这样做。
让我逐个展示测试。首先我会检查feed方法是否正常工作:
def "feed a tribble, get more tribbles"() {
when:
def result = tribble.feed()
then:
result.size() == 11
result.*every* {
it instanceof Tribble
}
}
when块调用feed方法。then块检查返回的集合中有 11 个元素,并且每个元素都是三文鱼。这个测试没有什么新奇的或不同寻常的地方。然而,当进行对 Vulcans 的反应测试时,我需要模拟Vulcan接口.^([24])
(24) 当我模拟一个 Vulcans 时,我感觉像麦克医生。
def "reacts well to Vulcans"() {
Vulcan spock = Mock()
when:
String reaction = tribble.react(spock)
then:
reaction == "purr, purr"
1*spock.soothe()
}
在 Spock 中使用Mock方法有两种方式。第一种如下所示:实例化类,并将其分配给适当类型的变量。该方法将实现声明类型的接口。第二种方式是将接口类型作为Mock方法的参数,这里没有展示。
一旦创建了模拟,when块使用模拟作为react方法的参数。在then块中,首先检查适当的反应,然后是有趣的部分。最后一行表示只有当在模拟上恰好调用一次soothe方法时,测试才通过,忽略任何返回值。
这是一个非常灵活的系统。基数可以是任何东西,包括使用下划线作为通配符(例如,(3.._)表示三次或更多)。
接下来是Klingon接口,以下测试进行了多项检查:
def "reacts badly to Klingons"() {
Klingon koloth = Mock()
when:
String reaction = tribble.react(koloth)
then:
1 * koloth.annoy() >> {
throw new Exception()
}
0 * koloth.howlAtDeath()
reaction == null
Exception e = thrown()
}
在模拟了克林贡人^([25])并调用了react方法之后,then块首先检查模拟上的annoy方法是否恰好被调用一次,并使用右移运算符通过抛出异常来实现该方法。下一行检查howlAtDeath方法根本未被调用。因为annoy方法抛出了异常,所以没有返回反应。最后一行验证确实抛出了预期的异常。
(25) 你如何模拟一个克林贡人?来自遥远的星系(rimshot)。
理念是即使模拟被配置为抛出异常,三文鱼测试仍然可以通过。测试验证了异常被抛出,而没有使测试本身失败。
6.4.5. 其他 Spock 功能
到目前为止展示的功能可能为 Spock 提供了一个预告。Spock 还有更多功能超出了本章的范围。例如,测试上的@Ignore注解会跳过该测试,但还有一个@IgnoreRest注解会跳过所有其他测试。@IgnoreIf注解检查一个布尔条件,如果条件评估为真则跳过测试。还有一个@Stepwise注解用于必须按特定顺序执行的测试,以及一个@Timeout注解用于执行时间过长的测试。
从 Spock 中学到的经验
1.
Spock测试扩展spock.lang.Specification2.
Specification类有一个 JUnit 运行器,因此 Spock 测试可以在你的现有 JUnit 基础设施中运行。3.
Spock测试名称是描述性句子。框架使用 AST 转换将它们转换为合法的 Groovy。4. 测试由块组成,如
expect或when/then。expect或then块中的表达式会自动评估 Groovy 的真理。5.
spock.lang.Specification中的old方法在执行when块之前评估其参数。6.
where块用于遍历测试数据,这些数据可以来自表格、数据库结果或 Groovy 可以遍历的任何数据结构。7. Spock 拥有内置的模拟能力。
Spock 的 wiki 包含许多示例,以及关于模拟细节(称为交互)的详细文档和更多内容。源代码还附带了一个 Spock 示例项目,你可以将其作为你项目的起点。Spock 是用 Gradle 构建的,它配置了所有依赖项,并且可以连接到其他 API,如 Spring。有关详细信息,请参阅文档和 API。^(26))
^([26] 见 Manning 出版的《Spock in Action》,作者 Ken Sipe,即将上市。)
6.5. 摘要
本章在测试领域覆盖了大量的内容。Groovy 引入了一个简单的 assert 语句,它可以用于脚本,并包括扩展 JUnit 功能的 GroovyTestCase 类。在管理依赖项方面,你可以使用闭包构建接口的存根实现,也可以使用 Expando 类构建更完整的存根。
Groovy 还提供了 StubFor 和 MockFor 类,它们可以用于测试交互。它们甚至可以为作为局部变量实例化的类创建模拟对象,这相当令人印象深刻。
最后,如果你愿意添加额外的库,Spock 测试框架提供了一个简单而灵活的 API,它仍然运行在你的现有基于 JUnit 的基础设施上。它还拥有自己的模拟能力,并与其他库,如 Spring 和 Tapestry 集成。
添加 Groovy 也为测试 Java 和混合 Java/Groovy 项目提供了广泛的选择。希望本章中的技术能帮助你决定在哪里从中获得最大的收益。
第三部分。现实世界中的 Groovy
在第三部分,“现实世界中的 Groovy”,我试图解决 Java 开发者经常面临的一些挑战。
我从 Spring 框架开始,这可能是 Java 世界中应用最广泛的开源项目。Spring 和 Groovy 是老朋友,它们配合得非常完美。第七章展示了如何在系统的任何地方使用 Groovy 类作为 Spring bean,包括方面。然后展示了 Spring 针对动态语言的独特功能,如可刷新的 bean、内联脚本 bean 和来自 Grails 的BeanBuilder类。
第八章涵盖了 Groovy 与持久存储的交互。Groovy 包含一个非常有用的 JDBC 封装,称为groovy.sql.Sql类,当与关系型数据库一起工作时非常有效。本章还提供了一个使用 GMongo 项目的示例,这是一个围绕 Java API 的 Groovy 包装器,用于与 MongoDB 一起工作。这是一个典型的 Groovy 惯用语——将 Java 库变得更容易使用。最后,本章讨论了许多与 GORM 相关的问题,GORM 是 Grails 的面向对象关系映射层,可能是目前 Groovy 中应用最广泛的领域特定语言。
第九章专注于 RESTful Web 服务,重点在于 JAX-RS 2.0 规范。大多数 JAX-RS 功能在 Groovy 和 Java 下操作方式相同,但提供了示例来展示如何处理超媒体应用程序。
本节最后一章是关于 Web 应用程序开发的。在第十章(Chapter 10)中,我从一个使用分类的 Groovy 元编程的好例子开始,具体来说,ServletCategory类被作为一个使用 Groovy 快速轻松完成工作的例子。接下来是关于 groovlets 的讨论,这些是通过 servlet 执行的 Groovy 脚本,使得运行简单的应用程序变得容易。本章最后展示了 Grails 框架作为 Groovy DSLs 的美丽组合,这些 DSLs 结合并配置基于 Spring/Hibernate 的 Web 应用程序。
第七章。Spring 框架
本章涵盖
-
在 Spring 应用程序中使用 Groovy 类
-
可刷新的 bean
-
内联脚本 bean
-
Grails 的
BeanBuilder类 -
使用 Groovy 类进行 Spring AOP
在 Java 框架中,Spring 是最成功的之一。Spring 将依赖注入、复杂对象生命周期管理和 POJO 的声明式服务等理念带到了 Java 开发的 forefront。几乎很少有项目不考虑充分利用 Spring 所提供的一切,包括其库中包含的众多 Spring“bean”。Spring 几乎触及了企业 Java 开发的各个方面,在大多数情况下,它极大地简化了这些方面。
在本章中,我将探讨 Groovy 如何与 Spring 框架交互。实际上,Groovy 和 Spring 是老朋友。Spring 管理 Groovy bean 就像它处理 Java bean 一样容易。Spring 包括与动态语言代码交互的特殊功能,我将在下面进行回顾。
Groovy 可以用来实现 bean 或配置它们。在本章中,我将尝试回顾 Groovy 如何帮助 Spring 的所有方式。图 7.1 包含本章讨论的技术指南。
图 7.1. 使用 Groovy 的 Spring 技术指南。Spring 管理 POGO 就像管理 POJO 一样容易,所以示例包括正常 bean 和方面的 Groovy 实现。在JdbcTemplate中,使用闭包强制转换实现RowMapper接口。可刷新的 bean 是可以在运行时修改的 Groovy 源文件。内联脚本 bean 包含在 XML 配置文件中。Grails 提供了一个BeanBuilder类来配置 bean。最后,Spock 有一个库,允许它与 Spring 的测试上下文功能一起使用。

为了展示 Groovy 如何帮助 Spring,我需要回顾 Spring 是什么,以及它是如何被使用和配置的。我将从一个简单但非平凡的示例应用开始。而不是展示所有组件(这些组件在本书的源代码库中),我将突出整体架构和 Groovy 部分。
7.1. 一个 Spring 应用
尽管 Spring 有诸多好处,但它对于不熟悉它的开发者来说是一个难以展示的框架。Spring 中的“Hello, World”应用会让你质疑为什么你想要它,因为它用几行额外的代码和大约 20 行 XML 替换了几个简单、易于理解、强类型的 Java 代码行。这并不完全是一个响亮的推荐。
要真正看到 Spring 的价值,你必须看到实际的应用,即使它在各种方式上被简化了。以下的应用模型了一个账户管理应用的服务和持久层。表示层是任意的,所以以下代码可以用于客户端或服务器端应用。在这种情况下,我将通过单元测试和集成测试来演示其功能。
Java 和 Groovy Spring Beans
与其他章节中在 Java 中构建整个应用然后转换为 Groovy 不同,为了节省空间,这个应用混合了两种语言。重点是 Spring 管理的 bean 可以用 Java 或 Groovy 实现,哪种更方便就用哪种。
考虑一个管理银行账户的应用。我将有一个代表账户的单个实体类,它只有id和balance,以及deposit和withdraw方法。
下面的列表显示了 Groovy 中的Account类,它比其 Java 对应物有明显的优势:它使得与java.math.BigDecimal一起工作变得容易。
列表 7.1. 使用BigDecimal的 Groovy Account POGO

财务计算是我们需要 java.math.BigDecimal 和 java.math.BigInteger 的原因之一。使用 BigDecimal 可以防止舍入误差进入账户,随着时间的积累,这些误差可能会累积起来.^([1]) 很容易展示舍入误差如何迅速变成问题。考虑以下两行代码:
¹ 如果你还没有看过 Office Space (
mng.bz/c6o8),你面前有一份真正的美味。
println 2.0d – 1.1d
println 2.0 – 1.1
第一行使用双精度浮点数,而第二行使用 java.math.BigDecimal。第一行计算结果为 0.8999999999999999,而第二行计算结果为 0.9。在 double 的情况下,我仅仅进行了一次计算,就已经有足够的误差显示出来。
在 Java 中编码时,使用 BigDecimal 是尴尬的,因为它是一个类而不是原始类型。这意味着你不能使用你正常的 +, *, - 操作符,而必须使用类的 API。
然而,由于 Groovy 具有操作符重载,因此这些都不必要。我可以简单地声明余额为 BigDecimal,然后其他所有事情都正常工作,即使我使用 Java 的 Account 类。
关于 Account 的一个额外说明:目前没有应用约束来确保余额保持正值。这只是为了说明目的,尽可能简单。
使用 Account 类的整体设计如图 7.2 所示。这是一种非常简单的分层架构形式,服务层提供事务支持,持久层由一个接口和一个 DAO 类组成,将在稍后讨论。
图 7.2. 一个简单的账户管理应用程序。事务在服务层中定义。持久层由一个实现接口并使用 Spring JdbcTemplate 访问嵌入式数据库的单个 DAO 类组成。

持久层遵循正常的 Data Access Object 设计模式。下面的列表显示了一个名为 AccountDAO 的 Java 接口,用 Java 编写。
列表 7.2. AccountDAO 接口,Java 编写
package mjg.spring.dao;
import java.util.List;
import mjg.spring.entities.Account;
public interface AccountDAO {
int createAccount(double initialBalance);
Account findAccountById(int id);
List<Account> findAllAccounts();
void updateAccount(Account account);
void deleteAccount(int id);
}
该接口包含将 Account 对象传输到数据库和返回的典型方法。有一个创建新账户、更新账户和删除账户的方法;一个通过 id 查找账户的方法;以及一个返回所有账户的方法。
使用名为 JdbcAccountDAO 的 Groovy 类实现接口,与 Spring 的 JdbcTemplate 一起工作。而不是展示整个类(可在书籍源代码中找到),让我只展示结构,然后之后强调 Groovy 方面。类的概要如下所示。
列表 7.3. 使用 JdbcTemplate 实现 AccountDAO,Groovy 编写

各种 查询 方法接受一个类型为 RowMapper<T> 的参数,其定义如下
public interface RowMapper<T> {
T mapRow(ResultSet rs, int rowNum) throws SQLException
}
当你在JdbcTemplate中执行query方法之一时,Spring 会获取ResultSet并将每一行通过RowMapper接口的实现传递。mapRow方法的任务是将该行转换为域类的实例。正常的 Java 实现可能是创建一个名为AccountMapper的内部类,其mapRow方法会从ResultSet行中提取数据并将其转换为Account实例。将AccountMapper类的实例提供给queryForObject方法后,将返回一个单独的Account。相同的实例也可以提供给query方法,该方法然后返回Account的集合。
这正是第六章中展示的闭包强制转换的类型。定义了一个名为accountMapper的变量,并将其分配给一个与所需的mapRow方法具有相同参数的闭包。然后,该变量在findAccountById和findAllAccounts方法中都被使用。
这里有两个使用 Groovy 的地方:
1. 一个 Groovy 类实现了 Java 接口,这使得集成变得容易并简化了代码。
2. 闭包强制转换消除了预期的内部类。
在书中源代码的示例中,我还包括了图 7.2 中引用的服务类。它使用 Spring 的@Transactional注解来确保每个方法都在所需的交易中操作。它本身并没有 Groovy 的特性,所以在这里我将只展示下一个列表中实现的概要。
列表 7.4. Java 中AccountService类的一部分

@Autowired注解由 Spring 用于将实现AccountDAO接口的类的实例(注入)到服务类中。有关自动装配的更多详细信息,请参阅 Spring 文档^([2])。
服务实现主要在 Java 中,因为没有在 Groovy 中实现它的明显优势,尽管我可以轻松地做到这一点。
拼图中的最后一部分是 Spring 的 bean 配置文件。书中源代码中的配置使用 XML 和组件扫描的组合来配置存储库和服务类。同样,其中没有任何内容使用 Groovy,所以在这里不会展示。记录在案,该示例使用 Spring 的<embedded-database>标签在内存中设置一个示例 H2 数据库,每次运行都会重新初始化。其余部分如描述所述。
现在回到 Groovy,我想展示下一个列表中的 Gradle 构建文件。
列表 7.5. 账户应用的 Gradle 构建文件
apply plugin:'groovy'
apply plugin:'eclipse'
repositories {
mavenCentral()
}
def springVersion = '3.2.2.RELEASE'
def spockVersion = '0.7-groovy-2.0'
dependencies {
compile "org.codehaus.groovy:groovy-all:2.1.5"
compile "org.springframework:spring-context:$springVersion"
compile "org.springframework:spring-jdbc:$springVersion"
runtime "com.h2database:h2:1.3.172"
runtime "cglib:cglib:2.2"
testCompile "org.springframework:spring-test:$springVersion"
testCompile "org.spockframework:spock-core:$spockVersion"
testCompile "org.spockframework:spock-spring:$spockVersion"
}
构建文件是本书迄今为止介绍的项目中典型的。它声明了 Groovy 和 Eclipse 插件。它使用 Maven central 作为仓库。依赖项包括 Groovy 和 Spock,如常。通过声明 spring-context 和 spring-jdbc 依赖项添加了 Spring。这些依赖项最终添加了几个其他与 Spring 相关的 JAR 文件。h2database 依赖项用于嵌入式数据库所需的 H2 驱动程序。
一个有趣的补充是 spock-spring 依赖项。Spring 包含一个基于 JUnit 的强大测试框架,它自动缓存 Spring 应用程序上下文。spock-spring 依赖项允许 Spock 测试与 Spring 测试上下文一起工作。
第一个测试类是对 JdbcAccountDAO 的 Spock 测试。以下列表显示了完整测试集的一些测试。
列表 7.6. JdbcAccountDAO 实现的 Spock 测试
import spock.lang.Specification;
@ContextConfiguration("classpath:applicationContext.xml")
@Transactional
class JdbcAccountDAOSpec extends Specification {
@Autowired
JdbcAccountDAO dao
def "dao is injected properly"() {
expect: dao
}
def "find 3 accounts in sample db"() {
expect: dao.findAllAccounts().size() == 3
}
def "find account 0 by id"() {
when:
Account account = dao.findAccountById(0)
then:
account.id == 0
account.balance == 100.0
}
// tests for other methods as well
}
@ContextConfiguration 注解告诉测试运行器如何找到 Spring bean 配置文件。添加 @Transactional 表示每个测试都在一个必需的事务中运行,在每个测试结束时自动回滚(这部分很酷),意味着数据库在每个测试开始时重新初始化。DAO 自动注入到测试类中。各个测试检查 DAO 中的所有方法是否按预期工作。
以下列表显示了服务类的测试,其中包括使用 第六章 中描述的 Spock 的 old 方法进行测试。
列表 7.7. 服务类的 Spock 测试
import spock.lang.Specification
@ContextConfiguration("classpath:applicationContext.xml")
@Transactional
class AccountServiceSpec extends Specification {
@Autowired
AccountService service
def "balance of test account is 100"() {
expect: service.getAccountBalance(0) == 100.0
}
// ... other tests as necessary ...
def "transfer funds works"() {
when:
service.transferFunds(0,1,25.0)
then:
service.getAccountBalance(0) ==
old(service.getAccountBalance(0)) - 25.0
service.getAccountBalance(1) ==
old(service.getAccountBalance(1)) + 25.0
}
}
与之前一样,注解允许 Spock 测试与 Spring 的测试框架一起工作,该框架缓存应用程序上下文。我使用了 Spock 的 old 操作来检查存款或取款后的账户余额变化。使用 Spock 与 Spring 测试上下文不需要其他添加。
尽管这个应用程序很简单,但它展示了 Spring 的许多功能,从声明式事务管理到自动装配,再到简化 JDBC 编码,再到有效的测试。从 Spring 的角度来看,Groovy bean 只是另一种名称的字节码。只要 groovy-all JAR 文件在类路径中,Spring 就非常乐意使用用 Groovy 编写的 bean。
Spring 管理 Groovy 中的 bean 和管理 Java 中的 bean 一样容易。尽管如此,Spring 为动态语言提供的 bean 也具有一些特殊功能。我将在下一节中展示这些功能,从可以在运行系统中修改的 bean 开始。
7.2. 可刷新的 bean
自 2.0 版本以来,Spring 为 Groovy 等动态语言提供了特殊功能。一个特别有趣、但可能危险的选项是部署所谓的 可刷新 bean。
对于可刷新的 bean,而不是像往常一样编译类,你需要部署实际的源代码,并告诉 Spring 在哪里可以找到它以及多久检查一次是否有变化。Spring 在每个刷新间隔结束时检查源代码,如果文件已被修改,它将重新加载 bean。这给了你在系统仍在运行时更改已部署类的机会.^([3])
³ 是的,这也让我感到害怕。蜘蛛侠的推论也适用:能力越大,责任越大。
我将演示一个稍微有些牵强但希望有趣的例子。在前一节中,我介绍了一个用于管理账户的应用程序。现在,让我假设账户管理员,可能是一种银行,决定进入抵押贷款业务。我现在需要一个代表抵押贷款申请的类,客户会提交这个申请以供批准。我还需要一个抵押贷款评估器,我将在 Java 和 Groovy 中实现它。整个系统如图 7.3 所示。图 7.3。
图 7.3. GroovyEvaluator是一个可刷新的 bean。源代码已部署,Spring 在每个刷新间隔后检查其变化。如果它已更改,Spring 将重新加载 bean。

为了使这个例子简单,抵押贷款申请类只包含代表贷款金额、利率和所需年数的字段,如下一列表所示。
列表 7.8. Groovy 中的简单抵押贷款申请类
class MortgageApplication {
BigDecimal amount
BigDecimal rate
int years
}
如前所述,使用 Groovy 只是为了减少代码量,并使其更容易处理BigDecimal实例。这个类的实例被提交给银行,银行会运行它通过抵押贷款评估器来决定是否批准它。以下列表显示了一个代表评估器的 Java 接口,它将在 Java 和 Groovy 中实现。
列表 7.9. Java 中的Evaluator接口
public interface Evaluator {
boolean approve(MortgageApplication application);
}
接口只包含一个方法,approve,它接受一个抵押贷款申请作为参数,如果申请被批准则返回true,否则返回false。
假设现在是 2008 年的夏天。公众对像信用违约掉期这样的术语一无所知,而银行则急于尽可能多地贷款给尽可能多的人。换句话说,这里是一个Evaluator接口的 Java 实现。
列表 7.10. 一个相当宽容的 Java 评估器
public class JavaEvaluator implements Evaluator {
public boolean approve(MortgageApplication application) {
return true;
}
}
这是一个非常宽容的贷款政策,但如果每个人都这样做,可能会出什么问题?
当然,出了问题的是,在 2008 年夏末和秋初,贝尔斯登崩溃,雷曼兄弟破产,美国经济几乎崩溃。银行需要尽快止血。如果现有的评估器是刚刚显示的 Java 评估器,那么系统必须停机以便修改。担心的是,如果系统离线,客户可能会担心它永远不会再回来.^([4])
⁴ 这是一个《美好人生》的引用:“乔治,如果你关上这些门,你就再也打不开它们了!”
然而,还有一种可能性。考虑一下贷款评估器的 Groovy 版本,其行为与 Java 版本等效,如下所示。
列表 7.11. 作为源代码部署的 Groovy 贷款评估器
class GroovyEvaluator implements Evaluator {
boolean approve(MortgageApplication application) { true }
}
再次,它简单地返回 true,就像 Java 版本一样。然而,这次我不想编译这个类并像往常一样部署它,而是想创建一个可刷新的 bean。为此,我需要在 Spring 配置文件中的 lang 命名空间中工作(假设我正在使用 XML;对于 Java 配置文件也有其他选择)。我还需要部署源代码本身,而不是该文件的编译版本。
部署源代码
注意,对于可刷新的 bean,你需要部署源代码,而不是编译后的 bean。
下一个列表显示了包含两个评估器的 bean 配置文件。注意添加了 lang 命名空间和 Groovy bean。
列表 7.12. 包含可刷新 Groovy 评估器 bean 的 bean 配置文件

Groovy 为动态语言(包括 Groovy、BeanShell 和 JRuby)的 bean 提供了一个命名空间。该命名空间中声明的一个元素是 <lang: groovy>,其 script-source 属性用于指向 Groovy 类的源代码。注意,与同一文件中的 Java 评估器 bean 不同,此属性指向实际的源文件,而不是编译后的 bean。对于该元素来说,另一个重要的属性是 refresh-check-delay,它表示 Spring 在多长时间(以毫秒为单位)后检查源文件是否已更改。这里延迟已设置为 1 秒。
现在是好玩的部分.^([5]) 下一个列表显示了一个演示应用程序,该应用程序加载 Groovy 评估器 bean 并调用 approve 方法 10 次,每次调用之间暂停 1 秒。
⁵ 严肃地说。这是一个在观众面前做的有趣演示。试试看。
列表 7.13. 加载 Groovy bean 并调用 approve 方法 10 次的演示应用程序

理念是启动演示程序,然后在迭代进行的同时,编辑源代码以将 approve 方法的返回值从 true 更改为 false。^([6]) 程序的输出类似于
⁶ 你注意到审批方法被一个 null 参数调用,承认抵押贷款申请根本无关紧要吗?这是玩笑的一部分,所以当你这样做的时候,一定要笑一笑。

在循环中途更改源代码以阻止出血。如果国会随后迅速采取行动并授予巨额政府救助金,则可以将其改回.^([8])
⁷ 哎呀。是的,这是一个糟糕的双关语,但却是无法抗拒的。
⁸ 或者不。
在运行系统中更改 bean 的实现能力非常强大,但显然也很危险。Spring 只将其提供给像 Groovy 这样的动态语言 bean。
可刷新 bean 的真实用例
尽管本节中展示的银行应用程序很有趣,但很少有公司会允许你在系统运行时将源代码部署到生产环境中并进行编辑。那么你实际上会在什么时候使用这个功能呢?
一些问题只有在系统负载下才会出现。想象一下,一个可刷新的 bean 就像是一个可适应的探测器,可以被服务器端开发者以受控的方式插入到基于 Spring 的系统。你不仅可以改变日志级别或其他属性(原则上你可以使用 JMX,Java 管理扩展来实现),你还可以实时改变探测器的行为并诊断实际发生的情况。
Dierk Koenig,Groovy in Action(Manning,2007)的主要作者,称这种模式为“钥匙孔手术”。当不知道进入时会发现什么时,它被用作一种微创手术.^([9])
⁹ 查看 Dierk 的演示文稿“Seven Groovy Usage Patterns for Java Developers”在www.slideshare.net上以获取更多详细信息。
在讨论其他仅限于动态语言 bean 的 Spring 能力,即内联脚本 bean 之前,让我先介绍另一个想法。Spring 的伟大之处之一是它提供了一个方便的基础设施来支持面向切面编程。我想讨论这意味着什么以及如何使用 Groovy 来实现一个切面。
7.3. 使用 Groovy bean 的 Spring AOP
Spring 的许多功能都是使用面向切面编程(AOP)实现的。Spring 提供了开发切面的基础设施。有趣的是,切面可以用 Groovy 和 Java 一样容易地编写。
AOP 是一个很大的主题,但在这里我可以总结一些关键特性。切面被设计来处理横切关注点,这些是适用于许多不同位置的功能。横切关注点的例子包括日志记录、安全和事务。每个都需要在系统的多个位置应用,这会导致相当多的重复,以及同一功能中不同类型功能的纠缠。
^(10) 关于 AOP 的完整讨论可以在 Ramnivas Laddad 的 AspectJ in Action 第二版(Manning,2009)中找到,www.mannin10g.com/laddad2/。
跨切面关注点被编写为方法,称为 建议。下一个问题是建议应用的位置。所有可用的建议应用位置统称为 切入点。给定 Aspect 的所选切入点的集合称为 切入点集合。建议和切入点的组合定义了 Aspect。
本节和下一节的示例应用如图 7.4 所示。figure 7.4。
图 7.4. Spring AOP 的实际应用。ChangeLogger 是一个记录每个 set 方法前消息的 Java Aspect。UpdateReporter 在 Groovy 中做同样的事情,但报告现有值。GroovyAspect 是在配置文件内部定义的内联脚本 Bean。

下面的列表展示了一个使用 Spring 注解编写的 Aspect 示例,该 Aspect 在每次即将调用 set 方法时应用,并记录被调用的方法和新的值。
列表 7.14. 一个记录属性变更的 Java Aspect
package mjg.aspects;
import java.util.logging.Logger;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
@Aspect
public class ChangeLogger {
private Logger log = Logger.*getLogger*(
ChangeLogger.class.getName());
@Before("execution(void set*(*))")
public void trackChange(JoinPoint jp) {
String method = jp.getSignature().getName();
Object newValue = jp.getArgs()[0];
log.info(method + " about to change to " +
newValue + " on " + jp.getTarget());
}
}
@Aspect 注解告诉 Spring 这是一个 Aspect。@Before 注解使用 AspectJ 切入点语言定义了切入点。^(11) 这个特定的切入点应用于所有以字母 set 开头、接受单个参数并返回 void 的方法。trackChange 方法是建议。JoinPoint 参数由 Spring 在 Aspect 被调用时提供。它提供了执行上下文。在这种情况下,JoinPoint 提供了检索被建议方法签名、方法提供的参数和目标对象的方法。
^(11) AspectJ 的文档托管在 Eclipse 上,详情请见
www.eclipse.org/aspectj/。
为了演示这个 Aspect 的实际应用,我需要配置 Spring 应用该 Aspect,并且需要一个被建议的对象。后者很容易实现。下一个列表展示了一个具有三个属性的简单类。
列表 7.15. 具有三个 set 方法的简单 POJO
package mjg;
public class POJO {
private String one;
private int two;
private double three;
public String getOne() { return one; }
public void setOne(String one) { this.one = one; }
public int getTwo() { return two; }
public void setTwo(int two) { this.two = two; }
public double getThree() { return three; }
public void setThree(double three) { this.three = three; }
@Override
public String toString() {
return "POJO [one=" + one + ", two=" + two +
", three=" + three + "]";
}
}
这个类被称为 POJO,它有三个属性,分别称为 one、two 和 three。每个属性都有一个 getter 和一个 setter。Aspect 将在每个 set 方法之前运行。
与完整的 AOP 解决方案相比,Spring 的 AOP 基础设施有一些限制。Spring 限制切入点仅限于 Spring 管理的 Bean 的公共方法边界。因此,我需要在 Spring 的配置文件中添加 POJO Bean。我还需要告诉 Spring 识别 @Aspect 注解并生成所需的代理。结果 Bean 配置文件在下面的列表中展示。
列表 7.16. AOP 的 Spring Bean 配置文件
<?xml version=*"1.0"* encoding=*"UTF-8"*?>
<beans xmlns=*"http://www.springframework.org/schema/beans"*
... namespace declarations elided ... >
<aop:aspectj-autoproxy />
<bean id=*"tracker"* class=*"mjg.aspects.ChangeLogger"* />
<bean id=*"pojo"* class=*"mjg.POJO"* p:one=*"1"* p:two=*"2"* p:three=*"3"*/>
</beans>
aop命名空间提供了<aspect-autoproxy>元素,它告诉 Spring 为所有带有@Aspect注解的类生成代理。tracker豆(bean)是之前展示的 Java 方面。pojo豆是刚刚讨论的POJO类。
现在,我需要调用set方法来查看方面(aspect)的实际效果。下面的列表显示了一个基于 JUnit 4 的测试用例,它使用了 Spring 的 JUnit 4 测试运行器,该运行器在测试之间缓存应用程序上下文。
列表 7.17. 一个用于测试 POJO 的 JUnit 4 测试用例
package mjg;
import static org.junit.Assert.*;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
@ContextConfiguration("classpath:applicationContext.xml")
@RunWith(SpringJUnit4ClassRunner.class)
public class POJOTest {
@Autowired
private POJO pojo;
@Test
public void callSetters() {
pojo.setOne("one");
pojo.setTwo(22);
pojo.setThree(333.0);
assertEquals("one", pojo.getOne());
assertEquals(22, pojo.getTwo());
assertEquals(333.0, pojo.getThree(),0.0001);
}
}
Spring 将POJO的实例注入到测试中并执行测试,该测试简单地调用三个 setter 并检查它们是否正常工作。有趣的部分在于控制台输出,它显示了方面在起作用:
INFO: setOne about to change to one on POJO [one=1, two=2, three=3.0]
INFO: setTwo about to change to 22 on POJO [one=one, two=2, three=3.0]
INFO: setThree about to change to 333.0 on POJO [one=one, two=22, three=3.0]
当方面被调用时,它会报告每个set方法的名称及其参数。一切如预期般工作。
然而,有一个问题。如果你想在 setter 更改属性之前知道每个属性的当前值,没有明显的方法可以找到。连接点(joinpoint)提供了对目标对象的访问,我知道正在调用set方法,但尽管在概念上我知道对于每个 setter 都有一个 getter,但确定如何调用它并不简单。确定适当的get方法可能需要结合反射和字符串操作,但这需要做些工作。
至少,除非我求助于 Groovy,否则需要做些工作。我可以像下一列表所示的那样,用几行 Groovy 代码完成我刚才描述的所有操作。
列表 7.18. 在属性更改之前打印属性值的 Groovy 方面
package mjg.aspects
import java.util.logging.Logger
import org.aspectj.lang.JoinPoint
import org.aspectj.lang.annotation.Aspect
import org.aspectj.lang.annotation.Before
@Aspect
class UpdateReporter {
Logger log = Logger.getLogger(UpdateReporter.class.name)
@Before("execution(void set*(*))")
void reportOnSet(JoinPoint jp) {
String method = jp.signature.name
String base = method – 'set'
String property = base[0].toLowerCase() + base[1..-1]
def current = jp.target."$property"
log.info "About to change $property from $current to ${jp.args[0]}"
}
}
UpdateReporter类是用 Groovy 编写的。它具有与 Java 方面相同的@Aspect和@Before注解。被调用的方法是以与 Java 方面相同的方式计算的,唯一的细微差别是 Groovy 访问signature和name属性,而不是显式调用相关的getSignature和getName方法。这实际上是一个预示,因为它意味着我真正需要做的只是找出属性的名称。
属性是通过取set方法的名称,减去字母set,并将结果转换为标准属性语法来找到的。现在我已经有了属性的名称,我只需要从目标对象中访问它,这将在下一行完成。我使用 Groovy 字符串来确保属性被评估。结果是,在三条 Groovy 代码中,我现在知道了属性的原始值。剩下的只是将其记录到标准输出。
要运行这个方面,我只需在配置文件中添加相应的豆:
<bean id=*"updater"* class=*"mjg.aspects.UpdateReporter"* />
现在如果运行相同的测试用例,输出将如下所示:
INFO: About to change one from 1 to one
INFO: setOne about to change to one on POJO [one=1, two=2, three=3.0]
INFO: About to change two from 2 to 22
INFO: setTwo about to change to 22 on POJO [one=one, two=2, three=3.0]
INFO: About to change three from 3.0 to 333.0
INFO: setThree about to change to 333.0 on POJO [one=one, two=22, three=3.0]
Groovy 方面和 Java 方面都在执行 POJO 的set方法。Groovy 方面的优势是它能够轻松地确定在更改之前属性的现有值。
生活并不像我描述的那么简单。处理 set 方法的字符串操作确定了一个属性名称。如果该属性实际上不存在(或者,更确切地说,get 方法不存在),访问它将不起作用。尽管如此,要求每个设置器都有一个相应的获取器似乎并不算过分的要求,尤其是在 Groovy POGO 会自动执行这一点的情况下。
为了完成这一节,列表 7.19 展示了从本章开头添加到银行示例中的方面,跟踪 Account 类中的方法。因为 Account 是一个 POGO,我没有显式的设置器方法。我也不一定想跟踪所有的获取器,因为其中一个是 getMetaClass,而这不是一个业务方法。
一种解决方法是通过 POGO 实现的 Java 接口。相反,这里我将使用显式切点并将它们组合起来。
这里是包含切点和通知的完整 AccountAspect 列表。
列表 7.19. 跟踪 Account POGO 中方法的方面
import java.util.logging.Logger
import org.aspectj.lang.JoinPoint
import org.aspectj.lang.annotation.Aspect
import org.aspectj.lang.annotation.Before
import org.aspectj.lang.annotation.Pointcut
@Aspect
class AccountAspect {
Logger log = Logger.getLogger(AccountAspect.class.name)
@Pointcut("execution(* mjg..Account.deposit(*))")
void deposits() {}
@Pointcut("execution(* mjg..Account.withdraw(*))")
void withdrawals() {}
@Pointcut("execution(* mjg..Account.getBalance())")
void balances() {}
@Before("balances() || deposits() || withdrawals()")
void audit(JoinPoint jp) {
String method = jp.signature.name
og.info("$method called with ${jp.args} on ${jp.target}")
}
}
@Pointcut 注解是用来创建一个 命名 切点的。这个名称由应用其上的方法的名称设置。这里的三个切点分别对应 Account 类中的 deposit、withdraw 和 getBalance 方法。@Before 通知通过 or 表达式将它们组合起来并记录方法调用。当运行 AccountSpec 测试时,(截断的) 输出类似于以下内容:
Jun 28, 2013 12:03:29 PM
INFO: getBalance called with [] on mjg.spring.entities.Account(id:4, balance:100.0)
Jun 28, 2013 12:03:29 PM
INFO: deposit called with [100] on mjg.spring.entities.Account(id:8, balance:100.0)
INFO: withdraw called with [100] on mjg.spring.entities.Account(id:9, balance:100.0)
Jun 28, 2013 12:03:29 PM
INFO: getBalance called with [] on mjg.spring.entities.Account(id:9, balance:0.0)
可以使用 JoinPoint 获取更多信息,但这些是 AOP 的细节,而不是 Groovy。
在这两个例子中,方面都是通过其自己的类提供的。然而,Spring 提供了一种替代方案,即直接在 Bean 定义文件中定义 Bean。
7.4. 内联脚本化 Bean
Spring 为动态语言提供的另一个功能是,Bean 可以直接在 XML 配置中编码.^(12)
^(12) 我必须承认,在使用 Spring 和 Groovy 的几年中,我从未找到过不能通过常规类处理的内联脚本化 Bean 的有说服力的用例。如果您有,请告诉我。
这里有一个例子。以下部分可以用于 Bean 配置文件,如下一列表所示。
列表 7.20. 为内联脚本化方面添加的 Bean 配置文件
<lang:groovy id=*"aspectScript"*>
<lang:inline-script>
<![CDATA[
import org.aspectj.lang.JoinPoint
import java.util.logging.Logger
class GroovyAspect {
Logger log = Logger.getLogger(GroovyAspect.getClass().getName())
def audit(JoinPoint jp) {
log.info "${jp.signature.name} on ${jp.target.class.name}"
}
}
]]>
</lang:inline-script>
</lang:groovy>
<aop:config>
<aop:aspect ref=*"aspectScript"*>
<aop:before method=*"audit"* pointcut=*"execution(* *.*(*))"*/>
</aop:aspect>
</aop:config>
<inline-script> 标签包装了 Groovy Bean 的源代码。我采取了额外的步骤,将代码包装在 CDATA 部分中,这样在验证 XML 时,XML 解析器将不会修改 Groovy 源代码。
与使用注解不同,这次代码的编写方式就像它是任何其他 bean 一样。因此,我不得不添加<config>元素。通常,一个方面是切点(pointcut)和通知(advice)的组合。在这种情况下,切点包含在<before>元素中,但这次它适用于系统中的每个单参数方法。通知是aspectScript bean 中的audit方法,它只是打印被调用的方法名称和包含它的对象名称。
最终的输出会在控制台添加更多行:
INFO: setOne on mjg.POJO
INFO: setTwo on mjg.POJO
INFO: setThree on mjg.POJO
内联脚本 bean 的原始动机是在释放 bean 之前可以在脚本中进行尽可能多的处理。^([13]) 然而,随着 Spring 迁移到 3.x 版本,配置 bean 的选项也有所增加。
^(13)正如我说的,这是一个很大的跳跃。Spring 文档建议这是一个脚本验证器的好机会,但我看不到。
7.5. Groovy 与 JavaConfig
Spring 在 3.0 版本中引入了配置 bean 的第三种方式。最初,所有 bean 都是通过 XML 配置的。然后,在 2.0 版本中引入了注解(假设 JDK 1.5 可用),如@Component、@Service和@Repository,以及用于拾取它们的组件扫描。
在 3.0 版本中,Spring 引入了 Java 配置选项。不再需要在 XML 中的中央位置定义所有 bean,或者将注解分散在 Java 代码库中,现在你可以在一个带有@Configuration注解的 Java 类中定义 bean。在配置文件中,单个 bean 用@Bean注解。
这种方法的一个优点是配置信息是强类型的,因为它们都是用 Java 编写的。然而,另一个优点是现在你可以自由地编写任何你想要的代码,只要最终返回正确的对象。
考虑以下示例。在之前讨论的账户管理器示例中,假设我想每月收取一次处理费.^([14]) 为了这样做,我创建了一个处理账户的类,很自然地命名为AccountProcessor。我想让Account Processor获取所有账户并对每个账户收取一美元的费用.^([15])
^(14)哇,我感觉自己更像一个真正的银行家了。
^(15)不多,但这是一个开始。
如果我以传统的方式这样做,我会将AccountDAO注入到AccountProcessor中。然后,在processAccounts方法中,我会使用 DAO 检索账户并对每个账户收费。然而,使用 Java 配置选项,我有一个替代方案。
以下列表显示了AccountProcessor类,这次是 Java 版本。
列表 7.21. 一个扣除每个账户一美元的账户处理器
package mjg.spring.services;
import java.util.List;
import mjg.spring.entities.Account;
public class AccountProcessor {
private List<Account> accounts;
public void setAccounts(List<Account> accounts) {
this.accounts = accounts;
}
public List<Account> getAccounts() { return accounts; }
public double processAccounts() {
double total = 0.0;
for (Account account : accounts) {
account.withdraw(1.0);
total += 1.0;
}
return total;
}
}
我没有将 AccountDAO 注入到处理器中,而是给它提供了一个账户列表作为属性。processAccounts 方法会遍历它们,从每个账户中提取一美元并返回总额。没有对 AccountDAO 的依赖,这个处理器可以用于任何来源的任何账户集合。这还有一个额外的好处,即总是从 DAO 中检索完整的账户集合。注入账户列表会在应用程序启动时初始化它,但不会在之后更新它。
那么,账户集合是如何进入我的处理器的呢?下面的列表显示了 Java 配置文件。
列表 7.22. 声明 AccountProcessor Bean 的 Java 配置文件
package mjg.spring;
import mjg.spring.dao.AccountDAO;
import mjg.spring.services.AccountProcessor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class JavaConfig {
@Autowired
private AccountDAO accountDAO;
@Bean
public AccountProcessor accountProcessor() {
AccountProcessor ap = new AccountProcessor();
ap.setAccounts(accountDAO.findAllAccounts());
return ap;
}
}
@Configuration 注解表示这是一个定义 Spring 中 Bean 的 Java 配置文件。每个 Bean 都使用 @Bean 注解定义。方法名是 Bean 的名称,返回类型是 Bean 的类。在方法内部,我的任务是实例化 Bean,适当地配置它,并返回它。
Bean 方法的实现可以简单到只是实例化 Bean 并返回它,在过程中设置所需的任何属性。不过,在这种情况下,我决定自动装配 AccountDAO Bean(在组件扫描中被选中)并使用 DAO 来检索所有账户并将它们放入处理器中。
下一个列表显示了一个 Spock 测试,以证明系统正在运行。它再次依赖于嵌入式数据库,正如你可能记得的,它配置了三个账户。
列表 7.23. 检查 AccountProcessor 行为的 Spock 测试
package mjg.spring.services
import mjg.spring.dao.AccountDAO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration
import org.springframework.transaction.annotation.Transactional
import spock.lang.Specification
@ContextConfiguration("classpath:applicationContext.xml")
@Transactional
class AccountProcessorSpec extends Specification {
@Autowired
AccountProcessor accountProcessor
@Autowired
AccountDAO dao
def "processing test accounts should yield 3"() {
given: def accounts = dao.findAllAccounts()
when: def result = accountProcessor.processAccounts()
then:
result == *3.0*
accounts.*every* { account ->
account.balance.toString().endsWith "9"
}
}
}
AccountProcessor 和 AccountDAO Bean 都被自动装配到测试中。DAO 用于检索账户。然后,当处理器处理账户时,返回三美元。
另一个测试条件依赖于每个账户的初始余额都能被 10 整除的事实。因此,从每个账户中减去一美元后,更新的余额都应该以数字 9 结尾。这有点笨拙,但它是有效的。
这个练习的目的是表明,使用 Java 配置选项,你可以在释放 Bean 之前编写任何代码来配置 Bean。尽管如此,Groovy 并没有太多可以添加的,尽管证明 Java 配置选项也可以在 Groovy 类上工作是有价值的。
通常情况下,我不会使用 Spring 来管理基本的实体实例。Spring 专注于管理后端服务,尤其是那些通常被设计为单例的服务。除非另有说明,Spring 容器中的所有 Bean 都假定是单例的。然而,你可以通过将 Bean 的作用域设置为 prototype 来告诉 Spring 每次都提供一个新实例。
列表 7.24 展示了一个 Java(实际上是 Groovy)配置文件,其中有一个类型为 Account 的单例 prototypeAccount 的 bean 定义。它使用 AccountDAO 在每次请求 prototypeAccount 时生成一个新的 bean,本质上使 Spring 成为 Account beans 的工厂,所有这些 beans 都以 100 为初始余额开始。
列表 7.24. 作为账户工厂的 Groovy 格式的 Spring 配置文件
package mjg.spring.config
import mjg.spring.dao.AccountDAO
import mjg.spring.entities.Account
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Scope
@Configuration
class GroovyConfig {
@Autowired
AccountDAO dao
@Bean @Scope("prototype")
Account prototypeAccount() {
int newId = dao.createAccount(100.0)
new Account(id:newId,balance:100.0)
}
}
@Configuration 和 @Bean 注解与 Java 配置文件中的对应注解相同。AccountDAO 仍然按照之前的方式自动装配。不过这次,使用了 @Scope 注解来表明 prototypeAccount 不是一个单例。实现方式是使用 DAO 创建每个新的账户,并使用生成的 ID 填充一个 Account 对象。
为了证明这是正常工作的,下面是另一个 Spock 测试。
列表 7.25. 对原型 Accounts 的 Spock 测试
package mjg.spring.services
import mjg.spring.entities.Account
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.ApplicationContext
import org.springframework.test.context.ContextConfiguration
import org.springframework.transaction.annotation.Transactional
import spock.lang.Specification
@ContextConfiguration("classpath:applicationContext.xml")
@Transactional
class AccountSpec extends Specification {
@Autowired
ApplicationContext ctx
def "prototype accounts have consecutive ids and balance 100"() {
when:
Account a1 = (Account) ctx.getBean("prototypeAccount")
Account a2 = (Account) ctx.getBean("prototypeAccount")
Account a3 = (Account) ctx.getBean("prototypeAccount")
then:
a3.id == a2.id + 1
a2.id == a1.id + 1
a1.balance == 100.0
a2.balance == 100.0
a3.balance == 100.0
}
}
这次应用程序上下文本身被自动装配到测试中,因为我想要多次调用它的 getBean 方法。然后测试获取三个 prototype-Account 实例,并首先验证它们的账户号码是连续的,然后验证所有三个都有预期的余额。
重要的是,你可以像使用 Java 一样轻松地使用 Groovy 创建 Spring 配置文件,在两种情况下,你都有语言的全部力量来做任何你可能想要的额外配置,然后再发布 beans。
到目前为止,所有讨论的技术都涉及如何使用 Spring 中定义的能力。然而,有一个新的能力允许你使用构建器符号定义复杂的 beans。这种机制来自 Grails 项目,但可以在任何地方使用。
7.6. 使用 Grails BeanBuilder 构建 beans
到目前为止,在这本书中我并没有过多地介绍 Grails,这是一个结合了 Groovy DSLs、Spring MVC 和 Hibernate 的强大框架。我将在第十章关于 Groovy 网络应用中更详细地讨论 Grails,但其中一部分内容与这里相关。通常,Spring 的创新会以插件的形式进入 Grails,但偶尔 Grails 的创新也会反过来。
Grails 的 BeanBuilder 是一个例子。grails.spring.BeanBuilder 类使用 Groovy 的构建器语法来创建 Spring 配置文件。你可以使用 Grails BeanBuilder 类做任何在常规配置文件中能做的事情。最好的部分,也是这里讨论最相关的部分,是你不需要在 Grails 项目中工作就可以使用 BeanBuilder。
注意
据说 Grails 的 BeanBuilder 类将在版本 4 中添加到核心 Spring 库中,这将使使用它变得非常简单。尽管如此,这里描述的过程对任何通用外部库都是有用的。
本章中使用的 Spring 版本是 3.2,它不包括BeanBuilder。在几个版本之前,Grails 被重新构建,尽可能地将依赖关系拆分为单独的 JAR 文件,就像 Spring 在 3.0 版本中重构一样。因此,Grails 发行版包含一个名为 grails-spring-2.2.2.jar 的 JAR 文件,对应于 Grails 版本 2.2.2。
Grails-Spring JAR 可以简单地作为外部 JAR 依赖项添加到我的项目中,但由于我的其余项目是用 Gradle 构建的,我更喜欢以那种方式列出我的附加依赖项。Grails-Spring JAR 本身依赖于 Java 简单日志框架(SLF4J),因此必须添加其依赖项。
下面的列表显示了完整的构建文件,它假设项目使用传统的 Maven 结构。
列表 7.26。完整的 Gradle 构建文件,包括 Grails-Spring 依赖关系

构建文件中显示的添加内容是使用 Grails BeanBuilder在常规应用程序中所需的所有内容。Grails-Spring 依赖关系(以及 SLF4J)以常规方式列出。任何其他必需的 JAR 文件(有几个)将自动下载。
为了展示如何使用BeanBuilder,我将采用与早期示例不同的方法。BeanBuilder是一个由开源项目提供的类。根据定义,开源项目会使其源代码可用。虽然浏览开源项目的实现确实具有教育意义,但我想要指出一个经常被忽视的资源。更好的开源项目充满了测试用例。由于没有人真正喜欢编写文档[¹⁶],有时很难确切地了解如何在项目中使用特定的功能。如果你很幸运,编写你想要的功能的人也为其编写了测试用例。然后,测试用例会详细展示如何使用该功能。测试用例是可执行的文档,说明了作者希望您如何使用该功能。
[4] 我的意思是除了书籍形式之外。写书既有趣又容易。这就是我的故事,我会坚持下去。
在 Grails BeanBuilder的情况下,有一个名为grails.spring.BeanBuilderTests的测试用例,它有几个非常不错的属性:
-
它最初由 Grails 项目的负责人 Graeme Rocher 编写,他可能是我遇到过的最好的开发者[¹⁷]。
[5] 除了 Guillaume Laforge、Dierk Koenig、Paul King、Andres Almiray 或少数其他人之外。Groovy 生态系统充满了非常聪明的开发者。
-
该测试用例中几乎有 30 个不同的测试,展示了您可能想对该类做的所有事情。
在本节中,我想回顾一下 BeanBuilderTests 类的一些基本功能。实际上,我把这个类复制到书籍源代码中,只是为了确保一切正常工作。我需要移除一些与独立运行 BeanBuilder(不依赖于 Grails)无关的测试,但其他所有测试都成功通过了。
在我继续之前,我应该强调这种做法是一个很好的通用规则:
测试用例
即使你从不查看实现,下载开源项目的源代码也是有用的。仅测试用例本身通常比实际文档更有价值。
这些建议可能比这本书中说的任何其他内容都更有用。
下一个列表显示了 BeanBuilderTests 类中的第一个测试用例。
列表 7.27. 包含其第一个测试用例的 BeanBuilderTests 类
class BeanBuilderTests extends GroovyTestCase {
void testImportSpringXml() {
def bb = new BeanBuilder()
bb.beans {
importBeans "classpath:grails/spring/test.xml"
}
def ctx = bb.createApplicationContext()
def foo = ctx.getBean("foo")
*assertEquals* "hello", foo
}
}
要使用 BeanBuilder,你只需要实例化这个类。这类似于使用 MarkupBuilder、SwingBuilder、AntBuilder 或任何用 Groovy 编写的广泛范围内的构建器。在这里,构建器被分配给变量 bb,因此使用构建器从 bb.beans 开始,就像在 Spring 配置文件中创建一个根 <beans> 元素一样。大括号表示子元素。在这里,子元素是一个 importBeans 元素,它从类路径中读取 test.xml 文件。在继续之前,这是 test.xml 的文本:
<?xml version=*"1.0"* encoding=*"UTF-8"*?>
<beans xmlns=*"http://www.springframework.org/schema/beans"*
xmlns:xsi=*"http://www.w3.org/2001/XMLSchema-instance"*
xsi:schemaLocation=*"http://www.springframework.org/schema/beans*
*http://www.springframework.org/schema/beans/spring-beans-2.0.xsd"*>
<bean id=*"foo"* class=*"java.lang.String"*>
<constructor-arg value=*"hello"* />
</bean>
</beans>
这是一个典型的 beans 配置文件,包含一个单独的 bean 定义。该 bean 是一个 java.lang.String 类型的实例,其值为 hello,名称为 foo。
返回到测试用例,在导入 XML 文件后,会调用 createApplicationContext 方法,这使得 bean 通过应用程序上下文可用。然后测试调用 getBean 返回 foo bean,并检查其值为 hello。
可以得出的结论是,要使用 BeanBuilder,你必须(1)实例化类,(2)使用正常的构建器语法定义 bean,(3)从构建器创建应用程序上下文,(4)以正常方式访问和使用 bean。
下一个列表包含另一个测试用例,展示了设置 bean 属性。
列表 7.28. 在 BeanBuilder 中设置 bean 属性,来自 BeanBuilderTests
void testSimpleBean() {
def bb = new BeanBuilder()
bb.beans {
bean1(Bean1) {
person = "homer"
age = 45
props = [overweight:true, height:"1.8m"]
children = ["bart", "lisa"]
}
}
def ctx = bb.createApplicationContext()
assert ctx.containsBean("bean1")
def bean1 = ctx.getBean("bean1")
assertEquals "homer", bean1.person
assertEquals 45, bean1.age
assertEquals true, bean1.props?.overweight
assertEquals "1.8m", bean1.props?.height
assertEquals(["bart", "lisa"], bean1.children)
}
在构建器内部,语法使用 bean 名称后跟括号中的 bean 类。在这种情况下,bean1 是 Bean1 类的一个实例的名称或 ID。在文件底部附近,你可以找到 Bean1 的定义:
class Bean1 {
String person
int age
Properties props
List children
}
实际上,在类的底部定义了几个豆类。与 Java 不同,Groovy 源文件可以包含多个类定义。Bean1 类包含类型为 String、int、Properties 和 List 的属性。测试用例将 name 分配给 homer,将 age 分配给 45,使用映射语法分配 overweight 和 height 属性,并将列表设置为孩子的名字.^([18])然后测试断言豆类在应用程序上下文中,并且在检索后,所有属性都已按描述设置。
(18) 略去了玛吉,她总是让人感到像是事后才想起的。
当然,你不仅限于定义单个豆类。接下来的列表显示了一个创建多个豆类并设置它们关系的测试。
列表 7.29. 使用 BeanBuilder 定义多个相关豆类
void testBeanReferences() {
def bb = new BeanBuilder()
bb.beans {
homer(Bean1) {
person = "homer"
age = 45
props = [overweight:true, height:"1.8m"]
children = ["bart", "lisa"]
}
bart(Bean1) {
person = "bart"
age = 11
}
lisa(Bean1) {
person = "lisa"
age = 9
}
marge(Bean2) {
person = "marge"
bean1 = homer
children = [bart, lisa]
}
}
def ctx = bb.createApplicationContext()
def homer = ctx.getBean("homer")
def marge = ctx.getBean("marge")
def bart = ctx.getBean("bart")
def lisa = ctx.getBean("lisa")
assertEquals homer, marge.bean1
assertEquals 2, marge.children.size()
assertTrue marge.children.contains(bart)
assertTrue marge.children.contains(lisa)
}
命名为 homer、bart 和 lisa 的豆类都是 Bean1 类的实例。marge 豆类是 Bean2 类的实例,它添加了一个类型为 Bean1 的引用,名为 bean1。在这里,marge 中的 bean1 引用被分配给了 homer。Bean1 类还有一个类型为 List 的 children 属性,因此它被分配给包含 bart 和 lisa 的列表。
我不想在这里通过所有测试,但有几个特性应该被强调。例如,你可以定义不同作用域的豆类,如下一个列表所示。
列表 7.30. 在不同作用域中定义豆类
void testScopes() {
def bb = new BeanBuilder()
bb.beans {
myBean(ScopeTest) { bean ->
bean.scope = "prototype"
}
myBean2(ScopeTest)
}
def ctx = bb.createApplicationContext()
def b1 = ctx.myBean
def b2 = ctx.myBean
assert b1 != b2
b1 = ctx.myBean2
b2 = ctx.myBean2
assertEquals b1, b2
}
通过将 myBean 的 scope 属性设置为 prototype,检索两次豆类会产生不同的实例。myBean2 的作用域默认为单例,因此请求两次将导致两个对同一对象的引用。
你还可以使用来自不同 Spring 命名空间的标签。在本章早些时候,我使用 Groovy 创建了一个方面。以下列表显示了使用 BeanBuilder 的类似情况。
列表 7.31. 使用 BeanBuilder 定义方面
void testSpringAOPSupport() {
def bb = new BeanBuilder()
bb.beans {
xmlns aop:"http://www.springframework.org/schema/aop"
fred(AdvisedPerson) {
name = "Fred"
age = 45
}
birthdayCardSenderAspect(BirthdayCardSender)
aop.config("proxy-target-class":true) {
aspect(id:"sendBirthdayCard",ref:"birthdayCardSenderAspect" ) {
after method:"onBirthday", pointcut:
"execution(void grails.spring.AdvisedPerson.birthday())
and this(person)"
}
}
}
def appCtx = bb.createApplicationContext()
def fred = appCtx.getBean("fred")
assertTrue (fred instanceof SpringProxy )
fred.birthday()
BirthdayCardSender birthDaySender = appCtx.getBean(
"birthdayCardSenderAspect")
assertEquals 1, birthDaySender.peopleSentCards.size()
assertEquals "Fred", birthDaySender.peopleSentCards[0].name
}
使用 xmlns 声明 aop 命名空间。在解释为(不存在的)方法调用的构建器中,其解释是在 aop 前缀下使命名空间可用。fred 豆类是 AdvisedPerson 类的实例,其定义如下
@Component(value = "person")
class AdvisedPerson {
int age
String name
void birthday() {
++age
}
}
birthdayCardSenderAspect 是 BirthdayCardSender 类的实例,该类在文件底部定义:
class BirthdayCardSender {
List peopleSentCards = []
void onBirthday(AdvisedPerson person) {
peopleSentCards << person
}
}
使用 aop 命名空间中的 config 元素,构建器声明了一个名为 sendBirthdayCard 的方面,该方面引用了方面。在任何被建议的人执行生日方法之后,方面的 onBirthday 方法将被执行,这会将这个人添加到 peopleSentCards 集合中。然后测试验证方面确实已运行。
其他测试展示了 BeanBuilder 中的其他功能。例如,如果你正在尝试设置的属性需要连字符,你将属性放在引号中。一些测试显示了如下示例
aop.'scoped-proxy'()
或者
jee.'jndi-lookup'(id:"foo", 'jndi-name':"bar")
请参阅测试文件以获取广泛的示例。底线是,你可以在常规 Spring bean 配置文件中完成的任何事情,都可以使用 Grails 的BeanBuilder来完成。
学习心得(Spring 与 Groovy)
1. Spring 以与 POJO 相同的方式管理 POGOS,因此 beans 可以用 Groovy 实现,就像用 Java 一样容易。
2. 闭包强制转换消除了匿名内部类的需要。
3. 通过添加单个 JAR 文件,Spock 测试可以在 Spring 测试上下文中运行。
4. 可刷新的 beans 允许你在不重新启动系统的情况下修改系统。
5. 内联脚本 beans 嵌入在配置文件中。
6. Grails 的
BeanBuilder提供了另一种配置 Spring 的方法。
7.7. 摘要
本章演示了 Groovy 可以在哪些地方与 Spring 框架高效地协同工作。除了在 Groovy 中编写 Spring beans,这有时会导致代码量显著减少之外,还有 Spring 特有的动态语言 beans 的特性。我展示了可刷新的 beans,其中你可以部署源代码并可以修改它而无需停止系统,以及内联脚本 beans,其中 beans 直接在配置文件中定义。Groovy beans 也可以作为 Spring AOP 方面,如所示。最后,我回顾了来自 Grails 的BeanBuilder类的测试,该类可以使用正常的 Groovy 构建器语法创建 Spring bean 定义,即使在 Grails 之外也是如此。
在下一章中,我们将探讨数据库开发和操作。在那里,除了groovy.sql.Sql类的酷炫功能之外,我还会使用 Grails 项目的一个贡献,即 Grails 对象关系映射(GORM)功能。
第八章 数据库访问
本章涵盖
-
JDBC 和 Groovy 的
Sql类 -
使用 GORM 简化 Hibernate 和 JPA
-
与 NoSQL 数据库一起工作
几乎每个重要的应用程序都以某种形式使用持久数据。其中绝大多数将数据保存在关系数据库中。为了便于在不同数据库之间切换,Java 提供了 JDBC^([1]) API。虽然 JDBC 可以处理所需的任务,但其低级性质导致处理甚至最简单的任务也需要很多行代码。
¹ 你可能会认为 JDBC 代表 Java Database Connectivity。每个人都可能会同意你的看法,除了创建该 API 的 Sun(现在是 Oracle)的人。他们声称 JDBC 是一个商标化的首字母缩略词,不代表任何东西。显然,在这个过程中涉及到了律师。我不会被这种愚蠢的东西所束缚,如果因此被起诉,我肯定会写博客关于这件事。
由于软件是面向对象的,而数据库是关系型的,因此在边界处存在不匹配。开源的 Hibernate 项目试图在更高层次的抽象上弥合这一差距。Java 包括 Java 持久化 API(JPA),作为对 Hibernate 和其他对象关系映射(ORM)工具的统一接口。
如同往常,Groovy 为 Java API 提供了一些简化。对于原始 SQL,Groovy 标准库包括 groovy.sql.Sql 类。对于像 Hibernate 这样的 ORM 工具,Grails 项目创建了一个特定领域的语言(DSL)称为 GORM。最后,最近变得流行的许多所谓的“非 SQL”数据库也提供了 Groovy API 以简化其使用。图 8.1 展示了本章涵盖的技术。
图 8.1. Java 使用 JDBC 和 JPA,其中 Hibernate 是最常用的 JPA 提供商。大多数 NoSQL 数据库都有一个 Java API,可以被 Groovy 包装;在本章中,使用 GMongo 访问 MongoDB。GORM 是基于 Spring 和 Hibernate 的 Groovy DSL。最后,groovy.sql.Sql 类使得使用关系型数据库的原始 SQL 变得容易。

在关系型数据库中,一切最终都归结为 SQL,所以我会从这里开始。
8.1. Java 方法,第一部分:JDBC
JDBC 是一组类和接口,在原始 SQL 上提供了一层薄层。这实际上是一项重大的工程成就。在几乎每个关系型数据库上提供统一的 API 并非易事,尤其是在每个供应商在 SQL 本身中实现了显著不同的变体时。
尽管如此,如果你已经解决了 SQL 问题,JDBC API 提供了类和方法来传递它到数据库并处理结果。
下面的列表展示了一个基于单个持久化类 Product 的简单示例。
列表 8.1. Product 类,一个映射到数据库表的 POJO
package mjg;
public class Product {
private int id;
private String name;
private double price;
public Product() {}
public Product(int id, String name, double price) {
this.id = id;
this.name = name;
this.price = price;
}
public int getId() { return id; }
public void setId(int id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public double getPrice() { return price; }
public void setPrice(double price) { this.price = price; }
}
Product 类只有三个属性,其中之一(id)将代表数据库中的主键。类的其余部分只是构造函数、getter 和 setter,以及(未展示)正常的 toString、equals 和 hashCode 重写。完整的版本可在本书源代码中找到。
下一个列表展示了 ProductDAO 接口。
列表 8.2. Product 类的 DAO 接口
import java.util.List;
public interface ProductDAO {
List<Product> getAllProducts();
Product findProductById(int id);
void insertProduct(Product p);
void deleteProduct(int id);
}
要实现接口,我需要知道表结构。再次,为了简化,假设我只有一个名为 product 的表。为了本例的目的,该表将在 DAO 实现类中使用 H2 数据库创建。
实现类是 JDBCProductDAO。下面展示了几个摘录。Java 开发者会发现代码及其伴随的繁琐相当熟悉。
下面的列表展示了实现的开端,包括表示 URL 和驱动类常量。
列表 8.3. DAO 接口的 JDBC 实现
public class JDBCProductDAO implements ProductDAO {
private static final String *URL* = "jdbc:h2:build/test";
private static final String *DRIVER* = "org.h2.Driver";
public JDBCProductDAO() {
try {
Class.*forName*(*DRIVER*);
} catch (ClassNotFoundException e) {
e.printStackTrace();
return;
}
createAndPopulateTable();
}
// ... More to come ...
}
已经仁慈地省略了 import 语句。创建和填充表的私有方法在下一个列表中显示。
列表 8.4. 将 Product 表的创建和填充添加到 DAO 中

描述 Java 时常用的一句话是,本质被埋藏在仪式中。JDBC 代码可能是整个 API 中最糟糕的违规者。这里的“本质”是创建表并添加几行。而“仪式”则是围绕它的所有样板代码。正如列表所示,需要 try/catch 块,因为 JDBC 中几乎所有的操作都会抛出检查的 SQLException。此外,由于无论是否抛出异常,都必须关闭数据库连接,因此必须在 finally 块中关闭连接。更糟糕的是,close 方法本身也会抛出 SQLException,因此它也必须被包裹在 try/catch 块中,当然,避免潜在的 NullPointerException 的唯一方法是在关闭时验证连接和语句引用不是 null。
这种样板代码在 DAO 的每个方法中都会重复。例如,以下列表显示了 findProductById 方法的实现。
列表 8.5. 带有所有必要仪式的 findProductById 方法

与 Java 中许多事物一样,你能说的最好的关于这段代码的事情就是最终你会习惯它。这里所做的一切只是执行一个带有 where 子句的 select 语句,该子句包括必要的产品 ID,并将返回的数据库行转换为 Product 对象。其他一切都是仪式。
我可以继续展示剩余的实现方法,但可以说细节同样被埋藏。请参阅书籍源代码以获取详细信息。
Lessons learned (JDBC)
1. JDBC 是一组用于访问关系型数据库的 SQL 访问的非常冗长、低级的类。
2. 如果没有 Groovy,Spring 的
JdbcTemplate类(在第七章 chapter 7 中介绍)是一个不错的选择。
几年前,这是 Java 的唯一可行选项。现在有其他选项存在,如 Spring 的 JdbcTemplate(在第七章 chapter 7 中讨论)和对象关系映射工具如 Hibernate(在本章后面讨论)。尽管如此,如果您已经了解 SQL 并且想实现 DAO 接口,Groovy 提供了一个非常简单的替代方案:groovy.sql.Sql 类。
8.2. Groovy 方法,第一部分:groovy.sql.Sql
groovy.sql.Sql 类是 JDBC 的一种简单封装。该类为您处理资源管理,包括创建和配置语句以及记录错误。与常规 JDBC 相比,使用起来容易得多,因此根本没有任何理由要回头。
下一个列表显示了设置数据库连接并初始化它的类部分。
列表 8.6. 使用 groovy.sql.Sql 类实现的 ProductDAO

groovy.sql.Sql 类包含一个名为 newInstance 的静态工厂方法,它返回该类的新实例。该方法具有多种参数的重载;请参阅 GroovyDocs 获取详细信息。
execute 方法接受一个 SQL 字符串,并自然地执行它。在这里,我使用多行字符串来使 create table 和 insert into 语句更容易阅读。Sql 类负责打开连接并在完成后关闭它。
Sql 类
groovy.sql.Sql 类执行了原始 JDBC 所做的一切,并且还处理资源管理。
同样的 execute 方法可以用来删除产品:
void deleteProduct(int id) {
sql.execute 'delete from product where id=?', id
}
execute 方法不仅创建了预编译的语句,还将提供的 ID 插入其中并执行它。这已经足够简单了。
插入产品可以使用相同的方法,但需要参数列表:
void insertProduct(Product p) {
def params = [p.id, p.name, p.price]
sql.execute
'insert into product(id,name,price) values(?,?,?)', params
}
该类还有一个名为 executeInsert 的方法,当任何列由数据库自动生成时使用。该方法返回包含生成值的列表。在这个例子中,id 值由程序提供。自动生成的值将在 第 8.3 节 中讨论 Hibernate 和 JPA。
获取产品涉及一个小的复杂性。有几个有用的查询方法。其中 firstRow、eachRow 和 rows 是其中之一。当只需要单行时使用 firstRow 方法。如果有多个行在结果集中,可以使用 eachRow 或 rows。在这种情况下,eachRow 返回一个列名到列值的映射,而 rows 方法返回一个映射列表,每个映射对应一行。
复杂之处在于返回的列名都是大写的。例如,查询
sql.firstRow 'select * from product where id=?', id
返回
[ID:1, NAME:baseball, PRICE:4.99]
对于 id 为 1 的情况。通常我想要将这个映射作为 Product 构造函数的参数,但由于 Product 属性都是小写,所以这行不通。
一种可能的解决方案是将映射转换为一个具有小写键的新映射。这正是 Map 类中的 collectEntries 方法的作用。因此,findProductById 方法的实现如下
Product findProductById(int id) {
def row = sql.firstRow('select * from product where id=?', id)
new Product( row.collectEntries { k,v -> [k.toLowerCase(), v] } );
}
通过使用 eachRow 并逐个转换它们,很容易将这个方法推广到 getAllProducts 方法。一个稍微更优雅的解决方案是使用 rows 方法并直接转换结果列表中的映射:
List<Product> getAllProducts() {
sql.rows('select * from product').collect { row ->
new Product(
row.collectEntries { k,v -> [k.toLowerCase(), v] }
)
}
}
这个解决方案要么非常优雅,要么过于聪明,这取决于你的观点。收集^([2]) 所有一切(除了在构造函数中已经显示的初始化之外),结果如下所示。
² 没有故意开玩笑。
列表 8.7. 除了已经显示的部分之外,完整的 SqlProductDAO 类
class SqlProductDAO implements ProductDAO {
Sql sql = Sql.newInstance(url:'jdbc:h2:mem:',driver:'org.h2.Driver')
List<Product> getAllProducts() {
sql.rows('select * from product').collect { row ->
new Product(
row.collectEntries { k,v -> [k.toLowerCase(), v] }
)
}
}
Product findProductById(int id) {
def row = sql.firstRow('select * from product where id=?', id)
new Product(
row.collectEntries { k,v -> [k.toLowerCase(), v] } );
}
void insertProduct(Product p) {
def params = [p.id, p.name, p.price]
sql.execute
'insert into product(id,name,price) values(?,?,?)', params
}
void deleteProduct(int id) {
sql.execute 'delete from product where id=?', id
}
}
顺便说一下,还有一个可用的选项,^([3]) 但仅当Person类是用 Groovy 实现的。如果是这样,我可以在Person类中添加一个构造函数来处理那里的大小写转换:
³ 感谢 Groovy Users 电子邮件列表上的 Dinko Srkoc 提供的这个有用的建议。
class Product {
int id
String name
double price
Person(Map args) {
args.each { k,v ->
setProperty( k.toLowerCase(), v)
}
}
}
使用这个构造函数,getAllProducts方法简化为
List<Product> getAllProducts() {
sql.rows('select * from product').collect { new Product(it) }
}
这对于优雅来说很难超越。
进入元领域
如果类属性使用驼峰式命名,这在正常情况下是常见的,那么章节中的“优雅”解决方案就会崩溃。相应的数据库表条目将使用下划线来分隔单词。
如 Groovy Users 电子邮件列表上的 Tim Yates 所示,^([4]) 你可以使用 Groovy 元编程向String类添加一个toCamelCase方法来进行转换。相关的代码是
⁴ 有关完整讨论,请参阅
groovy.329449.n5.nabble.com/Change-uppercase-Sql-column-names-to-lowercase-td5712088.html。
String.metaClass.toCamelCase = {->
delegate.toLowerCase().split('_')*.capitalize().join('').with {
take( 1 ).toLowerCase() + drop( 1 )
}
}
每个 Groovy 类都有一个通过getMetaClass方法检索到的元类。可以通过将闭包分配给它们来向元类添加新方法,就像这里所做的那样。使用了一个无参闭包,这意味着新方法将接受零个参数。
在闭包内部,delegate属性指向它被调用的对象。在这种情况下,它是被转换的字符串。数据库表列是大写字母,由下划线分隔,因此将delegate转换为小写,然后在下划线处分割,结果得到一个字符串列表。
然后使用扩展点操作符在列表上调用每个元素的capitalize方法,这只会将第一个字母转换为大写。然后join方法重新组装字符串。
然后是更有趣的部分。with方法接受一个闭包,在该闭包内部,任何没有引用的方法都会在delegate上被调用。take和drop方法用于列表(或在这种情况下,字符序列)。take方法检索其参数指定的元素数量。这里该值是 1,因此返回第一个字母,并将其转换为小写。drop方法返回在移除参数中的数字后的其余元素,在这种情况下意味着字符串的其余部分。
结果是,你可以在字符串上调用该方法并将其转换。'FIRST_NAME' .toLowerCase()变为'firstName',依此类推。
欢迎来到 Groovy 元编程的奇妙世界。
groovy.sql.Sql相对于原始 JDBC 的优点是显而易见的。如果我已经编写了 SQL 代码,我总是使用它。
学习到的经验(Groovy SQL^([5]))
1.
groovy.sql.Sql类在各个方面都使处理原始 SQL 变得更好:资源管理、多行字符串、闭包支持以及将结果集映射到映射。2. 本书中相关的示例可以在第七章(关于 Spring)第七章和第九章(关于 REST)第九章中找到。
⁵ 世界上最糟糕的 SQL 笑话:SQL 查询走进酒吧,选择两张表,然后说,“介意我加入你们吗?”(响指)。(警告:本章后面的 NoSQL 版本。)
而不是编写所有那些 SQL 代码,你可以使用可用的对象关系映射(ORM)工具之一,其中最普遍的还是 Hibernate。Java 持久化 API(JPA)规范作为 ORM 工具的前端,是下一节的主题。
8.3. Java 方法,第二部分:Hibernate 和 JPA
简化 JDBC 的一种方法是通过尽可能自动化它。Java 的早期年份见证了将 ORM 工具直接添加到规范中的尝试,成功率各不相同。首先是 Java 数据对象(JDO),它直接与编译后的字节码工作,今天在很大程度上已被遗忘。然后是企业 JavaBeans(EJB)实体 bean,社区在最初的几个版本中将其视为一团糟。
当需要某种功能而只有不受欢迎的规范可用时,这种情况经常发生,开源社区开发了一个实用的替代方案。在这种情况下,出现的项目被命名为 Hibernate,它仍然旨在在处理关系数据库时成为 Java 世界的首选 ORM 工具。
在常规 JDBC 中,ResultSet只要连接打开就连接到数据源,连接关闭时就会消失。因此,在 EJB 世界中,你需要两个类来表示一个实体:一个始终连接,一个从不连接。前者被称为类似于ProductEJB的东西,后者是ProductTO,或传输对象。6 当从数据库获取产品时,ProductEJB保存单行数据,并将其数据传输到ProductTO以进行显示。传输对象不连接,因此可能会过时,但至少它没有使用数据库连接,这是一种稀缺的商品。从 EJB 到 TO 的数据传输是通过会话 EJB 完成的,事务边界在那里发生。会话 EJBs 构成了服务层,也包含了业务逻辑。整个过程与图 8.2 中所示的过程非常相似。
⁶ 旧术语包括数据传输对象(DTO)和值对象(VO)。
图 8.2. 控制器与事务会话 EJBs 进行交互,通过实体 EJBs 获取数据库数据。数据被复制到传输对象中,并返回给控制器。

结果是,ProductEJB 类和 ProductTO 类在本质上相同,因为它们都包含相同的方法签名,尽管实现不同。马丁·福勒(企业应用架构模式 [Addison-Wesley, 2002],重构 [Addision-Wesley, 1999],以及其他几本书的作者)称这为反模式,并说这是设计有缺陷的症状。
Hibernate 和 EJBs 之间的一个关键区别是 Hibernate 会话的概念。创新之处在于,而不是一类始终连接的对象和另一类永远不会连接的对象,需要的是一组有时连接有时不连接的对象。在 Hibernate 中,当对象是 Hibernate 会话的一部分时,框架承诺将它们与数据库保持同步。当会话关闭时,对象断开连接,从而成为它自己的传输对象。任何时候通过 Hibernate 获取的对象,都成为 Hibernate 会话的一部分。
您可以通过会话工厂检索 Hibernate 会话。会话工厂读取所有映射元数据,配置框架,并执行任何必要的预处理。它应该只实例化一次,作为单例。
那些熟悉 Spring 框架的读者(如第七章所述[kindle_split_019.html#ch07])可能会突然产生兴趣,因为管理单例是 Spring 所关注的事情之一。它的另一个能力是声明式事务管理,这也非常适合。结果是,EJB 2.x 世界的架构被 Spring 的声明式事务和会话工厂以及 Hibernate 的实体 Bean 的组合所取代。
在 EJB 的第 3 版中,架构再次进行了重新设计,以更紧密地适应 Spring 和 Hibernate 所使用的架构。实体 Bean 部分导致了 Java 持久化 API 的创建。JPA 世界使用相同的概念,但标签不同.^([7]) Hibernate 的 Session 变成了 EntityManager。SessionFactory 是 EntityManagerFactory。被管理的对象(即,在 Hibernate 会话中)组成一个 持久化上下文。
⁷ 当然,这是肯定的。使用相同的术语会太容易了。
最后,在原始的 Hibernate 中,实体类到数据库表的映射是通过 XML 文件完成的。随着时间的推移,XML 已经变得不那么受欢迎,并被注解所取代。Hibernate 和 JPA 共享许多注解,这是幸运的。
现在是时候举一个例子了,这个例子将 Spring、Hibernate 和 JPA 结合在一起。Spring 框架的第七章[kindle_split_019.html#ch07]详细讨论了 Spring。在这里,我将只突出示例所需的各个部分。
首先,我需要一个数据库。为此,我将使用 H2,一个基于 Java 文件或内存的纯 Java 数据库。Spring 提供了一个嵌入式数据库 bean,以便更容易地与 H2 一起工作。Spring 配置文件中相关的 bean 是
<jdbc:embedded-database id="dataSource" type="H2">
<jdbc:script location="classpath:schema.sql"/>
<jdbc:script location="classpath:test-data.sql"/>
</jdbc:embedded-database>
架构和测试数据 SQL 文件定义了一个名为PRODUCT的单个表,包含三行:
create table PRODUCT (
id bigint generated by default as identity (start with 1),
name varchar(255), price double, primary key (id)
)
insert into PRODUCT(name, price) values('baseball', 5.99)
insert into PRODUCT(name, price) values('basketball', 10.99)
insert into PRODUCT(name, price) values('football', 7.99)
Spring 提供了一个表示EntityManagerFactory的 bean,它有几个属性可以设置:
<bean id="entityManagerFactory" class=
"org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean">
<property name="dataSource" ref="dataSource" />
<property name="persistenceUnitName" value="jpaDemo" />
<property name="packagesToScan">
<list>
<value>mjg</value>
</list>
</property>
<property name="jpaVendorAdapter">
<bean class=
"org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter">
<property name="database" value="H2" />
</bean>
</property>
</bean>
LocalContainerEntityManagerFactoryBean类使用之前定义的数据源 bean,扫描给定的包以查找实体,并使用 Hibernate 作为其实施。
⁸ 极长的类名是 Spring 的常规。我最喜欢的是
AbstractTransactional-Data-Source-Spring-ContextTests,它有 49 个字符,甚至已被弃用。你的呢?
实体本身是Product类,这次添加了一些 JPA(或 Hibernate)注解:
@Entity
public class Product {
@Id
private int id;
private String name;
private double price;
// ... constructors ...
// ... getters and setters ...
// ... toString, equals, hashCode ...
}
@Entity和@Id注解声明Product是一个映射到数据库表的类,并分别标识主键。由于一个惊人的巧合,^([9]) Product属性名和数据库列名恰好匹配,所以我不需要额外的物理注解,如@Table和@Column。
⁹ 并非如此。
ProductDAO接口与第 8.1 节中展示的 JDBC 接口相同,但现在insertProduct方法返回新的数据库生成的主键。JpaProductDAO实现类是动作发生的地方,它将在下一个列表中展示。
列表 8.8。使用 JPA 类实现 DAO 的JpaProductDAO类

JPA 实现非常简洁,但这是因为它假设事务管理由其他地方处理,并且 Spring 将处理分配和关闭必要的数据库资源。
我绝不会在没有合适的测试用例的情况下编写这么多代码。Spring 的测试上下文框架管理应用程序上下文,允许测试固定值被注入,并且如果提供了事务管理器,则在每次测试结束时自动回滚事务。
为了处理事务,我使用了另一个 Spring bean,JpaTransactionManager,它使用之前指定的实体管理器工厂:
<bean id="transactionManager"
class="org.springframework.orm.jpa.JpaTransactionManager"
p:entityManagerFactory-ref="entityManagerFactory" />
结果的测试用例如下所示。
列表 8.9。JPA DAO 实现的 Spring 测试用例
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations="classpath:applicationContext.xml")
@Transactional
public class JpaProductDAOTest {
@Autowired
private ProductDAO dao;
@Test
public void testFindById() {
Product p = dao.findProductById(1);
assertEquals("baseball", p.getName());
}
@Test
public void testGetAllProducts() {
List<Product> products = dao.getAllProducts();
assertEquals(3, products.size());
}
@Test
public void testInsert() {
Product p = new Product(99, "racketball", 7.99);
int id = dao.insertProduct(p);
Product p1 = dao.findProductById(id);
assertEquals("racketball", p1.getName());
}
@Test
public void testDelete() {
List<Product> products = dao.getAllProducts();
for (Product p : products) {
dao.deleteProduct(p.getId());
}
assertEquals(0, dao.getAllProducts().size());
}
}
测试检查每个 DAO 方法。我最喜欢的是testDelete,它删除表中的每一行,验证它们已经消失,并且不会将它们重新添加回去,这会产生副作用,让任何数据库管理员心跳加速。幸运的是,Spring 在测试完成后回滚所有更改,所以没有东西丢失,但大家都有了一个愉快的时光。
最后一个拼图是 Maven 构建文件。您可以在书籍源代码中看到它,就像往常一样。
这是一段相当多的代码和配置,而我只有一个类和一个数据库表。坦白说,如果我不能让它工作,我可能就放弃吧。当你添加关系时,生活就会变得复杂。([10])
^([10]) 在许多层面上;有时笑话会自己写出来。
Lessons learned (Hibernate and JPA)
1. Java 持久性 API 管理对象关系映射提供程序,这些程序将对象转换为表行,然后再转换回来。
2. Hibernate 是业界最常用的 JPA 提供商。
3. ORM 工具提供传递持久性、持久化上下文、SQL 代码生成等功能。
4. 与所有 Java 库一样,它们仍然相当冗长。
Groovy 可以通过几种方式帮助这种情况,这些将在下一节中讨论。
8.4. The Groovy approach, part 2: Groovy and GORM
在深入探讨 Grails 对象关系映射(GORM)之前,让我指出几个 Groovy 可以简化上一节示例应用的地方。
8.4.1. Groovy 简化
实体类 Product 可以写成 POGO。这不会改变行为,但可以将类的尺寸减少大约三分之二。还有应用程序的其他与 Spring 相关的部分也可以转换为 Groovy,这在第七章(kindle_split_019.html#ch07)中会有更详细的展示。
书籍源代码中包含一个 Gradle 构建文件。它看起来与前面章节中显示的大多数构建文件相似,但它比相应的 Maven 构建文件短得多,也更容易阅读。
8.4.2. Grails 对象关系映射(GORM)
Grails 框架由一组基于 Spring 和 Hibernate 的 Groovy DSLs 组成。由于 Spring 和 Hibernate 的组合在 Java 世界中是一个非常常见的架构,Grails 是一个自然的演变,它简化了编码和集成问题。
Grails 在第十章(kindle_split_022.html#ch10)中详细讨论了 Web 应用程序,但 Hibernate 集成部分与此相关。Grails 结合 Groovy 领域特定语言(DSLs)来简化配置领域类。
领域类
在 Grails 中,术语 domain 与 JPA 中的 entity 类似。领域类映射到数据库表。
考虑一个基于本章前面使用过的相同 Product 类的小型但非平凡的领域模型。下面的列表显示了 Product 类,现在是在 Groovy 中。
列表 8.10. Product 类,这次是在 Grails 应用程序中的 POGO
class Product {
String name
double price
String toString() { name }
static constraints = {
name blank:false
price min:0.0d
}
}
在 Grails 中,每个领域类隐式地有一个名为 id 的主键,它是某种整型,这里没有展示但确实存在。这里的 constraints 块是 GORM 的一部分^([11])。constraints 块中的每一行实际上是一个方法调用,其中方法名是属性名。blank 约束自然地意味着产品的名称不能是空字符串。price 约束设置最小值为 0,而 d 使其成为双精度浮点数,因为约束类型必须与属性数据类型匹配。
^(11) 在《星际迷航》原系列剧集“竞技场”中,指挥官柯克与之战斗的爬行动物是戈恩,而不是 GORM。我的意思是,谁听说过 Grails 对象关系式睡眠呢?(尽管可能有一个“懒加载”的笑话在其中。)
此应用程序将拥有另外三个领域类,代表客户、订单和订单上的行。接下来是 Customer 类,在下一个列表中展示。
列表 8.11. Customer 类。客户拥有多个订单(希望如此)。

客户有一个 name 属性和一个表示其订单的 Set。
Grails hasmany
在 Grails 中,hasMany 属性表示一对一关系。默认情况下,包含的对象形成一个集合。
name 不能为空。Order 类在下面的列表中展示。
列表 8.12. Order 类,包含多个订单且属于某个客户

这里有很多事情在进行。首先,一个订单包含一个订单行的 Set。订单也属于特定的客户。客户引用意味着你可以从订单导航到其关联的客户。通过将其分配给 belongsTo 属性,这两个类之间存在级联删除关系。如果从系统中删除客户,所有订单也会被删除。
Grails belongsto
在 Grails 中,单词 belongsTo 表示级联删除关系。
getPrice 方法通过计算每个订单行的价格来计算订单的价格。它也是一个派生量,因此不会保存到数据库中。
dateCreated 和 lastUpdated 属性由 Hibernate 自动维护。当订单首次保存时,其 dateCreated 值被设置;每次修改时,lastUpdated 也会被保存。
最后,mapping 块用于自定义类如何映射到数据库表。默认情况下,Grails 将生成一个与类名匹配的表。因为 order 是一个 SQL 关键字,所以生成的 DDL 语句会有问题。在 mapping 块中,指定的生成表名是 orders,而不是 order,以避免这个问题。此外,Hibernate 将所有关联都视为延迟加载。在这种情况下,这意味着如果加载了一个订单,还需要一个单独的 SQL 查询来加载订单行。在映射块中,fetch join 关系意味着所有关联的订单行将与订单一起通过内部连接加载。
OrderLine 类包含正在订购的产品和数量,如下面的列表所示。
列表 8.13. 组装以构建 Order 的 OrderLine POGO
class OrderLine {
Product product
int quantity
double getPrice() { quantity * product?.price }
static constraints = {
quantity min:0
}
}
getPrice 方法将数量乘以产品的价格以获取订单行的价格。然后,它将这个值加起来,以获得之前看到的总价。
还要注意,OrderLine 类没有对其所属的 Order 的引用。这是一个单向级联删除关系。如果订单被删除,所有订单行也会被删除,但你不能从订单行导航到其关联的订单。
当你声明一个 hasMany 关系时,Grails 会提供方法来将包含的对象添加到它们的容器中。为了说明这些方法之一,这里有一个名为 BootStrap.groovy 的文件,它是 Grails 应用中用于初始化代码的配置文件。接下来的列表显示了创建一个客户、两个产品、一个订单和一些订单行并将它们全部保存到数据库中的代码。
列表 8.14. BootStrap.groovy 中的初始化代码
class BootStrap {
def init = { servletContext ->
if (!Product.findByName('baseball')) {
Product baseball =
new Product(name:'baseball', price:5.99).save()
Product football =
new Product(name:'football', price:12.99).save()
Customer cb = new Customer(name:'Charlie Brown').save()
Order o1 = new Order(number:'1', customer:cb)
.addToOrderLines(product:baseball, quantity:2)
.addToOrderLines(product:football, quantity:1)
.save()
}
}
def destroy = {
}
}
当应用程序启动时,会执行 init 闭包中的代码。addToOrderLines 方法来自于声明一个 Order 有多个 OrderLine 实例。save 方法首先验证每个对象是否符合其约束,然后将它们保存到数据库中。
Grails 使用 Hibernate 生成数据库模式的能力。生成的数据库的实体关系图(ERD)显示在 图 8.3^([12]) 中。
[6](http://www.mysql.com/products/workbench/) 这个图表是使用 MySQL Workbench 生成的,这是一个可在 www.mysql.com/products/workbench/ 获取的免费工具。
图 8.3. 给定文本中列出的域类的生成的数据库实体关系图

在这种情况下,数据库是 MySQL 版本 5,因此 id 的数据类型是 BIGINT。它还将驼峰式属性 dateCreated 和 lastUpdated 转换为表中的下划线。由于 Order 和 OrderLine 之间的关系是单向的,Hibernate 在它们之间生成一个名为 orders_order_line 的连接表。
Grails 还为每个表添加了一个名为 version 的列。Hibernate 使用这个列来实现乐观锁。这意味着每当一个表的行被修改并保存时,Hibernate 会自动将版本列增加一。这是尝试在不实际锁定行并支付性能惩罚的情况下获得锁定行为的一种尝试。如果应用程序涉及许多读取但只有少量写入,这将工作得很好。如果有太多的写入,Grails 还为每个域类添加了一个名为 lock 的实例方法来锁定行。这被称为 悲观锁,会导致性能下降,因此仅在必要时使用。
Grails 做的远不止这些。例如,Grails 使用 Groovy 为每个域类生成动态的查找方法。对于 Product 类,Grails 在域类上生成静态方法,包括
-
Product.list(), 返回所有产品实例 -
Product.findByName(...), 返回第一个匹配名称的产品 -
Product.findAllByPriceGreaterThan(...), 返回所有价格大于参数的产品 -
Product.findAllByNameIlikeAndPriceGreaterThan(...,...), 返回名称满足不区分大小写的 SQLlike子句且价格大于第二个参数的产品
还有更多;有关详细信息,请参阅 Grails 文档^(13))。在每种情况下,Grails 都使用映射来生成满足所需条件的 SQL 代码。
^([13]) 有关 Grails 文档,请参阅
grails.org/doc/latest/。这些文档中的 第六章 详细讨论了 GORM。
Grails 还使用 Groovy 提供了构建器用于构建查询条件。Hibernate 有一个用于构建查询条件的 API,允许您以编程方式构建查询。Java API 可以工作,但仍然相当冗长。Grails 极大地简化了它,使得您可以编写如下表达式:
Product.withCriteria {
like('name','%e%')
between('price', 2.50, 10.00)
order('price','desc')
maxResults(10)
}
这生成一个 SQL 语句来查找所有名称中包含字母 e 且价格在 $2.50 到 $10.00 之间的产品。它按价格降序返回前 10 个匹配产品。
Hibernate 的一个基本原则是 Hibernate 会话的概念。如前所述,Hibernate 确保任何在 Hibernate 会话中(JPA 所称的持久化上下文)的对象都将与数据库保持同步。在 Hibernate 中,对象可以处于三种状态之一,^(14)),如图 8.4 所示。
^([14]) 定义状态的 Hibernate 文档可以在
mng.bz/Q9Ry找到。
图 8.4. 新的和已删除的对象是瞬时的。当它们被保存时,它们变为持久,当会话关闭时,它们变为分离。了解对象的状态是理解它在 Hibernate 中如何工作的关键。

通过 Hibernate 检索到的任何对象——例如,通过使用动态查找器或条件查询之一——都会被放置在持久状态中,并且在该状态下会与数据库保持同步。新创建的尚未保存的对象是瞬时的,而当 Hibernate 会话关闭时内存中的对象则变为分离的。分离的对象不再与数据库连接。
关键问题是,Hibernate 会话何时创建,何时关闭?随着时间的推移,已经形成了一种常见的做法,即把会话范围限定在单个 HTTP 请求中。在 Hibernate 文献中,这被称为视图中的打开会话(OSIV)模式,并且通过请求拦截器来实现。Spring 框架自带了一个库类来自动完成这项工作,Grails 默认使用它。
OSIV Bean
Grails 使用 Spring 中的 OSIV Bean 来将 Hibernate 会话范围限定到每个 HTTP 请求。该 Bean 拦截传入的请求并创建会话,然后它拦截传出的响应并关闭会话。
最后,事务是通过 Spring 的声明式事务能力来管理的,使用@Transactional注解。所有 Grails 服务方法默认都是事务性的,但可以使用注解来定制其行为。
设置所有这些基础设施——管理会话和事务、将域类映射到表、建立关系、处理乐观锁定、生成动态查找器和条件查询、以及将 Hibernate 会话范围限定到每个请求——在手动将 Spring 和 Hibernate 结合使用时需要做大量的工作。Grails 为你完成所有这些,以及更多。
Spring 框架是 Java 中所有开源项目中最为常见的之一,Hibernate 仍然是使用最广泛的 ORM 工具。任何考虑将它们结合使用的项目都应该考虑使用 Grails。
学习到的经验(Groovy 和 GORM)
1. Groovy 通过使用 POGs(Plain Old Groovy Objects)而不是 POJOs(Plain Old Java Objects)、使用闭包进行结果集处理以及简化构建和测试,简化了所有数据库访问。
2. GORM API 使得配置基于 Hibernate 的应用程序变得简单。当与 Spring(如 Grails 中那样)结合使用时,事务和 Hibernate 会话也变得简单。
3. 在 Grails 之外使用 GORM 并不容易,因为它与 Spring 紧密绑定。在业界尝试这样做的情况足够罕见,以至于这个过程在本章中没有涉及。
Grails 的最新版本也可以映射到非关系型数据库,但也可以使用常规 Groovy 来完成,下一节将展示这一点。
8.5. Groovy 和 NoSQL 数据库
在过去几年软件开发中最有趣的趋势之一^([15]) 就是替代性、非关系型数据库的增长。通用术语 NoSQL(大多数社区将其解释为“不仅限于 SQL”)指的是一系列无模式的数据库,这些数据库不是基于关系型方法。
(15) 当然,除了在 JVM 上动态语言的兴起之外。
NoSQL 数据库的主题已经很大,并且正在迅速增长,这已经超出了本书的范围。但许多数据库都有 Java API,其中一些也有简化它们的 Groovy 包装器。
其中最有趣的一个是 MongoDB,^([16]) 其 Java API 相当笨拙,但通过一个名为 GMongo 的 Groovy 包装器得到了显著改进。GMongo 项目,其 GitHub 仓库位于github.com/poiati/gmongo,是 Paulo Poiati 的作品,也是本节的主题。
(16) 有关下载和文档,请参阅www.mongodb.org/。
MongoDB 是一个面向文档的数据库,它以二进制 JSON(BSON)格式存储其数据。这使得它非常适合存储从 RESTful 网络服务下载的数据,这些服务通常在请求时产生 JSON 数据。
8.5.1. 填充 Groovy 吸血鬼
这个例子是因为我最近在书店闲逛时注意到,虽然只有一个书架被标记为“计算机”,但还有三个其他书架被标记为“青少年超自然浪漫”。我选择将此视为我需要在我的书中添加 Groovy 吸血鬼的证据,而不是哀叹西方文明的衰落。
以电影评论网站 Rotten Tomatoes 提供的网络服务为例,developer.rottentomatoes.com。如果你注册了一个 API 密钥,你可以进行 HTTP GET 请求来搜索电影、演员阵容等。数据以 JSON 形式返回。API 的基本 URL 位于api.rottentomatoes.com/api/public/v1.0。所有请求都以该 URL 开始。
例如,搜索关于电影Blazing Saddles^([17]) 的信息是通过访问api.rottentomatoes.com/api/public/v1.0/movies.json?q=Blazing%20Saddles&apiKey=...(在 URL 中提供 API 密钥)来完成的。结果是如下所示的一个 JSON 对象。
(17) 显然,这并不是一部吸血鬼电影,但拯救 MongoDB 中的 Mongo 的冲动是无法抗拒的。“Mongo 只是生命游戏中的一个小卒”是一句精彩的话,可以说是亚历克斯·卡拉斯作品的巅峰之作。
列表 8.15。代表电影Blazing Saddles的 JSON 对象的一部分
{
"total": 1,
"movies": [
{
"id": "13581",
"title": "Blazing Saddles",
"year": 1974,
"mpaa_rating": "R",
"runtime": 93,
"release_dates": {
"theater": "1974-02-07",
"dvd": "1997-08-27"
},
"ratings": {
"critics_rating": "Certified Fresh",
"critics_score": 89,
"audience_rating": "Upright",
"audience_score": 89
},
"synopsis": "",
...,
"abridged_cast": [
{
"name": "Cleavon Little",
"id": "162693977",
"characters": [
"Bart"
]
},
{
"name": "Gene Wilder",
"id": "162658425",
"characters": [
"Jim the Waco Kid"
]
},
...
],
"alternate_ids": {
"imdb": "0071230"
},
...
}
除了显示的数据外,JSON 对象还有指向完整的演员列表、评论等链接。使用像 MongoDB 这样的数据库来存储这些数据的另一个原因是并非每个字段都出现在每部电影中。例如,一些电影包含评论家的评分,而另一些则没有。这与基于 JSON 的无模式数据库的整体理念相符。
首先,为了填充 MongoDB,我将使用com.gmongo.GMongo类的一个实例。这个类直接封装了 Java API。实际上,如果你查看GMongo.groovy中的类,你会看到它由
class GMongo {
@Delegate
Mongo mongo
// ... Constructors and other methods ...
}
紧接着是各种构造函数和简单的修补方法。Groovy 的@Delegate注解是一个抽象语法树(AST)转换^([18]),它通过 GMongo 暴露了来自 Java API 的com.mongodb.Mongo类中的方法。AST 转换意味着你不需要手动编写所有代理方法。
^(18)在第四章中讨论了积分,在附录 B 中,“Groovy by feature”,以及本书的许多其他地方使用了。
初始化数据库就像
GMongo mongo = new GMongo()
def db = mongo.getDB('movies')
db.vampireMovies.drop()
MongoDB 使用movies作为数据库名称,其中包含的集合,如vampireMovies,是数据库的属性。drop方法清除集合。
搜索 Rotten Tomatoes 包括构建带有正确参数的 GET 请求。在这种情况下,以下代码搜索吸血鬼电影:
String key = new File('mjg/rotten_tomatoes_apiKey.txt').text
String base = "http://api.rottentomatoes.com/api/public/v1.0/movies.json?"
String qs = [apiKey:key, q:'vampire'].collect { it }.join('&')
String url = "$base$qs"
API 密钥存储在外部文件中。构建查询字符串从参数映射开始,该映射被转换为形式为“key=value”的字符串映射,然后与和号连接。完整的 URL 是基本 URL 加上附加的查询字符串。获取电影并将它们保存到数据库中几乎是微不足道的:
def vampMovies = new JsonSlurper().parseText(url.toURL().text)
db.vampireMovies << vampMovies.movies
JsonSlurper从 URL 接收 JSON 格式的文本数据并将其转换为 JSON 对象。将结果保存到数据库就像附加整个集合一样简单。
API 每页有 30 个结果的限制。搜索结果包括一个名为next的属性,它指向下一个可用的页面,假设有。因此,脚本需要循环这么多次数来检索可用数据:
def next = vampMovies?.links?.next
while (next) {
println next
vampMovies = slurper.parseText("$next&apiKey=$key".toURL().text)
db.vampireMovies << vampMovies.movies
next = vampMovies?.links?.next
}
就这些了。使用关系型数据库需要将电影结构映射到关系表,这将是一个挑战。因为 MongoDB 使用 BSON 作为其原生格式,即使是 JSON 对象的集合也可以不加任何工作地添加。
有一个名为 MonjaDB 的 Eclipse 插件,它连接到 MongoDB 数据库。图 8.5 显示了 vampireMovies 数据库的一部分。
图 8.5. 使用 Eclipse 的 MonjaDB 插件的部分吸血鬼电影数据库

8.5.2. 查询和映射 MongoDB 数据
现在数据已经存储在数据库中,我需要能够搜索它并检查结果。这可以通过 find 方法以简单的方式完成,或者可以将数据映射到 Groovy 对象以供后续处理。
集合上的 find 方法返回满足特定条件的所有 JSON 对象。如果我只是想查看集合中有多少元素,以下就足够了:
println db.vampireMovies.find().count()
如果没有参数,find 方法将返回整个集合。然后 count 方法返回总数。
将 JSON 映射到 Groovy 展示了强类型语言(如 Groovy)和弱类型语言(如 JSON)之间的差异。显示的 JSON 数据是字符串、日期、整数和枚举值的混合,但 JSON 对象没有嵌入的类型信息。将此映射到一组 Groovy 对象需要一些工作。
例如,以下列表展示了包含 JSON 对象数据的 Movie 类。
列表 8.16. 包装 JSON 数据的 Movie.groovy
@ToString(includeNames=true)
class Movie {
long id
String title
int year
MPAARating mpaaRating
int runtime
String criticsConsensus
Map releaseDates = [:]
Map<String, Rating> ratings = [:]
String synopsis
Map posters = [:]
List<CastMember> abridgedCast = []
Map links = [:]
}
Movie 类为每个包含的元素提供了属性,并指定了数据类型。它包含发布日期、海报、评级和附加链接的映射,以及简略演员列表。CastMember 只是一个 POGO:
class CastMember {
String name
long id
List<String> characters = []
}
Rating 包含一个字符串和一个整数:
class Rating {
String rating
int score
}
为了保持内容的趣味性,MPAA 评级是一个 Java enum,尽管它同样可以用 Groovy 实现:
public enum MPAARating {
G, PG, PG_13, R, X, NC_17, Unrated
}
将 JSON 电影转换为 Movie 实例是通过 Movie 类中的静态方法完成的。fromJSON 方法的部分内容将在下一个列表中展示。
列表 8.17. 将 JSON 电影转换为 Movie 实例的方法的一部分
static Movie fromJSON(data) {
Movie m = new Movie()
m.id = data.id.toLong()
m.title = data.title
m.year = data.year.toInteger()
switch (data.mpaa_rating) {
case 'PG-13' : m.mpaaRating = MPAARating.PG_13; break
case 'NC-17' : m.mpaaRating = MPAARating.NC_17; break
default :
m.mpaaRating = MPAARating.valueOf(data.mpaa_rating)
}
m.runtime = data.runtime
m.criticsConsensus = data.critics_consensus ?: ''
完整的列表可以在书籍源代码中找到,但与这里展示的基本上没有区别。
下面的列表展示了验证转换是否正常工作的测试。
列表 8.18. 验证 JSON 转换的 JUnit 测试

Lessons learned (NoSQL^([19]))
1. 像 MongoDB、Neo4J 和 Redis 这样的 NoSQL 数据库正在成为特定用例的常见选择。
2. 大多数 NoSQL 数据库都提供基于 Java 的 API,可以直接从 Groovy 中调用。
3. 通常,Groovy 库会提供包装 Java API 并简化它的功能。这里,以 GMongo 为例。
¹⁹ 最糟糕的 SQL 笑话的 NoSQL 版本:DBA 走进 NoSQL 酒吧;找不到表,所以他就离开了。
一旦映射成功,查找所有有评论家共识的吸血鬼电影就像以下脚本一样简单:
GMongo mongo = new GMongo()
def db = mongo.getDB('movies')
db.vampireMovies.find([critics_consensus : ~/.*/]).each { movie ->
println Movie.fromJSON(movie)
}
这已经非常简单了。使用 MongoDB^([20]) 与使用传统的关系型数据库一样简单.^([21])
²⁰ 有关 MongoDB 的详细说明,请参阅 Kyle Banker 所著的书籍 MongoDB in Action (Manning, 2011):www.manning.com/banker/。
^(21)由于某种原因,从“吸血鬼”查询中没有返回任何《暮光之城》电影。我考虑过修复这个问题,但最终决定这不是一个错误,而是一个特性。
8.6. 摘要
几乎每个重要的应用程序都需要持久化数据。其中绝大多数都是基于关系型数据库。在 Java 领域,关系型持久化使用 JDBC 或 Hibernate 或 JPA 等对象关系映射工具。本章回顾了这两种方法,并探讨了 Groovy 如何简化它们。
Groovy 的Sql类移除了伴随原始 JDBC 的大部分杂乱。任何直接使用 JDBC 的代码都可以使用Sql类显著简化。
许多现代应用程序使用 JPA 进行持久化,特别是 Hibernate 作为底层 API,Spring 框架处理单例和事务。仅配置这样的应用程序就是一个非平凡的任务。另一方面,Grails 框架优雅地处理所有这些,并且几乎不需要任何努力。
最后,许多所谓的 NoSQL 数据库都有 Java API。其中一些,如 MongoDB,包括一个 Groovy 包装器,使得与底层数据库的工作变得简单。
第九章. RESTful Web 服务
本章涵盖
-
REST 架构风格
-
使用 JAX-RS 在 Java 中实现 REST
-
使用 Groovy 客户端访问 RESTful 服务
-
超媒体
RESTful Web 服务在当今的 API 设计中占主导地位,因为它们提供了一种方便的机制,以高度解耦的方式连接客户端和服务器应用程序。特别是移动应用程序使用 RESTful 服务,但一个好的 RESTful 设计模仿了最初使网络如此成功的特点。
在讨论了 REST 的一般情况之后,我将讨论服务器端,然后是客户端,最后是超媒体问题。图 9.1,9.2,和 9.3 展示了本章中的不同技术。
图 9.1. 本章中的服务器端 JAX-RS 技术。JAX-RS 2.0 基于注解,但包括响应的构建器。URI 映射到资源中的方法,这些方法通过注解分配。资源作为使用客户端头的内容协商的表示返回。

图 9.2. 本章中的客户端 REST 技术。与 JAX-RS 1.x 不同,2.0 版本包括客户端类。Apache 也有一个通用的客户端,它在 Groovy 的 HttpBuilder 项目中进行了封装。最后,您可以使用标准的 Groovy 类手动解析请求和构建响应。

图 9.3. 本章中的超媒体方法。在 JAX-RS 中,超媒体通过 HTTP 头中的过渡链接、消息体中的结构链接或使用构建器和解析器定制的响应来实现。

9.1. REST 架构
代表性状态转移(Representational State Transfer,REST)这个术语来自 Roy Fielding 2000 年的博士论文([1)],他是一位拥有史上最伟大简历的人之一.([2)
¹ “Architectural Styles and the Design of Network-based Software Architectures,”可在 www.ics.uci.edu/~fielding/pubs/dissertation/top.htm 上在线获取。
² 菲尔德宁是 Apache 软件基金会的共同创始人;曾是 URI、HTTP 和 HTML 规范的 IETF 工作组成员;并帮助建立了一些原始的 Web 服务器。我将他轻松地列入了 CS 简历的前十名,与像 James Duncan Davidson(Tomcat 和 Ant 的第一版创造者;他基本上拥有 90 年代)、Sir Timothy Berners-Lee(创建了网络 → 勋章 FTW)和 Haskell Curry(其名字是终极函数式编程语言,其姓氏是一种基本的编码技术;如果你的 名字 就是你的简历,你就赢了)等人并列。
在他的论文中,菲尔德宁将 REST 架构定义为可寻址资源和它们之间的交互。当限制在通过互联网(不是架构的要求,但今天最常见的使用方式)发出的 HTTP 请求时,RESTful 网络服务基于以下原则:
-
可寻址资源— 项目可以通过 URI 被客户端访问。
-
统一接口— 使用标准的 HTTP 动词 GET、POST、PUT 和 DELETE 访问和修改资源.^([3])
³ 一些服务支持将 HEAD 请求作为返回空响应的 GET 请求,将 OPTIONS 请求作为在特定地址指定有效请求类型的一种替代方式。PATCH 请求被提议作为一种进行部分更新的方法。
-
内容协商— 客户端可以请求不同格式的资源表示,通常通过在请求的
Accept头部中指定所需的 MIME 类型。 -
无状态服务— 与资源的交互是通过自包含的请求完成的。
基于这些想法的 Web 服务旨在具有高度的可扩展性和可扩展性,因为它们遵循使 Web 本身具有高度可扩展性和可扩展性的机制。
RESTful 网络服务可扩展性的部分来源于 安全 和 幂等 这两个术语:
-
安全— 不修改服务器的状态
-
幂等性— 可以重复执行而不产生任何额外效果
GET 请求既安全又幂等。PUT 和 DELETE 请求是幂等的但不安全。它们可以重复执行(例如,如果出现网络错误)而不产生任何额外变化.^([4)POST 请求既不安全也不幂等。
⁴ 有时很难想象 DELETE 请求是幂等的,但如果你多次删除同一行,它仍然会消失。
另一个关键概念是作为应用程序状态引擎的超媒体,它有一个真正不幸、难以发音的首字母缩略词 HATEOAS。我所知道的许多 REST 倡导者^([5])只是简单地说是“超媒体”。
⁵ 通常被称为 RESTafarians。
本节中定义的原则是架构性的,因此与实现语言无关。在下一节中,我将讨论针对实现 RESTful 服务的 Java 特定规范,即 JAX-RS。
⁶ 对不起。
9.2. Java 方法:JAX-RS
Java EE 规范包括 RESTful 服务的 Java API。版本 1.18 来自 JSR 311。新版本 2.0 是 JSR 339 的实现,并于 2013 年 5 月发布。
在本节中,我将在一个简单的 POJO 上实现一组 CRUD 方法.^([7]) JAX-RS 部分不依赖于这一点,所以我将单独讨论。我将从基本基础设施开始,然后转向 REST。
⁷ 是的,这是一个 URL 驱动的数据库,是的,这违反了超媒体原则。我保证稍后会解释这一点。
Java 开发者实际上使用什么来实现 REST?
在这本书中,我通常从 Java 开发者针对特定问题使用的解决方案开始,然后展示 Groovy 如何帮助 Java 实现,最后讨论 Groovy 提供的替代方案。当我描述 Java 开发者通常使用的内容时,我默认使用 Java SE 或 EE 规范提供的内容。
对于 REST 来说,情况并非如此。除了规范,Java 开发者还使用几个第三方替代方案。其中最受欢迎的是 Restlet (restlet.org/)、RestEasy (www.jboss.org/resteasy) 和 Restfulie (restfulie.caelum.com.br/),还有其他替代方案。在这个时候很难知道,如果有的话,哪个将成为几年后 Java 开发者选择的 REST 框架.([8])([9))
⁸ Spring REST 不遵循 JAX-RS 规范。Apache CXF 是为 JAX-WS 设计的,但最新版本支持 JAX-RS。Apache Wink 是另一个 JAX-RS 1.x 实现。
⁹ 如果我必须下注,我会选择 Restlet。我所知道的许多优秀的 REST 开发者都非常喜欢它。
因此,我将本章基于 JAX-RS 规范,尽管它可能不是最受欢迎的替代方案。当替代方案不是显而易见的时候,规范通常获胜.^([10])
¹⁰ 除了它不这么做的时候。例如,JDO 仍然是 Java EE 的一部分。
本节中的应用程序将一个名为Person的 POGO 作为 JAX-RS 2.0 资源公开。该应用程序支持 GET、POST、PUT 和 DELETE 操作,并最终支持超媒体链接。
该项目的基础设施包括 POJO、Spock 测试和基于 H2 数据库的 DAO 实现。虽然这些实现很有趣,但它们只是讨论 RESTful 服务以及 Groovy 如何简化其开发的真正目标的辅助内容。因此,它们将不会在本章中详细介绍。通常,包括 Gradle 构建文件和测试在内的完整类可以在本书的源代码仓库中找到。
简要总结一下,Person 类将在下一列表中展示。
列表 9.1. 用于 RESTful 网络服务的 Person POGO
class Person {
Long id
String first
String last
String toString() {
"$first $last"
}
}
Person 对象的 DAO 接口包括查找方法,以及创建、更新和删除 Person 的方法。具体内容如下所示。
列表 9.2. 包含 Person CRUD 方法的 DAO 接口
import java.util.List;
public interface PersonDAO {
List<Person> findAll();
Person findById(long id);
List<Person> findByLastName(String name);
Person create(Person p);
Person update(Person p);
boolean delete(long id);
}
DAO 的实现使用 Groovy 的 groovy.sql.Sql 类完成,就像在第八章(关于数据库)中一样。与该章节不同的是,id 属性由数据库生成。以下是使用 Sql 类检索生成的 ID 的方法:
Person create(Person p) {
String txt = 'insert into people(id, first, last) values(?, ?, ?)'
def keys = sql.executeInsert txt, [null, p.first, p.last]
p.id = keys[0][0]
return p
}
executeInsert 方法返回生成的值的集合,在这种情况下,新的 ID 被找到为第一行中的第一个元素。
DAO 的 Spock 测试与第六章(关于测试)或第八章(关于数据库)中展示的类似。唯一的新部分是 Spock 中的 when/then 块被重复用于插入和删除一个新 Person。当 Spock 看到重复的 when/then 对时,它会按顺序执行它们。列表 9.3 展示了这个测试,它插入了一个代表 Peter Quincy Taggart 的行,^([11]) 验证他是否被正确存储,然后删除该行。回想一下,Spock 中非常酷的 old 方法在执行 when 块之前评估其参数,因此它可以与 when 块完成后评估的表达式的其余部分进行比较。
(11) 记得他吗?NSEA 保护队的指挥官?“永不放弃,永不屈服?”这是《银河护卫队》,一部《星际迷航》的恶搞电影,但可以说是其中较好的电影之一。你知道保护者的编号是 NTE-3120,而 NTE 代表“不是企业”吗?按照 Grabthar 的锤子,当你写一本 Groovy/Java 集成书籍时,这就是你必须要做的类型的研究。
列表 9.3. 插入和删除新 Person 的 Spock 测试方法
def 'insert and delete a new person'() {
Person taggart = new Person(first:'Peter Quincy', last:'Taggart')
when:
dao.create(taggart)
then:
dao.findAll().size() == old(dao.findAll().size()) + 1
taggart.id
when:
dao.delete(taggart.id)
then:
dao.findAll().size() == old(dao.findAll().size()) - 1
}
现在初步工作已经完成,是时候看看 JAX-RS API 提供的功能了。
9.2.1. JAX-RS 资源和测试
现在转向应用程序的 RESTful 部分,JAX-RS API 的几个特性都涉及到了实现。在这里,我将使用 PersonResource 类来实现 CRUD 方法。
集合和项目资源
通常提供两个资源:一个用于人员实例的集合,一个用于单个人员。在这种情况下,两者都合并以保持示例简短。
首先,每个与特定类型的 HTTP 请求绑定的方法使用这些注解之一:@GET、@POST、@PUT 或 @DELETE。例如,findAll 方法可以如下实现:
@GET
public List<Person> findAll() {
return dao.findAll();
}
成功请求返回 HTTP 状态码 200。@Produces 注解向客户端标识响应的 MIME 类型。在这种情况下,我希望返回 JSON 或 XML:
@Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML})
该注解接受 MediaType 实例数组,这些实例用于根据传入请求的 Accept 头进行内容协商。
如果我想指定响应头,JAX-RS 提供了一个名为 Response 的工厂类,使用构建者设计模式。以下是使用它的 findById 方法的实现:
@GET @Path("{id}")
@Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML})
public Response findById(@PathParam("id") long id) {
return Response.ok(dao.findById(id))
.build();
}
Response 类上的 ok 方法将响应状态码设置为 200。它接受一个对象作为参数,并将其添加到响应中。@PathParam 注解还自动将输入 ID 从字符串转换为 long 类型。
插入新实例稍微复杂一些,因为新插入的实例需要自己的 URI。因为在这种情况下,生成的 URI 将包含数据库生成的 ID,所以资源方法绑定到 HTTP POST 请求,这些请求既不安全也不幂等。
实现细节
create 方法返回一个包含数据库表主键的 URL。这个细节不是你想暴露给客户端的。需要一个唯一的标识符;在这里,为了简单起见,使用 ID。
新的 URI 作为其 Location 头的一部分添加到响应中。新的 URI 是使用 JAX-RS 的 UriBuilder 类根据传入的 URI 生成的:
UriBuilder builder =
UriBuilder.fromUri(uriInfo.getRequestUri()).path("{id}");
表达式中的 uriInfo 引用指向从应用程序上下文注入的 UriInfo 对象。这被添加为属性到实现中:
@Context
private UriInfo uriInfo;
通常,REST 应用程序中任何插入方法的响应要么是“无内容”,要么是实体本身。在这里的 create 方法中,我决定使用实体,因为它包含了生成的 ID,以防客户端需要它。
将所有这些放在一起,create 方法如下:
@POST
@Consumes({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML})
@Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML})
public Response create(Person person) {
dao.create(person);
UriBuilder builder =
UriBuilder.fromUri(uriInfo.getRequestUri()).path("{id}");
return Response.created(builder.build(person.getId()))
.entity(person)
.build();
}
@POST 注解将响应的 HTTP 状态码设置为 201。
资源 URL 模式总结如下:
-
基础资源模式是
/people。在该 URL 发起的 GET 请求返回所有Person实例。使用Person的复数形式是因为这个原因。 -
在相同的 URL (
/person) 上发起 POST 请求创建一个新的Person,为其分配一个自己的 URL,并将其保存到数据库中。 -
/people/lastname/{like}的子资源使用 URL 模板(like参数)执行类似 SQL 的查询,并找到所有姓氏满足该条件的Person实例。 -
使用 URL 模板
{id}的子资源支持返回具有该 ID 的Person实例的 GET 请求。 -
在
{id}URL 上执行 PUT 和 DELETE 请求分别更新和删除Person实例。
下面的列表显示了用于管理 Person 实例的完整 PersonResource 类。
列表 9.4. 用于 Person POJO 的 Java 资源类


为了验证一切是否正常工作,我再次使用 Spock 提供一个测试类。测试 RESTful API 需要一个可以部署应用程序的服务器。Jersey 引用实现包括一个名为 Grizzly 的服务器用于此目的。
Spock 测试方法 setupSpec 和 shutdownSpec 分别在单个测试之前和之后各执行一次。因此,它们成为启动和停止服务器的合适位置,如下所示:
@Shared static HttpServer server
void setupSpec() {
server = GrizzlyHttpServerFactory.createHttpServer(
'http://localhost:1234/'.toURI(), new MyApplication())
}
void cleanupSpec() {
server?.stop()
}
createHttpServer 方法在指定的 URI 上启动服务器并将 RESTful 应用程序部署到它上面。MyApplication 类非常简单:
public class MyApplication extends ResourceConfig {
public MyApplication() {
super(PersonResource.class, JacksonFeature.class);
}
}
类 MyApplication 继承了一个名为 ResourceConfig 的 JAX-RS 类,该类的构造函数接受所需的资源和特性作为参数。这里使用的 JacksonFeature 提供了将 PersonResource 实例转换为 JSON 以及反向转换的机制.^([12])
¹² 一提到 JSON,我就是在谈论表示,而不是资源。再次强调,我将在第 9.5 节(#ch09lev1sec5)中讨论超媒体。
注意在关闭服务器时使用的方便的 safe-dereference 运算符 ?.,这将在服务器无法正确启动时避免空指针异常。
第一个实际测试验证服务器已启动并运行,使用 HttpServer 类上的 isStarted 方法:
def 'server is running'() {
expect: server.started
}
再次,使用标准 Groovy 语法访问属性来调用 isStarted 方法。当然,如果你更喜欢,你也可以直接调用该方法。
其余^([13]) 的测试方法需要客户端使用适当的动词生成 HTTP 请求。在 Groovy 中,GET 请求很简单,因为你可以利用 Groovy JDK 添加到 java.net.URL 类中的 getText 方法。因此,检索所有实例的请求可以写成如下:
¹³ 再次强调,这里没有双关语。
'http://localhost:1234/people'.toURL().text
虽然这样也可以工作,但响应需要被解析以获取适当的信息。通常这不会是问题,但在这里我使用了一个替代方案。
类 RESTClient 是 HttpBuilder (groovy.codehaus.org/modules/http-builder/) 项目的组成部分。我将在第 9.4 节(#ch09lev1sec4)中进一步讨论 Groovy 客户端,但到目前为止,让我说它定义了由 Apache 的 HttpClient 项目提供的 Java 类的 Groovy 类。因此,测试中包含了一个类型为 RESTClient 的属性,如下所示:
RESTClient client
new RESTClient('http://localhost:1234/', ContentType.JSON)
客户端指向正确的端点,第二个参数指定请求中 Accept 头部的内容类型。使用此客户端的 GET 请求返回一个对象,可以查询头部属性以及数据:
def 'get request returns all people'() {
when:
def response = client.get(path: 'people')
then:
response.status == 200
response.contentType == 'application/json'
response.data.size() == 5
}
其他查找方法以类似的方式进行测试。为了保持测试的独立性,插入和删除方法一起测试;首先插入一个人,然后验证,然后再次删除。测试使用了 Spock 的另一个特性:每个块(when/then/expect 等)都可以提供一个字符串来描述其目的。这并不完全是行为驱动开发,但它是 Spock 目前所能达到的最接近的方式。
插入和删除测试看起来如下:
def 'insert and delete a person'() {
given: 'A JSON object with first and last names'
def json = [first: 'Peter Quincy', last: 'Taggart']
when: 'post the JSON object'
def response = client.post(path: 'people',
contentType: ContentType.JSON, body: json)
then: 'number of stored objects goes up by one'
getAll().size() == old(getAll().size()) + 1
response.data.first == 'Peter Quincy'
response.data.last == 'Taggart'
response.status == 201
response.contentType == 'application/json'
response.headers.Location ==
"http://localhost:1234/people/${response.data.id}"
when: 'delete the new JSON object'
client.delete(path: response.headers.Location)
then: 'number of stored objects goes down by one'
getAll().size() == old(getAll().size()) - 1
}
给定一个表示人的 JSON 对象,通过 POST 请求将其添加到系统中。返回的对象包含状态码(201)、内容类型(application/json)、返回的人对象(在 data 属性中),以及新资源的 URI 在 Location 头部。删除对象是通过向新 URI 发送 DELETE 请求并验证存储的实例总数减少一个来完成的。
更新是通过 PUT 请求完成的。为了确保 PUT 请求是幂等的,需要在请求体中指定完整对象。这就是为什么 PUT 请求通常不用于插入;客户端不知道新插入对象的 ID,所以使用 POST 请求代替。
完整的测试在下一列表中展示。
列表 9.5. 使用方便的测试服务器的 PersonResource 的 Spock 测试


JAX-RS 注解的使用很简单。使用它们构建 URL 驱动的 API 并不难。规范 2.0 版本还包括客户端 API,但这里没有展示。
(JAX-RS)学到的经验教训
-
JAX-RS 2.0 是 Java EE 规范的一部分,和大多数最近的规范一样,是基于注解的。
-
使用 JAX-RS 构建超链接驱动的数据库非常容易。
-
在 JAX-RS 中确实存在超媒体机制,但它们隐藏得很好。
相反,我想展示相同规范的 Groovy 实现,主要是为了说明代码简化。之后我会处理超媒体的问题。
9.3. 使用 Groovy 实现 JAX-RS
虽然 Groovy 并没有以任何根本的方式改变 JAX-RS,但像往常一样,它简化了实现类。JAX-RS 已经通过提供自己的 DSL 来简化实现,因此 Groovy 的修改是最小的。
上一节使用了 Groovy 实现,但没有展示。这里我将展示足够的示例来说明 Groovy 功能。
首先,这是 Person POGO。注意 @XmlRootElement 注解,它用于控制响应中 Person 的序列化。通常这用于 Java API for XML Binding (JAXB),但由于 Jackson JSON 解析器的存在,序列化过程会生成 JSON 对象:
@XmlRootElement
@EqualsAndHashCode
class Person {
Long id
String first
String last
String toString() { "$first $last" }
}
属性获取器、设置器和构造函数都是按常规方式生成的。@EqualsAndHashCode AST 转换负责 equals 和 hashCode 方法的实现。@ToString 注解也可以使用,但所需的 toString 方法几乎和它一样长,所以我只是直接写出来。
说到 AST 转换,当在 Groovy 中实现时,@Singleton 注解应用于 JdbcPersonDAO 类。这通过使构造函数私有、添加静态 instance 变量等方式自动实现并强制类的 singleton 属性。该类实现了与之前相同的接口。以下是类的开头:
@Singleton
class JdbcPersonDAO implements PersonDAO {
static Sql sql = Sql.newInstance(
url:'jdbc:h2:db', driver:'org.h2.Driver')
static {
sql.execute 'drop table if exists people'
...
}
...
}
Groovy 和 Java 接口
Java 工具更喜欢 Java 接口。如果你使用 Java 接口和 Groovy 实现来集成,大多数 Java/Groovy 集成问题都会消失。
从 Java 转换到 Groovy 需要一点语法上的变化。@Produces 和 @Consumes 注解接受它们支持的媒体类型列表。在 Java 实现中,这表示为一个使用花括号表示法的数组:
@Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML})
在 Groovy 中,花括号表示闭包。然而,方括号用于界定列表,因此 Groovy 的实现只是将花括号替换为方括号。
花括号与方括号
Groovy 使用花括号定义闭包,因此定义 Java 数组的字面表示法应该使用方括号来表示 java.util.ArrayList。
下一个列表展示了 Groovy 中 PersonResource 的完整实现。
列表 9.6. PersonResource 类的 Groovy 实现
@Path('/people')
class PersonResource {
@Context
private UriInfo uriInfo
PersonDAO dao = JdbcPersonDAO.instance
@GET
@Produces([MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML])
List<Person> findAll() {
dao.findAll();
}
@GET @Path("lastname/{like}")
@Produces([MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML])
List<Person> findByName(@PathParam("like") String like) {
dao.findByLastName(like);
}
@GET @Path("{id}")
@Produces([MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML])
Response findById(@PathParam("id") long id) {
Response.ok(dao.findById(id))
.build()
}
@POST
@Consumes([MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML])
@Produces([MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML])
Response create(Person person) {
dao.create(person);
UriBuilder builder =
UriBuilder.fromUri(uriInfo.requestUri).path("{id}")
Response.created(builder.build(person.id))
.entity(person)
.build()
}
@PUT @Path("{id}")
@Consumes([MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML])
@Produces([MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML])
Person update(Person person) {
dao.update(person)
person
}
@DELETE @Path("{id}")
Response remove(@PathParam("id") long id) {
dao.delete(id);
Response.noContent().build()
}
}
大多数关于 JAX-RS 的讨论都止步于此,有一个工作状态、由 URL 驱动的数据库。然而,真正的 REST 比这更灵活。RESTful 服务应该像网络一样行动,向客户端提供一个单一的 URL,客户端访问它并返回额外的链接。这被称为 HATEOAS,或简单地称为超媒体。
从 JAX-RS 与 Groovy 中学到的经验教训
-
Groovy 并没有显著改变 JAX-RS。
-
Groovy 的真正简化在于 POGO 和 DAO 类。两种语言中的资源实现基本上是相同的。
超媒体链接暴露给客户端,客户端消费它们。JAX-RS 1.x 不包括客户端 API。2.0 版本包括了一个,Groovy 生态系统中的 HttpBuilder 项目是一个方便的项目,用于执行 HTTP 请求。这两个都是下一节的主题。
9.4. RESTful 客户端
访问 RESTful 网络服务涉及创建适当类型的 HTTP 请求,并将任何必要的信息添加到体中。当从版本 1 迁移到版本 2 时,JAX-RS 最大的变化之一是添加了标准客户端 API。该 API 包括 Client 和 WebTarget 类,其使用方法如下:
Client cl = ClientBuilder.newClient()
WebTarget target = cl.target('http://localhost:1234/people/3')
def resp = target.request().get(Response.class)
从 ClientBuilder 创建一个 Client 实例,它又指向一个 Web-Target。GET 请求使用 get 方法,其参数是返回对象的数据类型。此示例取自下一节中展示的媒体超文本测试。
在 Groovy 中,Groovy JDK 使得 GET 请求变得非常简单。Groovy JDK 将 toURL 方法添加到 java.lang.String 中,将 String 转换为 java.net.URL 的实例。Groovy JDK 还将 getText 方法添加到 java.net.URL 中。因此,从网络获取信息可以像这样简单:
String response = 'http://localhost:1234/people/3'.toURL().text
在 Groovy 中,与 Java 一样,使用 POST、PUT 和 DELETE 请求并不有趣。相反,最好通过库来访问客户端。
最受欢迎的 HTTP 库之一是开源的 Apache HTTP 客户端库 (hc.apache.org/httpcomponents-client-ga/index.html),它是 Apache HttpComponents 项目的一部分。
我更愿意关注相应的 Groovy 项目 HttpBuilder,而不是展示那个库的细节。HttpBuilder 项目 (groovy.codehaus.org/modules/http-builder/) 遵循经典的 Groovy 习惯:封装一个 Java 库并使其更容易使用。虽然网站上的文档还不错,但我建议查看源代码中的测试用例,以了解如何使用 API。
与大多数酷项目一样,源代码托管在 GitHub 上,地址为 github.com/jgritman/httpbuilder。API 包含一个方便的用于 REST 应用的类 RESTClient,我在本章的测试中使用了它。相应的测试类 RESTClientTests 展示了如何使用所有标准 HTTP 动词访问 Twitter。
我在 PersonResourceSpec 测试中使用了 RESTClient 类。RESTClient 类有一个构造函数,它接受两个参数,即基础 URL 和内容类型:
RESTClient client = new RESTClient(
'http://localhost:1234/', ContentType.JSON)
在这种情况下,我在端口 1234 上运行 Grizzly 测试服务器,并且对于这个演示,数据以 JSON 格式存在。GET 方法的测试产生了以下结果:
def response = client.get(path: 'people')
response.status == 200
response.contentType == 'application/json'
response.data.size() == 5
RESTClient 提供了一个 get 方法,它接受一个 path 参数。响应返回具有(大多数)典型头部的特殊属性。其他头部可以通过请求 allHeaders 属性或通过调用 get-Header("...") 并提供所需的头部来检索。响应体中的任何返回实体都在 data 属性中。
查看 PersonResourceSpec 类的其余部分([7]),以获取 POST、PUT 和 DELETE 请求的示例。
^(14) 再次,抱歉。在某个时刻(这可能已经发生),当我说,“没有这个意思,”你可能根本不会相信我。
学习到的经验(REST 客户端)
-
JAX-RS 2.0 包含用于构建 REST 客户端的类.^([15])
^(15) JAX-RS 客户端类也非常容易使用,这在尝试展示 Groovy 有多酷的时候很不幸,但对于用户来说很有帮助。哦,算了。
-
Groovy 项目的 HttpBuilder 包装了 Apache HttpClient 项目,使其更容易使用。
在超媒体部分的测试用例中,RESTClient 和 JAX-RS 2.0 客户端都被使用,这为最终讨论 Java 中的 HATEOAS 提供了一个很好的过渡。
9.5. 超媒体
一系列资源 URL 不是一个 RESTful 网络服务。充其量,它是一个 URL 驱动的数据库。然而,像这样的应用程序,它们声称是 RESTful 服务,遍布整个网络。
一个真正的^(16) REST 应用理解到,尽管尝试尽可能保持稳定,但特定的资源 URL 可能会发生变化。因此,请求的目的是发现后续的 URL。我们习惯于有一个固定的 API,因此这个概念可能很难接受。你不知道任何给定请求会返回什么,但你知道如何发出第一个请求,并查询结果以了解接下来可能发生什么。这与我们浏览网页的方式相似,这并非巧合。
^(16) 这里的“真正”被定义为“至少尝试遵循 Roy Fielding 的论文中的原则。”
虽然这给客户端和服务器带来了更高的负担。服务器需要添加一些类型的元数据来解释后续资源是什么以及如何访问它们,客户端需要读取这些响应并正确解释它们。
本节将说明你可以如何向服务响应中添加链接。我会从一个公共 API 的例子开始,然后演示如何将链接添加到 HTTP 响应头或响应体中,最后演示如何自定义输出方式。
9.5.1. 一个简单的例子:烂番茄
作为简单的例子,考虑在第八章中使用的电影评论网站烂番茄提供的 API。烂番茄 API 只支持 GET 请求,因此它不是一个完整的 RESTful 服务.^(17])
^(17) 只支持 GET 的 RESTful 服务可以被称为 GETful 服务。如果它们也是无状态的,那么它们不就成了 FORGETful 服务?谢谢,谢谢。我整个星期都会在这里。尝试牛排,别忘了给服务员小费。
使用基于 URL 的 API 查询包含单词 trek 的电影看起来是这样的:
api.rottentomatoes.com/api/public/v1.0/movies.json?q=trek&apikey=3...
在生成的 151 (!) 部电影中,^([18]) 如果我选择 星际迷航:暗黑无界,我会得到一个如下所示的 JSON 对象(省略了很多部分):
^(18)包括一个名为,我必须说的是,星际迷航与蝙蝠侠。企业号回到 20 世纪 60 年代,被小丑和猫女占领。真的。
{
"id": "771190753",
"title": "Star Trek Into Darkness",
"year": 2013,
...,
"synopsis": "The Star Trek franchise continues ...",
...,
"links": {
"self": "http://api.rottentomatoes.com/.../771190753.json",
"cast": "http://api.rottentomatoes.com/.../771190753/cast.json",
"clips": "http://api.rottentomatoes.com/.../771190753/clips.json",
"reviews": "http://api.rottentomatoes.com/.../771190753/reviews.json",
"similar": "http://api.rottentomatoes.com/.../771190753/similar.json"
}
}
电影对象(使用 JSON 表示的资源)包含一个名为links的条目,它本身是一个键值映射。links对象中的所有键都指向其他资源,例如完整的演员列表或评论。
Rotten Tomatoes 服务将链接添加到单个资源中,而不是将其附加到响应头中。该网站使用自己的格式而不是其他标准.^([19]) 它还通过在 URL 本身嵌入“ .json”字符串来处理内容协商。
^(19)尝试标准化 JSON 链接包括www.subbu.org/blog/2008/10/generalized-linking和www.mnot.net/blog/2011/11/25/linking_in_json。
客户端当然需要知道所有这些信息,但通过在响应中包含一个links部分,服务器可以明确指出接下来期望的内容。客户端可以简单地展示这些链接给用户,或者尝试将它们置于上下文中,这需要额外的理解。
为基于超媒体的 RESTful 服务生成一个好的客户端不是一个简单任务。
注意一个有趣的观点:整个 API 使用 JSON 来表示对象。到目前为止,在本章中,我使用术语资源不仅代表服务器端暴露给客户端的对象,还代表其表示方式。正式来说,术语表示用来描述资源的形态。
表示
表示是一个不可变、自描述、无状态的资源快照,可能包含指向其他资源的链接。
最常见的表示形式是 XML 和 JSON,其中 JSON 几乎无处不在。
理查森成熟度模型:一个精心设计的演示
理查森成熟度模型(RMM)基于 Leonard Richardson 在 2008 年的一次演讲,他描述了 REST 采用的多个级别。
RMM 有四个级别,编号从零到三:
-
零级:通过 HTTP 的简单老式 XML(POX)— HTTP 仅仅是一个传输协议,服务本质上是通过它进行的远程过程调用。听起来很像 SOAP,对吧?这并非巧合。
-
第一级:可寻址资源— 每个 URI 对应服务器端的一个资源。
-
第二级:统一接口— API 仅使用 HTTP 动词 GET、PUT、POST 和 DELETE(可能还包括 OPTIONS 和 TRACE)。
-
第三级:超媒体— 响应的表示包含定义过程额外步骤的链接。服务器甚至可以定义自定义 MIME 类型来指定如何包含额外的元数据。
现在,老实说,我对这个模型没有任何异议。它对 Roy Fielding 的论文来说是基本的;除非你也有超媒体,否则你并不是真正采用了 REST。
然而,单词maturity却带有许多情感负担。谁愿意他们的实现不够成熟?这也并非巧合,SOAP 被认为是成熟度级别 0。模型是好的,但无需给它加上带有评判色彩的语气,使其感觉像是一场被操纵的演示。
JAX-RS 中的超媒体^([20])通过链接工作,链接有两种类型:
^(20)信不信由你,超媒体和 HATEOAS 这两个词在 JSR 339 规范中根本没有出现。我对此没有解释。
-
HTTP 头中的过渡链接
-
响应中嵌入的结构链接
图 9.4 在单个 HTTP 响应中显示了两者。
图 9.4. 过渡链接出现在 HTTP 响应头中,而结构链接是响应对象的一部分。在每种情况下,链接都可以用来访问其他资源。

JAX-RS 规范的 2.0 版本支持使用Link和LinkBuilder类进行过渡链接,以及使用特殊的 JAXB 序列化器进行结构链接。
为了说明两者,我将继续使用之前提到的Person示例,并为每个实例添加链接。每个人有三种可能的链接:
-
一个
self链接,包含该人的 URL -
一个
prev链接,指向 ID 比当前人 ID 小 1 的人 -
一个
next链接,指向 ID 比当前人 ID 大 1 的人
这是一个相当牵强的例子,但它具有简单性的优势。
我首先将链接添加到 HTTP 头中,并展示如何使用它们。然后,我将使用结构链接,使用 JAXB 序列化器。最后,我将控制输出生成过程,并使用 Groovy 的JsonBuilder自定义输出写入器。
9.5.2. 添加过渡链接
要创建过渡链接,JAX-RS API 从javax.ws.rs.core包中的内部类Response .Response-Builder开始。ResponseBuilder有三个相关方法:
public abstract Response.ResponseBuilder link(String uri, String rel)
public abstract Response.ResponseBuilder link(URI uri, String rel)
public abstract Response.ResponseBuilder links(Link... link)
前两个示例向 HTTP 响应添加单个Link头。第三个示例向响应添加一系列头。以下是从PersonResource类中的一个示例:
@GET @Produces(MediaType.APPLICATION_JSON)
Response findAll() {
def people = dao.findAll();
Response.ok(people).link(uriInfo.requestUri, 'self').build()
}
在这种情况下,link方法使用请求 URI 作为第一个参数,并将rel属性设置为self。相应的测试如下访问链接:
def 'get request returns all people'() {
when:
def response = client.get(path: 'people')
then:
response.status == 200
response.contentType == 'application/json'
response.headers.Link ==
'<http://localhost:1234/people>; rel="self"'
}
此示例仅返回一个Link头。对于多个链接(例如,每个个人的三个过渡链接prev、next和self),getHeaders('Link')方法检索所有这些链接。
在PersonResource中,链接是通过一个私有方法设置的,如下一列表所示。
列表 9.7. 为每个人设置prev、self和next链接头

所以所谓的“self”链接为每个人生成。对于位于第一个和最后一个元素之间的元素,生成下一个和上一个链接。链接本身是通过字符串操作生成的。
使用links方法将链接添加到资源中:
Response findById(@PathParam("id") long id) {
Person p = dao.findById(id)
Response.*ok*(p)
.links(getLinks(id))
.build()
}
结果表明,使用RESTClient将Link头转换为有用的内容并不简单。在这种情况下,JAX-RS 的Client类效果更好。Client类有一个名为getLink的方法,该方法接受一个字符串参数,其中字符串是关系类型。该方法返回一个javax.ws.rs.core.Link类的实例,对应于 IETF 的 RFC 5988,Web 链接规范。
我将通过在客户端逐个遍历链接来演示超媒体功能。以下列表是一个 JUnit 测试用例,用 Groovy 编写,它访问next链接。
列表 9.8. 使用链接头遍历数据

客户端使用getLink方法与关系类型(next或prev)一起使用,该方法返回一个Link实例。然后getUri方法返回一个java.net.URI实例,客户端可以在下一次迭代中跟随它.^([21])
²¹ 我必须提到,这可能是过去十年中我真正可以使用
do/while循环的唯一几次之一。具有讽刺意味的是,这正是 Groovy 不支持的唯一 Java 结构。
如果你更愿意在响应体中放置链接,则需要采用不同的方法,下一节将进行描述。
9.5.3. 添加结构化链接
JAX-RS 中的结构化链接是实体内部的Link类的实例。将它们转换为 XML 或 JSON 需要特殊的序列化器,该序列化器由 API 提供。
这是Person类,扩展以包含self、next和prev链接作为属性:
@XmlRootElement
@EqualsAndHashCode
class Person {
Long id
String first
String last
@XmlJavaTypeAdapter(JaxbAdapter)
Link prev
@XmlJavaTypeAdapter(JaxbAdapter)
Link self
@XmlJavaTypeAdapter(JaxbAdapter)
Link next
}
prev、self和next链接是javax.ws.rs.core.Link类的实例,与之前一样。Link.JaxbAdapter是一个内部类,它告诉 JAXB 如何序列化链接。
在资源中设置链接引用的值,这次使用了一个有趣的 Groovy 机制:
Response findById(@PathParam("id") long id) {
Person p = dao.findById(id)
getLinks(id).each { link ->
p."${link.rel}" = link
}
}
与头部分节中使用的相同getLinks私有方法,但这次链接被添加到Person实例中。通过调用link.rel(这会调用getRel方法)并将结果注入到一个字符串中,效果是调用p.self、p.next或p.prev,具体情况而定。在每种情况下,这将调用相关的 setter 方法并将属性分配给右侧的链接。
使用RESTClient测试结构化链接如下:
def 'structural and transitional links for kirk are correct'() {
when:
def response = client.get(path: 'people/3')
then:
'James Kirk' == "$response.data.first $response.data.last"
response.getHeaders('Link').each { println it }
assert response.data.prev.href == 'http://localhost:1234/people/2'
assert response.data.self.href == 'http://localhost:1234/people/3'
assert response.data.next.href == 'http://localhost:1234/people/4'
}
响应封装了一个Person实例,通过调用getData来访问。然后检索单个链接作为prev、self和next属性。结果是Link实例,其getHref方法可用于验证链接。
只有一个问题,这更像是一个麻烦,而不是其他。在超媒体部分的开始处,Rotten Tomatoes 示例中的链接不是电影的最顶层属性。相反,每个电影表示都包含一个键为 links 的 JSON 对象,其中包含单个链接和关系的列表。以下是 Rotten Tomatoes 响应的片段:
"links": {
"self": "http://api.rottentomatoes.com/.../771190753.json",
"cast": "http://api.rottentomatoes.com/.../771190753/cast.json",
"clips": "http://api.rottentomatoes.com/.../771190753/clips.json",
"reviews": "http://api.rottentomatoes.com/.../771190753/reviews.json",
"similar": "http://api.rottentomatoes.com/.../771190753/similar.json"
}
在使用序列化器的 JAX-RS 方法中,关系是属性名。如果我想创建一个类似于电影示例中的链接集合,该怎么办?为此,我需要控制序列化过程。
9.5.4. 使用 JsonBuilder 控制输出
要自定义输出生成,JAX-RS 包含一个名为 javax.ws.rs.ext 的接口 .MessageBodyWriter<T>。此接口是将 Java 类型转换为流的契约。它包含三个需要实现的方法。
第一种方法被称为 isWriteable,对于本写器支持的类型,它返回 true。对于 Person 类,实现很简单:
boolean isWriteable(Class<?> type, Type genericType,
Annotation[] annotations, MediaType mediaType) {
type == Person && mediaType == MediaType.APPLICATION_JSON_TYPE
}
此方法仅在 Person 实例上返回 true,并且只有当指定的媒体类型是 JSON 时才返回 true。
第二种方法被称为 getSize,在 JAX-RS 2.0 中已被弃用。它的实现应该返回 -1:
long getSize(Person t, Class<?> type, Type genericType,
Annotation[] annotations, MediaType mediaType) {
return -1;
}
writeTo 方法完成所有工作。在这里,我使用 groovy.json.JsonBuilder 生成我想要的输出形式,如下所示。
列表 9.9. 使用 JsonBuilder 生成嵌套链接

这里有一个特殊的怪癖值得注意。方法对单个 Link 实例调用 toString。正如 Link 的 JavaDocs 所明确指出的,Link 中的 toString 和 valueOf(String) 方法用于将字符串转换为和从字符串转换。
MessageBodyReader 接口相当类似。在这种情况下,只有两个方法:isReadable 和 readFrom。isReadable 的实现与 isWriteable 方法相同:
public boolean isReadable(Class<?> type, Type genericType,
Annotation[] annotations, MediaType mediaType) {
type == Person && mediaType == MediaType.APPLICATION_JSON_TYPE
}
readFrom 方法使用 JsonSlurper 将字符串输入转换为 Person,如下所示。
列表 9.10. 从字符串解析 Person 实例
public Person readFrom(Class<Person> type, Type genericType,
Annotation[] annotations, MediaType mediaType,
MultivaluedMap<String, String> httpHeaders,
InputStream entityStream)
throws IOException, WebApplicationException {
def json = new JsonSlurper().parseText(entityStream.text)
Person p = new Person(id:json.id, first:json.first, last:json.last)
if (json.links) {
p.prev = Link.valueOf(json.links.prev)
p.self = Link.valueOf(json.links.self)
p.next = Link.valueOf(json.links.next)
}
return p
}
readFrom 方法使用 JsonSlurper 的 parseText 方法将输入文本数据转换为 JSON 对象,然后根据结果属性实例化一个 Person。如果正文中存在链接,它们将使用 valueOf 方法进行转换。
要使用 MessageBodyWriter,我需要在实现类上添加 @Provider 注解,并确保它在应用程序中加载。后者是通过将提供者添加到 MyApplication 类中完成的:
public class MyApplication extends ResourceConfig {
public MyApplication() {
super(PersonResource.class, PersonProvider.class,
JacksonFeature.class);
}
}
在这种情况下,同时使用了 PersonProvider 和 JacksonFeature。Person 提供者将单个 Person 实例转换为 JSON,而 JacksonFeature 处理集合。对结果结构的测试如下所示:
def 'transitional links for kirk are correct'() {
when:
def response = client.get(path: 'people/3')
then:
'James Kirk' == "$response.data.first $response.data.last"
Link.valueOf(response.data.links.prev).uri ==
'http://localhost:1234/people/2'.toURI()
Link.valueOf(response.data.links.self).uri ==
'http://localhost:1234/people/3'.toURI()
Link.valueOf(response.data.links.next).uri ==
'http://localhost:1234/people/4'.toURI()
}
响应体现在有一个 links 元素,它包含作为子元素的 prev、self 和 next。
学习到的经验(超媒体)
-
JAX-RS 主要忽略了超媒体,但确实为它提供了一些方法。
-
过渡链接头是通过
Response-Builder中的link和links方法添加的。 -
通过特殊的 JAXB 注解在正文中添加结构链接。
-
你可以通过编写一个实现
MessageBodyReader和/或Message-BodyWriter的提供者类来自己管理解析和响应生成阶段。
在过渡链接、与 JAXB 序列化器的结构链接和 Groovy 的JsonBuilder之间,希望你现在有足够的机制以任何你应用程序需要的方式实现超媒体链接。选择使用哪种方法在很大程度上是一个风格问题,但有一些指导原则:
-
结构链接包含在响应中,因此客户端必须解析响应才能获取它们。
-
过渡链接在 HTTP 头中。这使它们从响应中分离出来,但迫使客户端解析 HTTP 响应头以检索它们。
-
自定义链接可以是任何东西,因此它们必须被清楚地记录。
在网络上可以找到所有三种方法的示例。
9.6. 其他 Groovy 方法
在 Groovy 生态系统中有三种其他方法我应该提到,用于 RESTful Web 服务。在这里,我将特别讨论 groovlets、Ratpack 项目和 Grails。
9.6.1. Groovlets
Groovlets 在第十章[kindle_split_022.html#ch10]中讨论了 Web 应用程序,以及第二章[kindle_split_012.html#ch02]中的简单示例,但本质上它们是接收 HTTP 请求并返回 HTTP 响应的 Groovy 脚本。Groovlets 包含许多隐式变量,包括request、response、session和params(用于存储输入变量)。
在 groovlet 中,你可以使用请求对象的getMethod方法来确定请求是 GET、PUT、POST 还是 DELETE。然后你可以相应地构建响应。
书中的源代码有一个名为SongService的项目,位于第十章[kindle_split_022.html#ch10],它展示了如何使用 groovlet。该服务本身就是一个 groovlet,如下所示。
列表 9.11. 处理并生成 XML 的 groovlet


groovlet 使用request.method在switch语句中确定正确的实现。然后它使用一个名为html的内置MarkupBuilder来生成 XML,并使用XmlSlurper将 XML 转换为歌曲实例。现在 groovlets 也有内置的JsonBuilder,因此 JSON 可以很容易地被使用。
^(22) 这是我对 Groovy 的巨大贡献——groovlet 中的隐式
json对象,我不仅添加了它,而且在添加的过程中还成功地破坏了构建。唉。如果你感兴趣,详细信息可以在mng.bz/5Vn6找到。
这种方法相当底层,但可能对快速实现或需要这种详细控制的情况很有用。
9.6.2. Ratpack
第二种选择是查看 Ratpack 项目(github.com/ratpack/ratpack)。Ratpack 是一个 Groovy 项目,它遵循 Ruby 世界中的 Sinatra^([23])项目的相同理念。Ratpack 被称为“微型”框架,因为你编写简单的 Groovy 脚本,以控制如何处理单个请求。
^(23)Sinatra,Ratpack,明白了吗?如果其他什么都没有,那也是一个很棒的名字。
例如,一个简单的 Ratpack 脚本看起来像这样:
get("/person/:personid") {
"This is the page for person ${urlparams.personid}"
}
post("/submit") {
// handle form submission here
}
put("/some-resource") {
// create the resource
}
delete("/some-resource") {
// delete the resource
}
该项目显示出很大的潜力,Sinatra 在 Ruby 世界中非常受欢迎,所以它可能值得一看。该项目最近被 Luke Daley 接管,他是 Groovy 世界中的主要人物,所以我期待很快会有显著的改进。
9.6.3. Grails 和 REST
最后,Grails 也有 REST 功能。例如,在一个 Grails 应用程序中,你可以按照以下方式编辑URLMappings.groovy文件:
static mappings = {
"/product/$id?"(resource:"product")
}
结果是,对于产品的 GET、POST、PUT 和 DELETE 请求将分别被导向ProductController中的show、save、update和delete操作。Grails 还可以自动解析和生成所需的 XML 和/或 JSON。
对于 Grails,还有一个可用的 JAX-RS 插件。目前它基于 JAX-RS 版本 1,但实现可以使用 Jersey 参考实现或 Restlets。当然,在两种情况下都没有提到超媒体,尽管你可以在 Groovy 中做的任何事情,当然也可以在 Grails 中做。
REST 功能是 Grails 3.0 的主要设计目标,因此到那时情况无疑会发生变化。
9.7. 总结
近年来,RESTful Web 服务的主题非常热门,这是有充分理由的。REST 架构使开发者能够构建灵活、高度解耦的应用程序,这些应用程序利用了使 Web 本身如此成功的相同特性。
在 Java 世界中,有许多库可用于实现 REST 架构。本章重点介绍了 JAX-RS 2.0 规范以及 Groovy 如何与之结合使用。除了基本的 URL 驱动数据库之外,还可以通过 HTTP 头中的过渡链接、实体体内的结构链接,甚至通过 Groovy 的JsonBuilder来实现超媒体。希望本章中的一些技术组合能够帮助你构建你想要的服务。
第十章. 构建和测试 Web 应用程序
本章涵盖
-
Groovy servlets 和
ServletCategory -
Groovlets
-
Web 应用的单元和集成测试
-
Groovy 杀手级应用,Grails
虽然 Java 在桌面上有其支持者,但 Java 在服务器端找到了真正的归宿。Java 在早期的发展和采用与 Web 本身的发展紧密相连。很少有 Java 开发者没有至少参与过一个 Web 应用程序的开发。
在本章中,我将探讨现代 Web 应用程序开发以及 Groovy 如何使这个过程更加简单和容易。有时 Groovy 只是简化了代码。有时它提供了有用的测试工具,如 Gradle 和 HTTPBuilder。最后,还有 Groovy 生态系统中最著名的框架 Grails。我将回顾它们所有,并尝试将它们放在 Web 应用程序的整体环境中。
是本章讨论的技术指南。
图 10.1。本章技术指南。Spring 提供了用于测试的模拟对象,这些对象也用于 Grails。使用插件和一些配置,Gradle 构建可以执行 Web 应用的集成测试。ServletCategory 类使会话、请求和其他对象更容易使用。Groovlets 是构建简单应用的一种快速方式。最后,HTTPBuilder 项目提供了一个程序性 Web 客户端,Grails 应用程序使用 Groovy DSL 和优雅的元编程将 Spring 和 Hibernate 结合到一个标准的约定优于配置框架中。

10.1。Groovy servlets 和 ServletCategory
Groovy 并没有为基本的 servlet 开发添加很多功能,但标准库确实提供了一个类别类,展示了 Groovy 的元编程能力。以下列表显示了一个简单的 servlet,HelloGroovyServlet.groovy,这是使用 Groovy 实现的 Web 应用程序的一部分。
列表 10.1。使用 Groovy 实现的简单 servlet
class HelloGroovyServlet extends HttpServlet {
void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
resp.writer.print 'Hello from a Groovy Servlet!'
}
}
除了正常的 Groovy 简化(省略 public 关键字,缺少分号,使用 writer 而不是 getWriter(),以及 print 上的可选括号)之外,这与 Java 实现并没有太大的不同。如果你更喜欢稍微简短的代码,可以使用 Groovy,但真正选择语言的问题是一个风格问题。
Groovy 提供的是一种类别类,可以进一步简化代码。类别类是 Groovy 元编程能力的一个例子。它们展示了如何在指定的代码块中向现有类添加方法,而不是像使用元类对象那样在程序中的任何地方添加它们。如果你曾经想了解类别,ServletCategory 是一个极好、极其简单、非常有用的例子。
类别
当你只需要在特定情况下使用那些方法时,使用 Groovy 类别向现有类添加方法。类别方法仅在 use 块中可用。
展示了
groovy.servlet.Servlet-Category 类的 GroovyDocs 示例。
图 10.2。ServletCategory 的 GroovyDocs。每个方法都是静态的,并添加到第一个参数中列出的类。

一个 Groovy 类别由一个或多个参数的静态方法组成。方法的第一参数是接收该方法的类。在 Servlet-Category 中只有四个方法,有很多重载(见 表 10.1)。
表 10.1. 不同作用域的 ServletCategory 方法
| 方法名称 | 第一个参数 |
|---|---|
| get(arg, String key) | ServletContext, HttpSession, ServletRequest, PageContext |
| getAt(arg, String key) | 与上面相同 |
| putAt(arg, String key, Object value) | 与上面相同 |
| set(arg, String key, Object value) | 与上面相同 |
你看到了模式吗?这个分类的任务是使在页面作用域(PageContext)、请求作用域(ServletRequest)、会话作用域(HttpSession)和应用作用域(ServletContext)中添加属性变得容易。记住,在 Groovy 中,所有操作符都对应于方法。在这种情况下,get 和 set 方法对应于点操作符,而 getAt 和 putAt 方法实现了数组索引操作符。在我展示示例之前,请看一下实际实现类 groovy.servlet.ServletCategory 的以下部分,它是在 Java 中实现的。
列表 10.2. 来自 groovy.servlet.ServletCategory 的 HttpSession 方法
public class ServletCategory {
public static Object get(HttpSession session, String key) {
return session.getAttribute(key);
}
...
public static Object getAt(HttpSession session, String key) {
return session.getAttribute(key);
}
...
public static void set(HttpSession session,
String key, Object value) {
session.setAttribute(key, value);
}
...
public static void putAt(HttpSession session,
String key, Object value) {
session.setAttribute(key, value);
}
}
首先值得注意的是,这个类是用 Java 编写的 (!),尽管它是在 Groovy 中使用的。当重载操作符时,Groovy 不关心你使用哪种语言来实现方法,只关心你使用的是否是委托给 Groovy 中方法的操作符。在这种情况下,我甚至不打算直接使用这些方法。相反,我使用点操作符和/或数组索引表示法来隐式调用它们。
这里另一个重要的细节是,所有方法都是委托给 getAttribute 或 setAttribute 方法。结果是,可以使用点操作符或索引操作符将属性添加到页面、请求、会话或应用作用域。
ServletCategory
无论你是否使用 ServletCategory,它的元编程和操作符重载的结合使其成为 Groovy 如何帮助 Java 的一个优秀示例。
Groovy 2.0 中的分类
Groovy 2.0 引入了一种用于定义分类的替代语法。在本节中讨论的 ServletCategory 中,分类类包含静态方法,其第一个参数是要修改的类。在新语法中,你可以使用注解和实例方法。
例如,考虑将数字格式化为货币。java.text.NumberFormat 类有一个名为 getCurrencyInstance 的方法,它有一个无参数方法用于格式化当前区域设置,还有一个重载版本,它接受一个 java.util.Locale 参数。向 Number 类添加一个 asCurrency 方法,并使用货币格式化器,经典的方式如下
import java.text.NumberFormat
class CurrencyCategory {
static String asCurrency(Number amount) {
NumberFormat.currencyInstance.format(amount)
}
static String asCurrency(Number amount, Locale loc) {
NumberFormat.getCurrencyInstance(loc).format(amount)
}
}
use(CurrencyCategory) {
def amount = 1234567.89012
println amount.asCurrency()
println amount.asCurrency(Locale.GERMANY)
println amount.asCurrency(new Locale('hin','IN'))
}
实现分类的新方法使用 @Category 注解,它接受要修改的类作为参数。然后在分类内部使用实例方法,this 引用指向分类被调用的对象。货币分类的类似实现如下
import java.text.NumberFormat
@Category(Number)
class AnnotationCurrencyCategory {
String asCurrency() {
NumberFormat.currencyInstance.format(this)
}
String asCurrency(Locale loc) {
NumberFormat.getCurrencyInstance(loc).format(this)
}
}
Number.mixin AnnotationCurrencyCategory
def amount = 1234567.89012
println amount.asCurrency()
println amount.asCurrency(Locale.GERMANY)
println amount.asCurrency(new Locale('hin','IN'))
还要注意使用 mixin 方法将类别添加到 Number 类。
假设现在正在实现 ServletCategory,它可能会使用注解方法。当然,无论哪种方式,结果都是相同的.^([1])
¹ 书籍源代码包括两种实现货币类别的方法以及一个测试用例。
以下将举例说明。下一列表展示了一个名为 HelloName-Servlet 的类,它使用 Groovy 实现,接收一个 name 参数,并返回标准的欢迎信息。
列表 10.3. 使用 ServletCategory 的 HelloNameServlet 类

这个类同时与请求和会话中的属性一起工作。在从请求中获取会话(这是标准的“属性访问意味着获取方法”风格,而不是类别)之后,use 块定义了类别活跃的区域。在 use 块内部,使用点符号向 request 添加了一个 name 属性,其值要么由用户以参数的形式提供,要么由默认值 World 组成。接下来,在会话中放置一个 count 属性;其值要么从现有值中递增,要么如果它不存在,则设置为 1。
测试类 HelloNameServletTest 在下一列表中展示。它使用 Spring API 模拟对象来测试 doGet 方法,无论是带参数还是不带参数。
列表 10.4. 使用 Spring 模拟对象的 HelloNameServletTest 类
import static org.junit.Assert.*;
import org.junit.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.mock.web.MockHttpSession;
class HelloNameServletTest {
HelloNameServlet servlet = new HelloNameServlet()
@Test
void testDoGetWithNoName() {
MockHttpServletRequest request = new MockHttpServletRequest()
MockHttpServletResponse response = new MockHttpServletResponse()
MockHttpSession session = new MockHttpSession()
request.session = session
servlet.doGet(request, response)
assert 'hello.jsp' == response.forwardedUrl
assert request.getAttribute("name") == 'Hello, World'
assert session.getAttribute("count") == 1
}
@Test
void testDoGetWithName() {
MockHttpServletRequest request = new MockHttpServletRequest()
MockHttpServletResponse response = new MockHttpServletResponse()
MockHttpSession session = new MockHttpSession()
request.session = session
request.setParameter('name','Dolly')
servlet.doGet(request, response)
assert 'hello.jsp' == response.forwardedUrl
assert request.getAttribute("name") == 'Hello, Dolly'
assert session.getAttribute("count") == 1
}
}
在测试中不需要 ServletCategory,因为我已经使用模拟对象而不是 Servlet API 类。请注意,测试检查了 request 和 session 属性以及从 doGet 方法转发的 URL。Servlet-``Category 类是使用 Groovy 的元编程能力简化 API 的简单示例。
作为正常 servlet 开发的简单替代方案,Groovy 提供了 groovlets。
10.2. 使用 groovlets 进行简单的服务器端开发
Groovlets 是响应 HTTP 请求执行的 Groovy 脚本。一个内置的库类 groovy.servlet.GroovyServlet 执行它们。像所有 Groovy 脚本一样,它们与一个绑定相关联,该绑定包含许多预实例化的变量。
要使用 groovlet,首先配置 GroovyServlet 以接收映射的请求。这样做的一种典型方式是将以下 XML 添加到标准的 Web 应用程序部署描述符 web.xml 中:
<servlet>
<servlet-name>Groovy</servlet-name>
<servlet-class>groovy.servlet.GroovyServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>Groovy</servlet-name>
<url-pattern>*.groovy</url-pattern>
</servlet-mapping>
GroovyServlet 类是标准 Groovy 库的一部分。在这里,它被映射到 URL 模式 *.groovy,这意味着任何以该模式结尾的 URL 都将被导向这个 servlet。例如,URL http://localhost/.../hello.groovy 将匹配到 Web 应用程序根目录下的名为 hello.groovy 的脚本。请记住,这实际上是源文件,而不是编译后的类。
Groovlets
Groovlets 以源代码的形式部署,而不是编译。
当被调用时,GroovyServlet类找到以 URL 结尾的脚本名称,预先实例化一系列变量,创建GroovyScriptEngine类的一个实例,并执行脚本。实际的脚本代码可以放置在 Web 应用根目录下的任何可访问目录中,或者放置在/WEB-INF/groovy 的任何子目录中。
groovlet 简单性的关键是这个已经配置好的基础设施。有了这个,开发者需要做的工作就少多了。
10.2.1. 一个“Hello, World!” groovlet
因为每种技术都需要一个“Hello, World!”应用程序,所以这里有一个用于问候用户的 groovlet。假设GroovyServlet已经配置好了,并在 Web 应用的根目录下添加一个名为hello.groovy的文件。在一个标准的 Maven 结构中,这将是 src/main/webapp/hello.groovy。groovlet 的内容如下
name = params.name ?: 'World'
println "Hello, $name!"
这是一个简单的 groovlet,但它仍然应该被测试。本章后面将讨论 Web 应用的集成测试,但下一个列表中的语法使用的是与前面几个章节中相同的机制来传输 GET 请求(使用 Groovy JDK 将字符串转换为URL,然后调用 URL 的getText方法)。
列表 10.5. HelloGroovletTest,hello groovlet 的集成测试
class HelloGroovletTest {
int port = 8163
@Test
void testHelloGroovletWithNoName() {
String response =
"http://localhost:$port*/HelloGroovlet/hello.groovy"*
.*toURL*().*text*
assert 'Hello, World!' == response.trim()
}
@Test
void testHelloGroovletWithName() {
String response =
"http://localhost:$port*/HelloGroovlet/hello.groovy?name=Dolly"*
.*toURL*().*text*
assert 'Hello, Dolly!' == response.trim()
}
}
这个测试没有什么特别令人惊讶或不寻常的地方,因为它很简单,因为 groovlet 只响应 GET 请求。
基于GroovyServlet将 groovlet 作为具有预定义变量的脚本执行的事实,单元测试也是可以完成的。例如,下一个列表显示了使用GroovyShell类和Binding类进行单元测试的 groovlet,其方式类似于在第六章(kindle_split_017.html#ch06)中描述的测试。
列表 10.6. 使用GroovyShell和Binding对 groovlet 进行的单元测试

这个测试的有趣之处首先在于 groovlet 期望一个输入参数的映射,因此测试必须提供一个,并且我需要一种方法来捕获 groovlet 的输出流,这通过绑定中的out变量来完成。
回想一下第六章,Groovy 还提供了一个名为GroovyTestCase的子类,称为GroovyShellTestCase,它被设计用来测试此类脚本。下面的列表显示了使用GroovyShellTestCase的相同单元测试。请注意,它明显更简单。
列表 10.7. 使用GroovyShellTestCase简化 groovlet 的单元测试

GroovyShellTestCase 类内部实例化一个 GroovyShell 并允许你通过 withBinding 方法传递一个绑定参数的映射。
10.2.2. groovlet 中的隐含变量
之前的例子显示 groovlets 期望所有请求参数都被打包到一个名为params的映射中。Groovlets 在一个包含许多隐含变量的环境中运行。表 10.2 显示了完整的列表。
表 10.2. Groovlets 中可用的隐式变量
| 变量 | 代表 | 备注 |
|---|---|---|
| request | ServletRequest | |
| response | ServletResponse | |
| session | getSession(false) | 可能为 null |
| context | ServletContext | |
| application | ServletContext (与 context 相同) | |
| params | 请求参数映射 | |
| headers | 请求/响应头映射 | |
| out | response.getWriter() | |
| sout | response.getOutputStream() | |
| html | new MarkupBuilder(out) |
之前的例子只使用了params变量。现在我将讨论一个稍微复杂一点的例子,这个例子首先在第二章中介绍的 Groovy Baseball 应用程序中使用。
下面的列表显示了完整的源代码。
列表 10.8. Groovy Baseball 应用程序中的GameService groovlet

GameService groovlet 的目标是获取用户界面提供的日期,调用GetGameData服务中的getGames方法,并将结果以 XML 形式提供给用户。groovlet 在响应中设置contentType头为 XML,检索表示请求日期的输入参数,如果需要则将它们规范化到适当的形式,调用游戏服务,并使用内置的标记构建器将游戏结果写入 XML 块。
使用标记构建器来写入 XML 在这里很有帮助。当前 Web 应用面临的一个问题是用户界面中使用的 JavaScript 代码无法解析服务器端产生的 Java 或 Groovy 对象。需要一个中间格式,双方都可以解释和生成。为此,只有两个现实的选择:XML 和 JavaScript 对象表示法(JSON)。最近的趋势是尽可能多地使用 JSON 对象,但 groovlets 内部的标记构建器使得生成 XML 变得容易。该应用程序生成的 XML 量很少,所以解析用户界面中的 XML 不是问题。
生成 XML
在需要时使用 groovlets 中的html标记构建器来写入 XML,而不是生成 HTML 网页。
这个演示很简单,但这就是重点。Groovlets 是一种方便的方式,可以接收输入数据,访问后端服务,并生成响应或将用户转发到新的目的地。因为它们有一个内置的方式将对象转换为 XML(并且添加一个JsonBuilder以转换为 JSON 也不难^([2])),所以它们是 RESTful Web 服务的理想前端。
² 事实上,我确实帮了那个忙。这就是开源的魅力;如果你有想法,就去实现它。
学习到的经验(groovlets)
-
Groovlets 是由嵌入式 servlet 执行的 Groovy 脚本。
-
Groovlets 包含请求参数、HTTP 会话等隐式对象。
-
Groovlets 使用构建器来生成格式化的输出。
在展示 Grails 框架之前,现在让我讨论一下测试 Web 应用程序的问题,包括作为单元测试的独立测试和利用 Gradle 自动化的集成测试。
10.3. 单元测试和集成测试 Web 组件
第六章讨论了单元测试 Java 和 Groovy 类的技术,并展示了 Groovy 的模拟能力如何提供一套标准库的模拟和存根来支持单元测试。测试单个类以及将这些测试作为构建过程的一部分自动运行是非常容易的。
测试如此重要,以至于大多数现代 Web 框架都将可测试性视为一个主要的设计目标,因此它们试图使各个组件易于测试。例如,原始 Struts 框架与更现代的 Struts 2、Spring MVC、JSF 或其他许多框架之间的一个主要区别是它们的组件是如何考虑测试而设计的。尽管如此,Web 组件的测试程度远不如预期。
尽管如此,单元测试和集成测试 Web 应用程序与测试系统中的任何其他内容一样重要,并且自动执行这些测试是至关重要的。通过手动在表单中输入数据并点击链接来集成测试 Web 应用程序是一种极其昂贵且容易出错的机制。必须有一种更好的方法,幸运的是 Groovy 在这个领域提供了大量帮助。
然而,为了打下基础,我将从 Spring 框架中最大的 Java 库之一提供的模拟类库开始。
10.3.1. 使用 Spring 单元测试 Servlet
Spring 框架是 Java 世界中最受欢迎的开源库之一。第七章关于 Groovy 和 Spring 的讨论中对其进行了详细阐述,但我在这里想要提及它有两个原因:(1) Spring 为单元测试 Web 应用程序提供了一套优秀的模拟对象,(2) Spring 是 Grails 的底层技术之一,因此了解 Spring 的工作原理有助于你更有效地使用 Grails。
为了说明挑战并突出测试过程中需要模拟的依赖关系,让我从一个简单的名为 HelloServlet 的 Java Servlet 类开始,以展示其结构:
public class HelloServlet extends HttpServlet {
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
resp.getWriter().print("Hello, Servlet!");
}
}
Servlets 都是通过继承创建的,通常是通过扩展 javax.servlet.http.HttpServlet。HttpServlet 是一个没有抽象方法的抽象类。它接收 HTTP 请求并将它们委托给对应于每个 HTTP 动词的 do 方法,如 doGet、doPost、doPut、doTrace 或 doOptions。这些方法都接受两个参数,一个是 HttpServletRequest 类型,另一个是 HttpServletResponse 类型。
HelloServlet 类重写了 doGet 方法以响应 HTTP GET 请求。它使用 resp 参数(HttpServletResponse 的一个实例)来获取相关的输出写入器,该写入器用于向输出流打印。
即使在这个简单的类中,单元测试的挑战性也是显而易见的。为了提醒大家单元测试的要点,让我说:
单元测试 Web 组件
单元测试 Web 应用程序的目的是在容器外运行测试。这需要所有由容器提供的类和服务的模拟对象。
在这种情况下,我需要代表类型为HttpServlet-Request和HttpServletResponse的两个对象。在大多数情况下,我还需要代表HttpSession、ServletContext,以及可能还有更多的对象。
正是 Spring 框架中的模拟类集合提供了帮助。Spring API 包括一个名为org.springframework.mock.web的包,正如 API 中所述,它包含“一套全面的 Servlet API 2.5 模拟对象,旨在与 Spring 的 Web MVC 框架一起使用。”幸运的是,它们可以与任何 Web 应用程序一起使用,无论它是否基于 Spring MVC。
³ 模拟对象在 Servlet 3.0 中同样适用,尽管 JavaDocs 中列出了少数例外。
下一个列表显示了对我“Hello, World!” servlet 的doGet方法的 JUnit 测试。
列表 10.9. HelloServletJavaTest:使用模拟对象的 servlet 测试类
import static org.junit.Assert.*;
import org.junit.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
public class HelloServletJavaTest {
@Test
public void testDoGet() {
HelloServlet servlet = new HelloServlet();
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse resp = new MockHttpServletResponse();
try {
servlet.doGet(req, resp);
assertEquals("Hello, Servlet!",
resp.getContentAsString().trim());
} catch (Exception e) {
e.printStackTrace();
}
}
}
try/catch块尽力将本质隐藏在仪式之中,但意图是清晰的。该方法实例化了 servlet 以及代表 servlet 请求和 servlet 响应类的模拟对象,然后使用模拟对象作为参数在 servlet 上调用doGet方法。好事是MockHttpServletResponse类有一个名为getContentAsString的方法,它可以捕获写入 servlet 输出流中的数据,以便与预期的答案进行比较。
注意,模拟类并不是作为传统意义上的 Spring beans(如第七章中所述)来使用的,而仅仅是作为一个可用的 API。
如图 10.3 所示,单元测试 Servlet 非常简单。实例化 servlet,提供它需要的任何模拟对象,调用适当的do方法,并检查结果。这个例子展示了getContentAsString;本章中的其他测试将展示另外两个方便的方法:getForwardedUrl和getRedirectedUrl。有了这些类和方法,就不需要部署到 servlet 容器中。
图 10.3. 使用 Spring 模拟的 Servlet 测试。Spring API 为请求、响应和会话提供了模拟类,并捕获输出、转发和重定向的 URL。

然而,到目前为止,我完全没有使用 Groovy。Groovy 提供了什么来使 Servlet 开发和测试更容易?我将在下一节回答这个问题。
尽管单元测试很重要,但并不总是足够。我想证明我的应用程序类在实际中也能正常工作,所以我还想进行集成测试。这意味着我需要一个 servlet 容器,一种部署我的 Web 应用程序的方法,以及一种触发除简单 GET 请求之外的其他请求类型的方法。这是下一节的主题。
10.3.2. 使用 Gradle 进行集成测试
Gradle 是一个用 Groovy 实现的构建工具,这在第五章(关于构建过程)中进行了广泛讨论。[kindle_split_016.html#ch05]。Gradle 使用 Groovy 构建器语法来指定仓库、库依赖项和构建任务。使用正常插件(如本书中使用的 Groovy 插件)执行构建会下载任何需要的依赖项,编译和测试代码,并准备最终的结果报告。与 Gradle 一起工作的一个优点是它提供了大量的可用插件。在本章中,我正在处理 Web 应用程序,Gradle 理解它们的结构以及常规的 Java 或 Groovy 应用程序。你只需要包含war插件,一切就会工作。更好的是,Gradle 还包含一个jetty插件,它专为测试 Web 应用程序而设计。
简单地添加以下行到 Gradle 构建文件中:
apply plugin:'war'
项目将使用 Web 应用程序的默认 Maven 结构。这意味着 web 目录 src/main/webapp 将包含任何视图层文件,如 HTML、CSS 和 JavaScript。该目录还将包含 WEB-INF 子目录,其中包含 web 部署描述符 web.xml。源结构可以按任何方式映射,但在这个部分,我将坚持使用默认的 Maven 方法。
考虑一个包含上一节中的HelloServlet的 Web 应用程序。项目布局如图 10.4 所示。figure 10.4。
图 10.4. Web 项目布局。集成测试目录将在本章后面讨论。该项目具有 Web 应用程序的标准 Maven 结构。

在这个阶段,Gradle 构建文件非常简单,如下所示。
列表 10.10. 使用war插件的 Web 应用程序的 Gradle 构建文件

列表中包含了war插件。通常,依赖项来自 Maven 中央仓库。依赖的库包括 JUnit 和用于单元测试的 Spring API 库。有趣的功能是providedCompile依赖项。这告诉 Gradle 在编译期间需要 servlet 和 JSP API,但在部署时不需要,因为容器将提供它们。
当与jetty插件结合使用时,war插件表现得非常出色。Jetty 是由 Eclipse 基金会托管的一个轻量级、开源的 servlet 容器。4 这使得测试 Web 应用程序变得方便,Gradle 的标准分发中包含了一个jetty插件。
⁴有关详细信息,请参阅www.eclipse.org/jetty/。
10.3.3. 在 Gradle 构建中自动化 Jetty
要在 Gradle 中使用 Jetty,你需要添加插件依赖项,但还需要配置一些设置:
apply plugin:'jetty'
httpPort = 8080
stopPort = 9451
stopKey = 'foo'
httpPort变量是 Jetty 将用于 HTTP 请求的端口。使用 8080 是典型的,因为它既是 Tomcat 也是 Jetty 的默认端口,但这绝对不是必需的。Jetty 容器将在stopPort上监听关闭请求,当需要关闭时,插件将向 Jetty 发送stopKey。
将插件和属性添加到 Gradle 构建中可以启用三个新任务:
1.
jettyRun,用于启动服务器并部署应用程序2.
jettyRunWar,在部署前创建 WAR 文件3.
jettyStop,用于停止服务器
这很有帮助,但我想自动化部署应用程序的过程,这样我就可以在没有人为干预的情况下运行集成测试。为了实现这一点,我需要jettyRun和jettyRunWar任务以“守护”模式运行,这意味着启动后,控制权将返回到构建,以便它可以继续执行其他任务。
因此,我在构建中添加了以下行:
[jettyRun, jettyRunWar]*.daemon = true
记住,在 Groovy 中这里的扩展点运算符(*.)意味着要为集合中的每个元素设置daemon属性。如果没有星号,点运算符将尝试在集合本身上设置属性,这是不会工作的。
测试本身可以在构建文件中定义为私有方法,并在 Gradle 任务内部调用,如下所示:
task intTest(type: Test, dependsOn: jettyRun) << {
callServlets()
jettyStop.execute()
}
private void callServlet() {
String response = "http://localhost:$httpPort*/HelloServlet/hello"*
.*toURL*().*text*.trim()
assert response == 'Hello, Servlet!'
}
intTest任务使用左移运算符(<<)定义,它是添加doLast闭包的别名。换句话说,这定义了任务但不执行它。因为任务依赖于jettyRun任务,所以如果调用此任务,jettyRun将首先被调用。任务调用私有的callServlet方法,该方法将String转换为 URL,访问网站,并将响应与预期值进行比较。一旦方法完成,intTest任务告诉 Jetty 关闭,我就完成了。
我可以直接从命令行调用intTest任务,但我想让它成为我的正常构建过程的一部分。为了做到这一点,我注意到在 Gradle 构建文件形成的有向无环图(DAG,见第五章)中,测试任务完成后紧接着的任务被称为check。
这听起来比实际要复杂得多。我需要做的只是用–m标志运行 Gradle,以防止它实际执行,这会产生以下输出:
prompt> gradle -m build
:compileJava SKIPPED
:processResources SKIPPED
:classes SKIPPED
:war SKIPPED
:assemble SKIPPED
:compileTestJava SKIPPED
:processTestResources SKIPPED
:testClasses SKIPPED
:test SKIPPED
:check SKIPPED
:build SKIPPED
BUILD SUCCESSFUL
如您所见,check任务在test任务完成后立即发生,而intTest任务根本不会执行,除非我调用它。为了将我的任务放入流程中,我将它设置为check任务的依赖项:
check.dependsOn intTest
现在如果再次运行相同的构建任务,集成测试将在适当的时间运行:
prompt> gradle -m build
:compileJava SKIPPED
:processResources SKIPPED
:classes SKIPPED
:war SKIPPED
:assemble SKIPPED
:jettyRun SKIPPED
:compileTestJava SKIPPED
:processTestResources SKIPPED
:testClasses SKIPPED
:intTest SKIPPED
:test SKIPPED
:check SKIPPED
:build SKIPPED
BUILD SUCCESSFUL
注意,jettyRun任务在测试之前也会被触发。现在一切按我想要的方式工作。
从一个角度来看,这相当是一项工程壮举。Gradle 中的类结构使得定义新任务变得容易,我可以确保我的任务在正确的时间运行,我甚至可以将测试作为 Groovy 代码直接嵌入到构建文件中。
当然,问题是,我可以将测试作为 Groovy 代码直接嵌入到我的构建文件中。在这个例子中,这行得通,但在构建文件中进行业务逻辑(甚至测试)不能是一个好的长期解决方案。测试用例不是构建的一部分;构建调用它们。在构建内部,它们难以维护且不易重用。
10.3.4. 使用集成测试源树
将测试基础设施与实际测试分开的一个好方法是为其创建一个特殊的源树。这为测试提供了一个方便的位置,它们将在构建的正确时间自动运行。
Gradle 项目有一个sourceSets属性,可以用来映射源目录,如果它们不符合默认的 Maven 模式。第五章中给出了一个例子。在这里,我想添加一个额外的测试目录。对于 Java 和 Groovy 插件,只需定义一个源集名称就可以生成正确的任务。
在当前的构建中,我添加了一个名为integrationTest的源集:
sourceSets {
integrationTest
}
这会导致 Gradle 生成名为compileIntegrationTestJava、compileIntegrationTestGroovy、processIntegrationTestResources和integrationTest-Classes的任务。目录树现在包括 src/integrationTest/java、src/integrationTest/groovy 和 src/integrationTest/resources。
对于这个源集,我希望编译和运行时依赖与常规测试目录中的对应项相匹配:
dependencies {
// ... Various libraries ...
integrationTestCompile configurations.testCompile
integrationTestRuntime configurations.testRuntime
}
和之前一样,我会使用intTest任务,但现在我需要配置它以拥有正确的类路径和测试目录。以下是任务的最新版本:
task intTest(type: Test, dependsOn: jettyRun) {
testClassesDir = sourceSets.integrationTest.output.classesDir
classpath = sourceSets.integrationTest.runtimeClasspath
jettyStop.execute()
}
testClassesDir属性指向编译后的测试源。类路径设置为源集的运行时类路径,这仅仅是常规测试的运行时类路径。我现在可以将集成测试放入 src/integrationTest 目录树中,它们将在正确的时间执行。
在展示集成测试之前,还有一个问题需要解决。创建 HTTP GET 请求很简单:你将字符串 URL 转换为java.net.URL的一个实例,然后访问它的text属性,如前所述。然而,创建 POST、PUT 和 DELETE 请求并不那么简单。这些在第八章中有详细讨论,但到目前为止,我将使用第三方开源库。
HTTPBuilder 库(groovy.codehaus.org/modules/http-builder/)是 Apache HttpClient 库的 Groovy 包装器。它使用 Groovy 使其易于执行 HTTP 请求并处理响应。为了使用它,我在 Gradle 构建文件中添加了以下依赖项:
testCompile 'org.codehaus.groovy.modules.http-builder:http-builder:0.6'
在此添加之后,以下列表现在显示了各种集成测试。测试类包括使用 HTTPBuilder 客户端和不使用客户端的测试。
列表 10.11. ServletIntegrationTests.groovy:访问部署的 servlet

列表展示了三种不同类型的测试。第一种显示了一个没有任何库依赖的简单 GET 请求。第二种使用 HTTPBuilder^([5])库执行 GET 请求,最后一种使用 POST 请求完成同样的操作。详细的语法来自库文档。
⁵HTTPBuilder 包括一个名为
RESTClient的类,它在第九章(kindle_split_021.html#ch09)关于 REST 的讨论中被广泛使用。
在此基础设施到位的情况下,单元测试和集成测试都可以添加到标准项目树中,并且都可以使用 Gradle 构建中的插件通过嵌入的 Jetty 服务器执行。
Gradle 集成测试
使用 Gradle 的web和jetty插件与集成源树一起,可以在正常构建期间以“实时”模式测试 Web 应用程序。
| |
Geb 网络测试框架
Geb (www.gebish.org)(发音为“jeb”,带有一个轻柔的g)是一个基于 Spock 的 Groovy 测试工具,它允许使用以页面为中心的方法编写网络应用的测试。网站交互可以用页面对象来脚本化,而不是简单的屏幕抓取。它使用类似 jQuery 的语法以及 Groovy 语义来执行浏览器自动化,底层使用 WebDriver 库。
Geb 项目显示出很大的潜力,并且有越来越多的支持者。它当然值得考虑作为一个功能测试工具,以及像 Canoo WebTest (webtest.canoo.com)和 Selenium^([6]) (seleniumhq.org) JavaScript 库这样的替代品。仅关于这些工具就可以写一整章,但本书可能已经足够长了。
⁶顺便问一下,你知道为什么它叫 Selenium 吗?当它被开发时,有一个非常令人讨厌的产品叫做 Mercury Test Runner。碰巧的是,元素 Selenium(Se)是治疗水银(Hg)中毒的良药。
由于这是一个活跃的开发领域,我建议参考 Paul King 在 Slide Share 上的测试演示(例如,www.slideshare.net/paulk_asert/make-tests-groovy),他是Groovy in Action(Manning,2007)的合著者之一,也是一位杰出的开发者,作为有益的参考资料.^([7])
⁷我就在这里说:Paul King 说的每一句话都是对的。从这个假设开始,你就会做得很好。
Groovy 有其他支持服务器端配置的类,例如ServletBinding,它扩展了常规脚本Binding类。
经验教训(测试)
-
Spring 提供了一个用于单元测试 Web 应用的模拟对象库。这个库同样被集成到了 Grails 中。
-
Gradle 的 Web 和 Jetty 插件使得构建和部署 Web 应用程序变得容易。经过一些工作,Gradle 可以进行自动集成测试。
较大的应用程序需要更多的结构以便易于维护。Java 世界充满了 Web 框架,从 Struts(1 和 2 版本)到 Tapestry,再到 Wicket,JSF,Spring MVC 以及更多。在 Groovy 世界中,有一个特定的框架占主导地位,以至于吸引开发者学习 Groovy 只是为了使用这个框架。这就是杀手级应用的定义:一个如此酷的应用程序,以至于人们会为了使用它而学习一门新的语言。正如大多数读者所熟知,这个框架被称为 Grails。
这是一本关于如何结合使用 Java 和 Groovy 的书,因此我不会提供一个标准的 Grails 入门教程。关于这一点,有大量的参考资料可用.^([8]) 相反,我将展示一个简单但可能不平凡的示例应用程序,讨论在创建 Grails 时做出的某些架构选择,并展示如何将现有的 Java 类集成到 Grails 应用程序中。
⁸ 特别推荐 Peter Ledbrook 和 Glen Smith 所著的《Grails in Action》(Manning,2009)。
10.4. Grails:Groovy 的“杀手级应用”
很难过分强调 Ruby on Rails(RoR)彗星在 2005 年横扫 Java 天空时对 Java 世界产生的影响。当时的 Java Web 开发由一系列由各种技术组成的层组成,每种技术都有自己的配置文件和依赖关系。仅仅开始一个新的 Web 应用程序就是一个挑战。
Ruby on Rails,由于其强调 DRY(不要重复自己)原则和约定优于配置,展示了生活可以多么简单。虽然许多 Java Web 开发者接受了 RoR 方法,但并非每个人都处于可以简单地放弃 Java 世界的情况。关键问题是,我们如何将 RoR 世界中的快速开发原则带入 Java 企业开发?
我将在稍后解决这个问题,但首先我想从 30,000 英尺的高度讨论每一个创建过的 Web 应用程序,^([9]) 如 图 10.5 所示的标准架构。
⁹ 对于大多数非 Web 应用程序也是如此。这些层相当普遍。
图 10.5. 所有 Java Web 应用程序的分层设计。表示层类,包括控制器,通过事务服务访问持久数据。

标准模型中的用户界面是一个浏览器,也称为轻量级客户端,与重量级(桌面)Java 客户端相对。浏览器向用户展示视图,用户偶尔会将信息提交回服务器端。信息通过控制器传递,控制器是一类决定去哪里、调用什么业务逻辑以及请求处理完毕后使用什么视图的类。
Java Web 应用开发的一个关键原则是保持控制器瘦,这意味着它们实际处理的数量最小。相反,业务逻辑被委托给服务类。服务类还需要作为事务边界,因为数据访问是通过数据访问层中的一系列类来处理的。数据访问类遵循数据访问对象(DAO)设计模式,它封装了^([10])数据源,并将实体转换为数据库表,反之亦然。
^(10) 封装。唉。我们难道不能只说“包裹”吗?为什么每个来自面向对象编程(OOP)的术语都必须如此复杂?为什么我们不能只是“制作”或“创建”某物,而不是“实例化”它?而且我完全理解“多种形式”的概念,但谁会认为“多态”这个术语正是我们所需要的?谁会那样说(毕竟,这么多年了,除了我之外)?
当我谈到这个话题时,我需要向你展示一个在 Web 应用领域中不可避免的一个图示。那就是标准的模型-视图-控制器(MVC)架构,如图 10.6 所示。figure 10.6。
图 10.6。模型-视图-控制器(MVC)架构,自 Smalltalk 时代以来变化不大。视图显示模型对象,这些对象由控制器创建和配置。

MVC 背后的基本思想是关注点的分离。视图显示模型对象,从用户那里收集数据,并将其提交给控制器。控制器创建和配置模型对象,并将它们转发到视图。虽然控制器和视图紧密耦合,但模型对象并不与它们绑定。如果系统中的任何内容是可重用的,那就是模型类。顺便说一下,从这种架构中明显缺失的是前一个图中的服务,但这种方法本身就是一种过度简化,所以我选择不去担心它。
Grails 是前面描述的基于 MVC 的分层架构的典型代表,它有一些有趣的变体,将在本节中讨论。Grails 有几个显著的特点:
-
Grails 是建立在现有成熟技术之上的。Grails 在 Spring 和 Hibernate 之上结合了一系列 Groovy 领域特定语言(DSLs)。
-
Grails 是一个完整的堆栈框架(与 RoR 非常相似),它将视图层到持久化层以及中间的所有开源解决方案结合起来。
-
Grails 具有一种交互式脚本能力,这使得快速原型化应用变得容易。
-
Grails 的设计基于一个插件系统,这使得它非常容易扩展.^(11))
^(11) 至少到目前为止,有超过 800 个插件可供 Grails 使用(质量参差不齐)。
-
Grails 应用程序部署在现有的基于 Java 的基础设施上。
Grails 依赖于 Spring 框架的内部基础设施,因此 Spring 能做的任何事情,Grails 都能直接或通过插件做到。持久性通过 Hibernate 对象关系映射层管理,它足够强大,但也可以用现代的 NoSQL 数据库家族来替换。
为了展示 Grails 如何融入标准架构,我将遍历一个示例组件。
10.4.1. 寻找圣杯的旅程
Grails 可以用来设计任意复杂的 Web 应用程序,但其中一个亮点是提供一个数据库表集的 Web 界面。在展示组件之后,我会回到这一点,因为它既是祝福也是诅咒。
Grails
本节的目标是演示一个小但非平凡的 Grails 应用程序的一部分。第八章更详细地探讨了 GORM。第九章简要介绍了 Grails 中的 REST。最后,第七章介绍了 Spring,讨论了底层基础设施。
考虑一个包含四个域类的 Web 应用程序:Quest、Task、Knight和Castle。
域类
在 Grails 中,域类的实例映射到数据库表行。
Grails 对约定优于配置的方法意味着有一个特定的目录用于所有内容,如图 10.7 所示。图 10.7。域类有自己的目录,控制器、服务、视图也是如此。这使得理解一个你没有编写的 Grails 应用程序变得容易,因为它们都将元素存储在相同的位置。
图 10.7. 所有 Grails 应用程序的标准布局。遵循约定优于配置的规则使得查找各种组件变得容易,从控制器到服务,再到域类和视图。

Grails 域类通常用 Groovy 编写,尽管这不是必需的。在下一列表中所示的应用程序中,一个任务有一个名称,并与许多任务和骑士相关联。
列表 10.12. Quest域类
class Quest {
String name
String toString() { name }
static *hasMany* = [knights:Knight, tasks:Task]
static *constraints* = {
name blank:false
}
}
Quest有一个name属性和一个覆盖的toString方法来返回它。关键字hasMany是 GORM 的一部分,即 Grails 对象关系映射 DSL,它以编程方式配置 Hibernate。其他 ORM 工具也是可用的,但 Hibernate 是默认的。hasMany关键字暗示了Knight和Task表与Quest表之间的外键关系。
域类也有约束,Grails 在创建新实例时强制执行这些约束。对于Quest,name字段不能为空。
下一个列表显示了Task类。Task具有名称、优先级、开始和结束日期以及完成标记。
列表 10.13。Task属于一个Quest
class Task {
String name
int priority = 3
Date startDate = new Date()
Date endDate = new Date()
boolean completed
String toString() { name }
static belongsTo = [quest:Quest]
static constraints = {
name blank:false
priority range:1..5
startDate()
endDate validator: { value, task ->
value >= task.startDate
}
completed()
}
}
约束闭合状态表明Task必须有一个名称,一个介于 1 和 5 之间的优先级,以及一个大于或等于开始日期的结束日期。这个类中另一个值得注意的部分是belongsTo关键字,它暗示了任务和任务之间的级联删除关系。如果一个Quest被删除,所有相关的Task也会从数据库中移除。
Knight与Quest和Castle都有关联,但不是通过级联删除。实际上,一个Knight可以在Quest之间,但不属于一个Castle,所以这两个引用在下一个列表中都被列为nullable。
列表 10.14。与Quest和Castle关联的Knight类
class Knight {
String title = 'Sir'
String name
Quest quest
Castle castle
String toString() { "$title $name" }
static constraints = {
title inList: ['Sir','King','Lord','Squire']
name blank: false
quest nullable: true
castle nullable: true
}
}
最后一个域类是Castle,它有一个名称、一个城市、一个州以及一个计算出的经纬度对,如下所示。
列表 10.15。存储位置信息的Castle
class Castle {
String name
String city
String state
double latitude
double longitude
String toString() { "$name Castle" }
static hasMany = [knights:Knight]
static constraints = {
name blank: false
city blank: false
state blank: false
latitude min: -90d, max: 90d
longitude()
}
}
Castle中的hasMany变量表示Knight表将有一个外键指向Castle表。
在一个简单的 Grails 演示中,所有相关的控制器都会被 scaffold。在 Grails 中,这意味着它们有一个名为scaffold的单个属性,如下所示:
class QuestController {
static scaffold = Quest
}
scaffold术语告诉 Grails 动态(即在运行时)生成用于list、show、edit、update和delete任务的视图。每个动作的代码在运行时生成,所以这里没有显示。然而,最终我需要自定义控制器和视图,所以我需要生成静态版本。
下一个列表显示了Castle控制器的一部分。
列表 10.16。静态Castle控制器类
class CastleController {
...
def list(Integer max) {
params.max = Math.min(max ?: 10, 100)
[castleInstanceList: Castle.list(params),
castleInstanceTotal: Castle.count()]
}
...}
list动作检查params映射是否已经包含一个名为max的键。如果是,则将其转换为整数并重置为提供的值和 100 之间的最小值。如果参数不存在,则使用 10 作为max。从 Grails 2.0 开始,请求参数可以用作控制器动作的参数,并且类型转换将自动进行。
控制器
Grails 控制器包含称为actions的方法,这些方法映射到 URL。它们要么转发到其他资源,直接渲染输出,要么重定向到其他 URL。
然而,对于架构讨论来说,更重要的是作为动作返回值的映射。该映射包含两个键,castleInstanceList和castleInstanceTotal。前者与 10 座城堡(或max参数评估的任何值)的列表相关联,后者给出它们的总数。这很好,但真正有趣的是这些值的计算方式。Grails 在域类上添加了list方法和count方法作为静态方法。
没有 Dao 类
与数据访问对象不同,Grails 使用 Groovy 元编程向领域类添加静态方法。这遵循了 Active Record^([12]) 方法,这在 Java 框架中不常见,但在 Ruby 中非常流行。
(12) 来自马丁·福勒的《企业应用架构模式》(Addison-Wesley Professional,2002 年)。有关简要总结,请参阅
en.wikipedia.org/wiki/Active_record。
根据标准架构,控制器应该通过服务层访问 DAO 类。在静态脚手架中没有服务层。如果应用程序确实只是稍微多于一个由 Web 驱动的数据库,那么这没问题,但在一般情况下,应用程序需要更多。
服务
在 Grails 中,业务逻辑应该放在服务中,这些服务是事务性的、由 Spring 管理的豆,可以自动注入到其他组件中。
此应用程序确实包含一个服务。它是来自 Groovy Baseball 应用程序的 Geocoder。在下一个列表中,它操作 Castles。
列表 10.17. Geocoder,这次它操作 Castles
class GeocoderService {
String base = 'http://maps.googleapis.com/maps/api/geocode/xml?'
def fillInLatLng(Castle castle) {
String encodedAddress =
[castle.city, castle.state].collect {
URLEncoder.encode(it, 'UTF-8')
}.join(',+')
String qs =
[address: encodedAddress, sensor: false].collect { k,v ->
"$k=$v"
}.join('&')
def root = new XmlSlurper().parse("$base$qs")
castle.latitude = root.result[0].geometry.location.lat.toDouble()
castle.longitude = root.result[0].geometry.location.lng.toDouble()
}
}
这样多的代码当然必须经过测试.^([13]) Grails 从一开始就具有测试能力,最初基于 JUnit 子类。从版本 2.0 开始,Grails 测试用例使用注解(特别是,@TestFor 注解)来控制所谓的 mixin 类。将 @TestFor 注解应用于控制器或服务测试会自动实例化测试并将其分配给一个属性。
(13) 在纯测试驱动开发(TDD)中,首先编写测试。然后观察它失败,实现服务,并观察测试最终通过。
例如,下一个列表显示了 GeocoderService 类的测试。
列表 10.18. GeocoderService 的单元测试
![284fig01_alt.jpg]
测试用例
Grails 为它生成的每个组件(领域类、控制器或服务)生成一个单元测试类。默认实现故意失败,以鼓励您正确实现它们。
在 Grails 应用程序中,服务使用 Spring 的依赖注入。在这里,Geocoder 服务被注入到 CastleController 中,以便在数据库中保存实例之前更新纬度和经度。通过声明一个与服务同名的属性(首字母小写)来注入服务.^([14]) 例如,以下代码是 CastleController 实现的另一个部分。
(14) 在 Spring 术语中,这被称为“按名称自动装配”。
列表 10.19. 将服务注入到控制器中
![284fig02_alt.jpg]
服务通过名称注入到控制器中(在 Spring 中术语是 autowiring),因此使用与服务相同名称的变量并使用小写字母开头告诉 Grails 在该点提供一个服务的实例。该服务在 save 方法中使用,以在保存之前更新 Castle。
Grails 服务
使用标准的分层架构的 Grails 应用程序。让控制器委托给服务,并让事务性服务与数据库协同工作。
如本节前面所述,Grails 有一个丰富的可用插件集。在这个应用程序中,有用的一个插件是 Google Visualization 插件,它提供了一个用于生成 Google Maps 应用 JavaScript 的自定义 GSP 标签库。
与其他一切一样,Grails 以标准方式管理插件安装。grails-app/conf 文件夹中的 BuildConfig.groovy 文件有一个关于插件的章节。向该文件添加适当的语句会导致 Grails 在下一次应用程序启动时自动下载并安装插件。
这里是 BuildConfig.groovy 文件的相关部分:
plugins {
runtime ":hibernate:$grailsVersion"
runtime ":jquery:1.8.3"
runtime ":resources:1.1.6"
compile ":google-visualization:0.6.2"
build ":tomcat:$grailsVersion"
}
Google Visualization 插件的文档说明,为了使用它,需要在希望地图出现的 GSP 的 <head> 部分添加 <gvisualization:apiImport /> 标签。然后插件提供了一个 <gvisualization:map /> 标签来生成 Google 地图应用的 JavaScript。map 标签使用 columns 和 data 属性来指定地图点的信息,这是我需要指定的。
Quest 应用程序提供了一个很好的过程演示。假设我想让地图出现在与城堡关联的 list.gsp 页面上。Grails 将 URL <host>:<port>/holygrails/castle/list 映射到 CastleController 类中的 list 动作。该动作中的最后一个表达式是一个映射(Groovy 的而不是 Google 的),因此 Grails 自动将条目添加到 HTTP 请求中,并将其转发到 list.gsp 页面。
因此,目标是向适当的控制器动作添加地图所需的信息。像往常一样,数据应来自服务,我已经有 GeocoderService 可用。下一个列表显示了添加的额外方法。
列表 10.20. 添加到 GeocoderService 的方法以支持映射插件

CastleController 中的 list 动作已经返回了一个城堡列表和总数,这些用于在表格中显示它们。我也可以使用相同的动作来返回地图的列和数据。
CastleController 中修订的 list 动作如下所示:
def list() {
params.max = Math.min(params.max ? params.int('max') : 10, 100)
[castleInstanceList: Castle.list(params),
castleInstanceTotal: Castle.count(),
mapColumns:geocoderService.columns, mapData:geocoderService.markers]
}
下面的列表显示了为显示城堡地图而添加到视图 list.gsp 的修改。
列表 10.21. 对 list.gsp 的修改以显示城堡的 Google 地图

结果如图 10.8 所示,它显示了 Google 地图上的城堡。插件生成了 Google 地图 API 所需的 JavaScript 代码。
图 10.8. 在 Google 地图上显示城堡。Castle 领域类通过 GeocoderService 设置了纬度和经度坐标。Google 可视化插件生成必要的 JavaScript 代码,将它们添加到 Google 地图上。

Grails 是一个功能强大、特性丰富的框架,拥有众多特性,而它所缺少的特性则通过插件提供。如果你花时间学习 Groovy,那么在构建网络应用程序时,它总是值得一看。
(Grails)学到的经验教训
-
Grails 是一个基于约定优于配置的框架,用于生成网络应用程序。
-
Grails 领域类由 Hibernate 管理,并映射到数据库表。
-
Grails 服务是 Spring 管理的豆,它们通过名称自动注入到其他组件中。它们默认使用 Spring 的交易管理。
-
Grails 架构的插件设计使得添加额外功能变得容易。
Grails 在底层使用 Spring 和 Hibernate,因此它将基于 Groovy 的领域特定语言混合在主要的 Java 库之上。
10.5. 摘要
本章探讨了 Groovy 如何帮助进行测试和构建网络应用程序。单元测试与它们的 Java 对应物类似,但 Gradle 构建框架提供了一个很好的方法来进行已部署应用程序的集成测试。
Groovy JDK 包含像 ServletCategory 这样的类,这些类简化了网络组件的实现。Groovy 还有一个内置的网络脚本引擎,称为 groovlets,这使得在 Web 应用程序中处理请求、响应、会话和输入参数变得容易。
最后,本章简要讨论了 Grails,这可能是所有 Java/Groovy 集成成功故事中最大的一个。
附录 A. 安装 Groovy
安装 Groovy 很简单。本附录将向您展示如何进行安装,并对涉及的各种选项进行回顾。
A.1. 安装 JDK
Groovy 生成由 Java 虚拟机解释的 Java 字节码。这意味着您必须安装 Java 才能安装 Groovy。您需要一个完整的 Java 开发工具包(JDK),而不是 Java 运行时环境(JRE)。您只需要 Java 的标准版(SE),而不是企业版^([1])。
¹ 顺便问一下,"business"这个词是什么时候被"enterprise"这个词取代的?是《星际迷航》的事情吗?成为企业架构师意味着您是设计星际飞船谋生的吗?在星际飞船上煮咖啡时使用企业 Java Bean 吗?
Java SE 的官方 JDK 可以从 Oracle 的mng.bz/83Ct获取。在撰写本文时,当前版本是 Java SE 7u25(Java 7,更新 25),但 Groovy 可以在 Java 1.5 及以上版本的任何 Java 版本上运行。
请确保设置一个名为JAVA_HOME的环境变量,以指向安装目录。您可能还希望将JAVA_HOME下的 bin 文件夹添加到您的路径中。
在 Windows 上看起来是这样的:
C:\> set JAVA_HOME="C:\Program Files\Java\jdk1.7.0"
C:\> set PATH=%JAVA_HOME%\bin;%PATH%
这些命令将在本地 shell 中设置JAVA_HOME和PATH属性。要将其设置到每个地方,右键单击我的电脑,选择属性,然后单击高级,接着单击环境变量。将它们作为系统变量添加,并启动一个新的 shell.^([2])
² 在不同的 Windows 版本上,安装过程的细节可能会有所不同,但概念是相同的。将变量作为系统环境变量设置,并启动一个新的 shell,因为 Windows 不会更新现有的一个。
在 Mac 或 Unix 版本上,设置是相同的
$ export JAVA_HOME=/Library/Java/...
$ export PATH=$PATH:$JAVA_HOME/bin
根据目录结构和版本号的不同,这些语句的变体太多,难以计数,但原则始终相同:安装 Java,设置JAVA_HOME变量以指向它,并将它的 bin 子目录添加到您的路径中。
A.2. 安装 Groovy
假设您已安装 Java,安装 Groovy 就变得简单了。同样,有几种选择,但基本过程归结为下载和解压发行版,设置GROOVY_HOME环境变量,并将它的 bin 子目录添加到您的路径中。
如果您不喜欢自动安装程序或您在机器上没有 root 权限,您可以直接下载 Groovy 的压缩二进制发行版。当前版本始终可以在groovy.codehaus.org/Download找到。您可以获取二进制发布版或源发布版(或两者都获取)。无论如何,将下载解压到您选择的目录中。
在 Windows 上,遵循与 Java 安装相同的模式,它是
C:\> set GROOVY_HOME=C:\Groovy\groovy-2.1.6
C:\> set PATH=%GROOVY_HOME%\bin;%PATH%
在 Mac 或 Unix 上,过程是相同的
$ export GROOVY_HOME=...
$ export PATH=$PATH:$GROOVY_HOME/bin
如果你不在乎安装程序,Windows 上有一个好的安装程序可用。下载页面上有可用的 EXE 安装程序,它将 Groovy 安装到你的选择目录中,为你设置 GROOVY_HOME 变量,并将它下面的 bin 文件夹添加到你的路径中。它还提供为你安装一些可选库,这些库很有用,并且不会以任何方式干扰你的常规安装。我已经在客户站点上使用 Windows 安装程序多年,从未遇到过问题。顺便说一句,如果未设置 JAVA_HOME 环境变量,它将通知你。
如果你使用的是 Mac 或其他 Unix 系统,你有其他方便的替代方案可用。首先,有一个 MacPorts (www.macports.org) 选项。运行
$ sudo port install groovy
这将下载并安装最新版本。如果你更喜欢 HomeBrew (mxcl.github.io/homebrew),相关的命令是
$ brew install groovy
这也会下载最新版本,安装它,并在你的路径中创建可执行脚本的软链接。
另一个主要的替代方案是使用 GVM,即 Groovy 环境管理器 (gvmtool.net)。如果你计划随时切换版本,这是最佳选择。GVM 使用 curl 安装,命令如下:
$ curl –s get.gvmtool.net | bash
GVM 假设你使用的是 bash shell,但同样的过程适用于大多数 Unix 系统。如果你安装了 Cygwin,它也可以在 Windows 上工作。详情请见网页。
GVM 的巨大优势是它使得切换版本几乎变得非常简单。如果你已经安装了 GVM,你可以通过输入来找出可用的 Groovy 版本
$ gvm list groovy
你可以像这样安装最新版本:
$ gvm install groovy
如果你向 install 命令提供一个版本号,你可以选择安装哪个版本的 Groovy。你可以使用以下方式从一个版本的 Groovy 切换到另一个版本
$ gvm use groovy [version]
如果你请求的版本尚未安装,GVM 会为你下载并安装它。在我的工作中,我并不经常切换 Groovy 版本,但我经常切换 Grails 版本,而这个工具对 Groovy、Grails、Griffon 以及一些其他软件发行版都适用。GVM 在你的主目录下的 .gvm 文件夹中安装软件,因此你应该将 GROOVY_HOME 变量设置为指向那里。例如,在我的 Mac 上,我有
$ export GROOVY_HOME=/Users/kousen/.gvm/groovy/current
这很有用,因为通过 GVM 切换版本会更新当前链接。不过,我无需明确将那个文件夹添加到我的路径中,因为该工具已经将软链接添加到了已经在我路径中的 bin 文件夹。
A.3. 测试你的安装
检查你的 Groovy 安装是否正常工作的最简单方法是尝试 Groovy shell 或 Groovy 控制台。如果你输入
$ groovysh
你应该得到如下响应:
Groovy Shell (2.1.5, JVM: 1.7.0_11)
Type 'help' or '\h' for help.
------------------------------------
groovy:000> println 'Hello, World!'
Hello, World!
===> null
groovy:000>
Groovy shell 实质上是 Groovy(甚至 Java)的 REPL^([3])。请注意,这里的响应是 null,因为 println 命令有一个 void 返回类型。
³ 读取-评估-打印-循环,在附录 B 中进一步讨论。Read-Eval-Print-Loop, discussed further in [appendix B]。
Groovy 控制台稍微有用一些。使用以下命令启动它:
$ groovyConsole
在 Windows 上,这会启动一个单独的进程。在 Mac 和 Unix 版本上,groovyConsole命令会锁定特定的 shell,因此你可能想要通过附加一个&符号在后台运行它。结果看起来像图 A.1。
图 A.1. Groovy 控制台,它是 Groovy 发行版的一部分。记得在视图菜单下选择运行时自动清除输出,这样可以使工具更加实用。

Groovy 控制台将其结果追加到输出窗口,这可能会引起问题。更糟糕的是,如果你输入了一行抛出异常的代码,^([4]) 结果窗口将停止滚动,即使你后来修复了错误。因此,我的建议是选择视图菜单下的最后一个条目,标题为运行时自动清除输出。这样,每次执行脚本时控制台都会清除输出。Groovy 控制台包括抽象语法树浏览器等工具。即使你通常使用 IDE,它也很有用。
⁴我知道你永远不会那样做,但你了解你的同事是什么样的。他们什么都能做到。
谈到 IDE,下一节将介绍它们当前的支持水平。
A.4. IDE 支持
如果你是一名 Eclipse 用户,Groovy Eclipse 插件是顶级的。要将它添加到现有的 Eclipse 发行版中,请使用在groovy.codehaus.org/Eclipse+Plugin页面找到的更新字符串。该插件也可以在 Eclipse 市场找到。
Eclipse 有一个令人烦恼的 bug,需要安装目录可由用户写入。Groovy Eclipse 不能安装到所谓的“共享”安装中,这通常包括 Windows 上的 c:\Program Files 目录。只需将你的 Eclipse 安装到其他位置,一切就会正常。
如果你只想使用 Groovy,Groovy Eclipse 插件就足够了。如果你想同时使用 Grails,那么你可以安装 Groovy 和 Grails 工具套件,GGTS。GGTS 是基于 Eclipse 的一系列插件,由 Pivotal(原名 SpringSource)管理。你可以从www.springsource.org/downloads/sts-ggts下载 GGTS。请注意:该网站首先列出 STS 下载,然后是 GGTS 下载。
STS 和 GGTS 都来自同一个代码库。区别在于初始的插件集。GGTS 包含了 Groovy Eclipse 插件和 Grails 支持,提供了整个 Grails 视角、各种向导、快捷键等。
主要的 IDE 替代品是 IntelliJ IDEA。在www.jetbrains.com/idea/features/groovy_grails.html的页面中讨论了其 Groovy 和 Grails 功能。它甚至支持 Griffon,这在目前来说相当不寻常。IntelliJ IDEA 是大多数核心 Groovy、Grails 和 Griffon 团队成员的首选工具,但它是一个商业产品,因此需要许可证。^([[5])如果你参与开源项目或在当地的 Java/Groovy/Grails 用户组中做演讲,你可以获得免费许可证,这是参与开源世界的另一个原因。
⁵曾经有一个社区版不支持 Grails,但这种情况可能正在改变。务必查看网站以了解当前的功能。
groovy.codehaus.org/IDE+Support网页列出了从 Emacs 到 TextMate 到 UltraEdit 等 IDE 的插件和支持。如果你找不到你感兴趣的一个,务必在邮件列表上提问。总有人会知道并告诉你去哪里找到你需要的东西。
A.5. 在 Groovy 生态系统中安装其他项目
GVM 工具目前可以安装和管理 Groovy、Grails、Griffon、Gradle 以及其他项目的分发版。^([[6])如果你使用的是 Mac 或 Unix 发行版,这是最简单的方法。同样,在 Mac 上,HomeBrew 和 MacPorts 都有相同项目的选项。在 Windows 上,Groovy 有本章前面提到的安装程序。
⁶当前的候选列表包括 Groovy、Grails、Griffon、Gradle、Lazybones、Vertx 和 Groovyserv。
Grails 始终是一个 ZIP 文件,你需要下载并解压它。然后你设置一个环境变量(在这种情况下为GRAILS_HOME)并将 bin 子目录添加到你的路径中。Griffon 和 Gradle 的工作方式几乎相同。
注意,所有这些项目都在 GitHub 上拥有自己的源代码仓库。你可以随时克隆分发版并自行构建,尽管这可能会有些复杂。有关详细信息,请参阅各自的项目页面。GitHub 的最好之处之一是你可以浏览源代码而无需下载任何内容。熟悉包含在各个项目中的测试用例是个好主意,因为它们是每个项目的可执行文档。网页可能会过时,但持续集成服务器会一直执行测试用例。当它们出现问题时,每个人都知道,并且会立即修复。
在这本书中广泛讨论的另一个项目是 Spock。Spock 是一个库而不是框架,通常作为 Gradle(或 Maven)构建的一部分安装。其源代码也在 GitHub 上。
附录 B. Groovy 按特性
有些人通过例子学习。有些人通过特性学习。在这本书中,我试图满足两者。如果你是一个只有粗略了解 Groovy 的 Java 开发者,希望这个附录或第二章,“Groovy 按例子”,能让你对 Groovy 语言有所了解。
这个附录概述了 Groovy 的大部分主要特性,并提供了简短的代码片段来展示它们。虽然这一章并不声称像《Groovy 实战》(Manning,2007;在本附录的其余部分称为 GinA)那样是一个详尽的参考,但它有几个特点使其优于更全面的处理方法:(1)它相对较短,并且(2)附录中用友好的大字写着“不要慌张!”(实际上就在这句话中).^([1]) 更严肃地说,在这个附录 I 中,我回顾了本书中使用的 Groovy 编程语言的主要特性。
¹ 对于那些出生太晚的人来说,这是一个对《银河系漫游指南》的引用。我可以说这一章“包含很多伪经,或者至少是非常不准确的内容”,但这可能对销售没有好处。
因为这不会是一个全面的处理,我选择基于两个标准来审查 Groovy 的方面:(1)它们在实践中使用得多频繁,以及(2)它们相对于 Java 中相应特性(假设 Java 中甚至存在相应的特性)提供了多少优势。在掌握 Groovy 的基本知识(如如何运行 Groovy 程序和基本数据类型,如数字和字符串)之后,我将继续讨论集合、I/O、XML 等问题。一些主题,如 SQL,在其他章节中有所涉及,但你会发现这里的基本内容。
B.1. 脚本和传统例子
假设你已经安装了 Groovy,^([2]) 我将从传统的“Hello, World!”程序开始,如下所示:
² 详细内容请见附录 A。
println 'Hello, Groovy!'
这就是整个程序。在 Java 中,你需要在类内部有一个main方法,并在main方法内部调用System.out.println来写入控制台。Java 开发者对此已经习惯了,但根据你如何计算,大约有 8 到 10 个面向对象的概念涉及其中.^([3]) 在 Groovy 中,整个程序只有一行。
³ 粗略统计包括类、方法、字符串、数组、公共访问、静态方法和属性、void 返回类型、重载方法如
println等。布鲁斯·艾克尔(Bruce Eckel)的《Java 编程思想》(Prentice-Hall,2002)之所以需要超过 100 页才能到达他的第一个“Hello, World”程序,这并非偶然。
为了演示,考虑 Groovy 伴随的两个执行环境之一,即 groovysh 命令,它启动 Groovy 壳。Groovy 壳是一个 REPL^[[4)],允许你逐行执行 Groovy 代码。以下列表中的所有行都会产生相同的结果。
⁴ 读取-评估-打印循环;有关详细信息,请参阅
en.wikipedia.org/wiki/REPL。
列表 B.1. 在 Groovy 壳中运行“Hello, World!”

在每种情况下,println 方法都会打印到控制台并返回 null。当没有歧义时,可以省略括号。分号的作用与 Java 中的相同,但它们是可选的。
这是一个 Groovy 脚本 的例子。脚本是一个不显式包含类定义的代码列表。在 Java 中,一切都必须在类内部。Groovy 能够同时处理脚本和类。
Groovy 脚本是一种 语法糖。^([[5)]] 一个类实际上也是涉及的。如果我编译这个脚本并对其运行 javap 命令,我会得到以下响应:
⁵ 语法糖是简化代码编写的语法,但不会在底层改变任何东西。过度使用语法糖可能会导致语法糖尿病。
> groovyc hello_world.groovy
> javap hello_world
Compiled from "hello_world.groovy"
public class hello_world extends groovy.lang.Script{
public static transient boolean __$stMC;
public static long __timeStamp;
public static long __timeStamp__239_neverHappen1309544582162;
public hello_world();
public hello_world(groovy.lang.Binding);
public static void main(java.lang.String[]);
public java.lang.Object run();
...
javap 命令大约有 30 行输出,主要涉及超类方法。有趣的部分是 groovy 命令生成一个名为 hello_world 的类,以及一对构造函数和一个 main 方法。该类在编译时生成,并扩展了来自 Groovy 库的 groovy.lang.Script 类。实际上,Groovy 中的脚本成为 Java 中的类,其中脚本中的代码最终(经过几层间接)由 main 方法执行。然而,我不想给人留下 Groovy 生成 Java 的印象。Groovy 代码直接编译成 JVM 的字节码。
编译后的 Groovy
Groovy 是编译的,而不是解释的。它不是一个代码生成器;编译器直接生成 Java 字节码。
由于字节码在 JVM 上运行,只要你在类路径中包含必要的 JAR 文件,就可以使用 java 命令执行 Groovy 脚本:
> java –cp .;%GROOVY_HOME%\embeddable\groovy-all-2.1.5.jar hello_world
Hello, World!
执行 Groovy
在运行时,Groovy 只是一个 JAR 文件。只要 groovy-all JAR 文件在类路径中,Java 就可以完美地执行编译后的 Groovy 代码。
groovy 命令用于执行 Groovy 程序。它可以与编译后的代码(类似于 java 命令)或 Groovy 源代码一起使用。如果你使用源代码,groovy 命令首先编译代码然后执行它。
B.2. 变量、数字和字符串
Groovy 是一种可选类型的语言。Groovy 使用类来定义数据类型,就像 Java 一样,但 Groovy 变量可以是静态类型或使用 def 关键字。
例如,我完全可以自由地声明类型为 int、String 或 Employee 的变量,使用标准的 Java 语法:
int x
String name
Employee fred
如果我不知道变量的类型,或者我不在乎,Groovy 提供了关键字 def:
def arg
类型化变量与无类型变量
当你应该使用 def 而不是实际类型时?没有严格的答案,但最近我关于这个问题与 Dierk Koenig(GinA 的主要作者)、Andres Almiray(Griffon in Action 的主要作者和 Griffon 项目负责人)以及 Dave Klein(Grails: A Quick-Start Guide 的主要作者)进行了一场(非常温和的)Twitter 讨论。Dierk 在这个话题上给出了我听过的最好的建议。他说:“如果我想到了一个类型,我就输入它(字面意思)。”
我自己的经验是,随着我对 Groovy 的经验越来越丰富,我越来越少使用 def。我同意 Dierk 的建议,并额外建议现在当我声明一个类型时,我会停下来片刻,看看是否有什么实际类型浮现在我的脑海中。如果有,我就使用它。
在某些情况下,def 更受欢迎,尤其是在使用测试中的模拟对象时。这个主题在第六章 中讨论。
接下来,让我们来看看数据类型本身。Java 在原始类型和类之间做出了区分。在 Groovy 中没有原始类型。Groovy 中的数字是一等对象,有自己的方法集。
B.2.1. 数字
因为在 Groovy 中数字是对象,我可以确定它们的数据类型。对于整型字面量,数据类型取决于值,如下面的脚本所示:
x = 1
assert x.class == java.lang.Integer
x = 10000000000000000
assert x.class == java.lang.Long
x = 100000000000000000000000
assert x.class == java.math.BigInteger
关于这个脚本有几个要点需要注意。首先,变量 x 完全没有声明。这只有在脚本中才是合法的,其中变量成为脚本绑定的一部分,可以从外部设置和访问。关于这个过程的详细信息请参阅 第三章 关于与 Java 的集成。这里只需说,这在脚本中是合法的,但在类中不是。如果你觉得更舒服,你可以在 x 前面自由地添加单词 def。
脚本变量
如果脚本中的一个变量没有被声明,它就成为了脚本绑定的一个部分。
如前所述,脚本缺少分号。在 Groovy 中,分号作为语句分隔符是可选的,如果没有歧义,可以省略。再次强调,你可以自由地添加它们而不会出现问题。
分号的使用
在 Groovy 中,分号是有效的,但不是必需的。
接下来,Groovy 广泛使用名为 assert 的方法。单词 assert 可以不带括号书写,就像这里一样,或者你可以用它们包围一个表达式。结果表达式必须评估为布尔值,但这比 Java 中的要求宽松得多。在 Java 中,唯一可用的布尔值是 true 和 false。在 Groovy 中,非空引用是 true,非零数字、非空集合、非空字符串以及布尔值 true 都是 true。
这需要重复强调,被称为 Groovy 真理。
Groovy 真理
在 Groovy 中,非空引用、非空集合、非空字符串、非零数字以及布尔值 true 都是真实的。
最后,Java 中浮点值的默认数据类型是 double,但在 Groovy 中是 java.math.BigDecimal。Java 中的 double 类型大约有 17 位十进制精度,但如果你想对其准确性感到沮丧,可以尝试这个微小的示例:
println 2.0d – 1.1d
在字面量后附加的 d 使其成为双精度浮点数。你可能会期望这里的答案是 0.9,但实际上它是 0.8999999999999999。这并不是很大的差异,但我只做了一次减法,就已经出现了偏差。这并不好。这就是为什么任何严肃的数值计算在 Java 中都需要 java.math.BigDecimal,但这意味着你不能再使用标准运算符(+、-、*、/)了,而必须使用方法调用。
Groovy 无需处理这个问题。以下是对应的 Groovy 脚本:
println 2.0 – 1.1
在这种情况下,答案是 0.9,正如预期的那样。因为计算使用了 BigDecimal,所以答案是正确的。Groovy 也支持运算符重载,因此加法运算符可以与 BigDecimal 值一起使用。总结如下:
字面量
没有小数点的数字类型为 Integer、Long 或 java.math.BigInteger,具体取决于大小。有小数点的数字类型为 java.math.BigDecimal。
因为数字是对象,所以它们也有方法。B.2 列表 展示了一个脚本,它对一些数字进行了测试。其中一些表达式使用了闭包,这是 B.4 节 的主题。最简单的定义是,将它们视为一个代码块,就像它是匿名方法调用一样执行。
列表 B.2. numbers.groovy,显示对数字字面量的方法调用
assert 2**3 == 8
assert 2**-2 == 0.25 // i.e., 1/(2*2) = 1/4
def x = ""
3.times { x += "Hello" }
assert x == "HelloHelloHello"
def total = 0
1.upto(3) { total += it }
assert total == 1 + 2 + 3
def countDown = []
5.downto 1, { countDown << "$it ..." }
assert countDown == ['5 ...', '4 ...', '3 ...', '2 ...', '1 ...']
Groovy 有一个指数运算符,与 Java 不同。数字有 times、upto 和 downto 等方法。times 操作接受一个类型为 Closure 的单个参数。当方法参数的最后一个参数是闭包时,可以将其放在括号之后。因为方法没有其他参数,所以可以完全省略括号。
闭包参数
如果方法参数的最后一个参数是闭包,则可以将其放在括号之后。
upto 和 downto 方法接受两个参数,因此前者显示了括号,后者使用逗号来表示数字和闭包都是方法的参数。countDown 变量是一个列表,将在 B.3 节 中讨论。左移运算符被重载以向集合中添加元素,这里的参数是一个参数化字符串。Groovy 有两种字符串类型,将在下一节讨论。
B.2.2. 字符串和 Groovy 字符串
在 Java 中,单引号用于界定字符(原始类型)而双引号包围java.lang.String的实例。在 Groovy 中,单引号和双引号都用于字符串,但存在区别。双引号字符串用于参数替换。它们不是java.lang.String的实例,而是groovy.lang.GString的实例。
这里有一些示例,展示了它们的用法:
def s = 'this is a string'
assert s.class == java.lang.String
def gs = "this might be a GString"
assert gs.class == java.lang.String
assert !(gs instanceof GString)
gs = "If I put in a placeholder, this really is a GString: ${1+1}"
assert gs instanceof GString
单引号字符串始终是java.lang.String的实例。双引号字符串可能是 Groovy 字符串,也可能不是,这取决于是否进行了参数替换。
Groovy 还有多行字符串,可以是单引号或双引号。区别再次在于是否进行了参数替换:
def picard = '''
(to the tune of Let It Snow)
Oh the vacuum outside is endless
Unforgiving, cold, and friendless
But still we must boldly go
Make it so, make it so, make it so!
'''
def quote = """
There are ${Integer.*toBinaryString*(2)} kinds of people in the world:
Those who know binary, and those who don't
"""
assert quote == '''
There are 10 kinds of people in the world:
Those who know binary, and those who don't
'''
最后还有一种字符串类型,用于正则表达式。Java 从 1.4 版本开始就具备正则表达式功能,但大多数开发者要么不知道,要么避免使用它们.^([6]) Java 中正则表达式的一个特别令人烦恼的部分是反斜杠字符\用作转义字符,但如果你想在正则表达式中使用它,你必须对反斜杠进行转义。这导致了一些令人烦恼的表达式,其中你必须对反斜杠进行双重转义,使得结果表达式几乎无法阅读。
⁶ Perl 程序员热爱正则表达式。Ruby 开发者也喜欢它们,但态度比较理性。Java 开发者一看到
java.util.regex.Pattern类的 JavaDocs,就会感到恐惧。
Groovy 提供了所谓的斜杠语法。如果你用斜杠包围一个表达式,它就被假定为正则表达式,你不再需要双重转义。
字符串
Groovy 使用单引号表示普通字符串,双引号表示参数化字符串,斜杠用于正则表达式。
这里有一个示例,用于检查字符串是否是回文:也就是说,如果它们正向和反向相同。要检查回文,你首先需要移除任何标点符号,并在反转字符串之前忽略大小写:
def palindromes = '''
Able was I ere I saw Elba
Madam, in Eden, I'm Adam
Sex at noon taxes
Flee to me, remote elf!
Doc, note: I dissent. A fast never prevents a fatness. I diet on cod.
'''
palindromes.*eachLine* {
String str = it.trim().replaceAll(*/\W/*,'').toLowerCase()
assert str.*reverse*() == str
}
再次证明,一点 Groovy 代码就能包含很多功能。String类中添加了eachLine方法,用于在换行处分割多行字符串。它接受一个闭包作为参数。在这种情况下,闭包中没有使用虚拟变量,因此每个字符串都分配给默认变量it。
it 变量
在闭包中,如果没有指定虚拟名称,则默认使用术语it。
trim方法应用于行以移除任何前导和尾随空格。然后使用replaceAll方法将所有非单词字符替换为空字符串。最后,将字符串转换为小写。
assert 测试使用了 Groovy 添加到String的另一个方法,称为reverse。Java 在StringBuffer中有reverse方法,但不是在String中。Groovy 为了方便,将reverse方法添加到String。
Groovy 向 Java 标准库添加了许多方法。这些方法统称为Groovy JDK,并且是 Groovy 的最佳特性之一。Groovy 文档包括 Groovy 标准库和 Groovy JDK 的 GroovyDocs。
Groovy JDK
通过其元编程能力,Groovy 向标准 Java 库添加了许多便利方法。这些额外的方法被称为 Groovy JDK。
总结来说,Groovy 使用数字和对象,并且具有常规和参数化的字符串以及额外的方法。Groovy 在简化 Java 的另一个领域是集合。
B.3. Plain Old Groovy Objects
具有属性获取器和设置器的 Java 类通常被称为 POJOs,或 Plain Old Java Objects。在 Groovy 中,相同的类被称为 Plain Old Groovy Objects,或 POGOs.^([7]) 本节讨论了 POGOs 的额外特性。
⁷ Python 偶尔使用 POPOs 这个术语,听起来有点令人作呕。如果你真的想惹恼一个 Ruby 开发者,可以提到 POROs。Ruby 开发者讨厌任何听起来像 Java 的东西。
考虑以下 Groovy 中的Person类:
class Person {
String firstName
String lastName
String toString() { "$firstName $lastName" }
}
POGOs 不需要访问修饰符,因为在 Groovy 中属性默认是私有的,方法默认是公共的。类也是默认公共的。任何没有访问修饰符的属性都会自动获得公共的获取器和设置器方法。如果你想添加public或private,你可以这样做,并且对属性的任何指定都会阻止生成相关的获取器和设置器。
Groovy 属性
在 Groovy 中,属性访问是通过动态生成的获取器和设置器方法完成的。
这里是一个使用Person类的脚本示例:
Person mrIncredible = new Person()
mrIncredible.firstName = 'Robert'
mrIncredible.setLastName('Parr')
assert 'Robert Parr' ==
"${mrIncredible.firstName} ${mrIncredible.getLastName()}"
Person elastigirl = new Person(firstName: 'Helen', lastName: 'Parr')
assert 'Helen Parr' == elastigirl.toString()
脚本显示,你还可以获得一个默认的、基于映射的构造函数,之所以称为这样,是因为它使用了 Groovy 映射中使用的相同的property:value语法。
这个习惯用法在 Groovy 中非常常见,以至于标准库中的任何地方的获取器和设置器方法通常都使用属性表示法来访问。例如,Calendar.instance用于在Calendar类上调用getInstance方法。
现在转向实例集合,我将从范围开始,然后转向列表,最后查看映射。
B.4. 集合
自从 J2SE 1.2 以来,Java 标准库已经包括了集合框架。该框架定义了列表、集合和映射的接口,并为每个接口提供了一组小型但实用的实现类,以及java.util.Collections类中的一组多态实用方法。
Groovy 可以使用所有这些集合,但还添加了很多:
-
列表和映射的原生语法
-
一个
Range类 -
许多额外的便利方法
我将在本节中展示每个示例。
B.4.1. 范围
范围是 Groovy 中的集合,由一对点分隔的两个值组成。范围通常用作其他表达式的部分,如循环,但也可以单独使用。
groovy.lang.Range 类提供了访问范围边界的方法,以及检查它是否包含特定元素的功能。以下是一个简单的示例:
Range bothEnds = 5..8
assert bothEnds.contains(5)
assert bothEnds.contains(8)
assert bothEnds.from == 5
assert bothEnds.to == 8
assert bothEnds == [5, 6, 7, 8]
使用两个点包括边界。要排除上限,请使用小于号:
Range noUpper = 5..<8
assert noUpper.contains(5)
assert !noUpper.contains(8)
assert noUpper.from == 5
assert noUpper.to == 7
assert noUpper == [5, 6, 7]
数字范围会遍历包含的整数。其他库类也可以用于范围。字符串按字母顺序遍历:
assert 1..5 == [1,2,3,4,5]
assert 'A'..'E' == ["A","B","C","D","E"]
日期遍历包含的日期,如下一列表所示。
列表 B.3. 使用 Java 的 Calendar 类在范围中使用日期

尽管 Groovy 拥有众多优点,但它也无法驯服 Java 中略显笨拙的 java.util.Date 和 java.util.Calendar 类,但它可以使使用它们的代码变得更加简单。Calendar 是一个具有工厂方法 getInstance 的抽象类,因此我在 Groovy 中通过访问 instance 属性来调用它。Groovy JDK 为 Date 添加了 format 方法,因此不需要单独实例化 SimpleDateFormat。
在列表中,在设置年份、月份和日期之后,通过调用 getTime 获取 Date 实例。^([8]) 在这种情况下,这相当于访问 time 属性。日期被 each 方法用作范围的边界,该方法将每个日期追加到列表中。
⁸ 是的,您没有看错。您通过调用 ...
getTime来获取 日期。嘿,这不是我写的。
实际上,任何包含三个特征的类都可以被转换为范围:
-
一个
next()方法,用于正向迭代 -
一个
previous()方法,用于反向迭代 -
实现
java.util.Comparable接口,用于排序
这里,范围用作循环的基础,日期被追加到列表中。
B.4.2. 列表
Groovy 中的列表与 Java 中的列表相同,只是语法更简单,并且有一些额外的方法可用。在 Groovy 中创建列表,请将值放在方括号之间:
def teams = ['Red Sox', 'Yankees']
assert teams.class == java.util.ArrayList
默认列表类型为 java.util.ArrayList。如果您想使用 LinkedList,请按常规方式实例化它。
Groovy 具有操作符重载。Groovy JDK 显示,加号、减号和左移操作符已被定义为与列表一起工作:
teams << 'Orioles'
assert teams == ['Red Sox', 'Yankees', 'Orioles']
teams << ['Rays', 'Blue Jays']
assert teams ==
['Red Sox', 'Yankees', 'Orioles', ['Rays', 'Blue Jays']]
assert teams.flatten() ==
['Red Sox', 'Yankees', 'Orioles', 'Rays', 'Blue Jays']
assert teams + 'Angels' - 'Orioles' ==
['Red Sox', 'Yankees', ['Rays', 'Blue Jays'], 'Angels']
可以使用类似数组的语法访问列表的元素。同样,这是通过重写方法来实现的——在这种情况下,是 getAt 方法:
assert teams[0] == 'Red Sox'
assert teams[1] == 'Yankees'
assert teams[-1] == ['Rays','Blue Jays']
如 图 B.1 所示,从左侧访问元素从索引 0 开始。从右侧访问从索引 -1 开始。您也可以使用方括号中的范围:
def cities = ['New York', 'Boston', 'Cleveland','Seattle']
assert ['Boston', 'Cleveland'] == cities[1..2]
图 B.1. 使用索引从两端访问任何线性集合。第一个元素在索引 0。最后一个元素在索引 -1。您还可以使用子范围,例如 mylist[-4..-2]。

类似数组的访问
线性集合支持从两端通过索引访问元素,甚至可以使用范围。
Groovy 为集合添加了 pop、intersect 和 reverse 等方法。详细信息请参阅 GroovyDocs。
有两种方法可以将函数应用于每个元素。扩展点操作符 (.*) 使得访问属性或对每个元素应用方法变得容易:
assert cities*.size() == [8, 6, 9, 7]
collect 方法接受一个闭包作为参数,并将其应用于集合的每个元素,返回一个包含结果的列表。这与扩展点操作符类似,但可以进行更通用的操作:
def abbrev = cities.*collect* { city -> city[0..2].toLowerCase() }
assert abbrev == ['new', 'bos', 'cle', 'sea']
在箭头之前使用的单词 city 作为一个方法调用的占位符。闭包提取列表中每个元素的前三个字母,并将它们转换为小写。
集合的一个特别有趣的特点是它们支持使用 as 操作符进行类型强制转换。这意味着什么?将 Java 列表转换为集合并不困难,因为有一个构造函数用于此目的。然而,将列表转换为数组则需要一些笨拙、反直觉的代码。以下是 Groovy 对此过程的看法:
def names = teams as String[]
assert names.class == String[]
def set = teams as Set
assert set.class == java.util.HashSet
这很简单.^([9]) Groovy 中的集合就像 Java 中的集合一样,这意味着它不包含重复项,也不保证顺序。
⁹ 我知道我经常这么说,但用 Groovy 我也经常这么想。
as 操作符
Groovy 使用关键字 as 用于许多目的。其中之一是类型强制转换,它将一个类的实例转换为另一个类的实例。
Groovy 集合最令人愉悦的特性之一是它们是可搜索的。Groovy 为集合添加了 find 和 findAll 方法。find 方法接受一个闭包,并返回满足闭包的第一个元素:
assert 'New Hampshire' ==
['New Hampshire','New Jersey','New York'].find { it =~ */*New*/* }
findAll 方法返回满足闭包的所有元素。此示例返回所有名字中包含字母 e 的城市:
def withE = cities.findAll { city -> city =~ */*e*/* }
assert withE == ['Seattle', 'New York', 'Cleveland']
Groovy 还提供了 any 和 every 方法,这些方法也接受闭包:
assert cities.any { it.size() < 7 }
assert cities.every { it.size() < 10 }
第一个表达式表示至少有一个城市的名字少于 7 个字符。第二个表达式说明所有城市名字的长度都不超过 10 个字符。
表 B.1 总结了添加到 Groovy 集合中的可搜索方法。
Table B.1. 添加到 Groovy 集合中的可搜索方法
| 方法 | 描述 |
|---|---|
| any | 如果任何元素满足闭包则返回 true |
| every | 如果所有元素满足闭包则返回 true |
| find | 返回满足闭包的第一个元素 |
| findAll | 返回满足闭包的所有元素的列表 |
最后,join 方法使用提供的分隔符将列表中的所有元素连接成一个单独的字符串:
assert cities.*join*(',') == "Boston,Seattle,New York,Cleveland"
原生语法和附加便利方法的组合使得 Groovy 列表比它们的 Java 对应物更容易处理。实际上,映射也是以同样的方式改进的。
B.4.3. 映射
Groovy 映射类似于 Java 映射,但再次具有原生语法和额外的辅助方法。Groovy 使用与列表相同的方括号语法来表示映射,但映射中的每个条目都使用冒号来分隔键和其对应的值。
您可以在声明映射本身时立即通过添加元素来填充映射:
def trivialMap = [x:1, y:2, z:3]
assert 1 == trivialMap['x']
assert trivialMap instanceof java.util.HashMap
这定义了一个包含三个条目的映射。当向映射中添加元素时,假设键是字符串类型,因此不需要在它们周围放置引号。值可以是任何内容。
映射键
在向映射中添加内容时,假设键的类型为 string,因此不需要引号。
您可以使用 Java 或 Groovy 语法向映射中添加内容:
def ALEast[10] = [:]
ALEast.put('Boston','Red Sox')
assert 'Red Sox' == ALEast.get('Boston')
assert ALEast == [Boston:'Red Sox']
ALEast['New York'] = 'Yankees'
^(10)对于非棒球爱好者来说,ALEast 是美国联盟东部分区的缩写。
可以使用显示的数组语法或使用点来访问值。如果键中包含空格,请将键用引号括起来:
assert 'Red Sox' == ALEast.Boston
assert 'Yankees' == ALEast.'New York'
我一直使用 def 来定义映射引用,但 Groovy 理解 Java 泛型:
Map<String,String> ALCentral = [Cleveland:'Indians',
Chicago:'White Sox',Detroit:'Tigers']
assert 3 == ALCentral.size()
assert ALCentral.Cleveland == 'Indians'
映射有一个 size 方法,它返回条目的数量。实际上,size 方法是通用的。
大小
在 Groovy 中,size 方法适用于数组、列表、映射、字符串等。
映射有一个重载的 plus 操作,它将两个映射的条目组合起来:
def both = ALEast + ALCentral
assert 5 == both.size()
与 Java 映射一样,您可以使用 keySet 方法从映射中提取键集:
assert ALEast.keySet() == ['Boston','New York'] as Set
映射还有一个相当有争议的方法,允许您在元素不存在的情况下添加一个具有默认值的元素:
assert 'Blue Jays' == ALEast.get('Toronto','Blue Jays')
assert 'Blue Jays' == ALEast['Toronto']
在这里,我尝试使用不在映射中的键(Toronto)来检索值。如果键存在,则返回其值。如果不存在,则将其添加到映射中,get 方法的第二个参数是其新值。这很方便,但这也意味着如果您在尝试检索时意外拼写错误,您不会收到错误;相反,您最终会添加它。使用 get 的单参数版本则不是这样。
最后,当您使用闭包迭代映射时,虚拟参数的数量决定了如何访问映射。使用两个参数意味着映射作为键和值被访问:
String keys1 = ''
List<Integer> values1 = []
both.*each* { key,val ->
keys1 += '|' + key
values1 << val
}
each 迭代器有两个虚拟变量,因此第一个代表键,第二个代表值。这个闭包将键追加到一个字符串中,键之间用竖线分隔。值被添加到一个列表中。
或者,使用单个参数将每个条目分配给指定的参数,或者如果没有指定,则分配给 it:
String keys2 = ''
List<Integer> values2 = []
both.*each* { entry ->
keys2 += '|' + entry.key
values2 << entry.value
}
因为闭包中使用了单个虚拟参数,所以我需要访问其 key 和 value 属性(相当于通常调用 getKey 和 getValue 方法)来执行与上一个示例相同的操作。
两种机制产生相同的结果:
assert keys1 == keys2
assert values1 == values2
在本节中,我在示例中使用了闭包,但没有定义它们是什么。这是下一节的主题。
B.5. 闭包
和许多开发者一样,我最初是从过程式世界开始的。我以研究科学家的身份开始了我的职业生涯,研究不稳定的空气动力学和声学。大部分工作都涉及数值求解偏微分方程。
这意味着除非我想编写自己的所有库,否则我必须选择 Fortran 作为我的首选专业语言。^([11)] 我在第一份工作中的第一个任务是取我老板用 Fortran IV 编写的 3000 行程序,并为其添加功能。最好的部分是原始程序中只有两个子程序:一个大约 25 行长,另一个 2975 行。不用说,我在知道实际术语之前就学会了重构。
^([11]) 我认真考虑用不同的语言编写那些库的事实,是我处于错误职业的另一个迹象。
^([12]) 不寒而栗。哦,数学-if 语句,蝙蝠侠。噩梦已经停止,但花了一些时间。
我迅速学会了当时被认为是良好的开发实践,这意味着我写了尽可能使用现有库的结构化程序。直到 90 年代中期,当我第一次学习 Java 时,我才接触到面向对象编程。
那是我第一次遇到后来有影响力的博主 Steve Yegge 称之为名词王国中动词的屈服现象。^([13)] 在大多数面向对象的语言中,方法(动词)只能作为名词(类)的一部分存在。Java 当然就是这样工作的。即使是那些不需要对象的静态方法,也必须定义在某个地方的类内部。
^([13]) “在名词王国的执行”,在
mng.bz/E4MB
第一种改变这一切的语言是 JavaScript,它是一种基于对象的语言,而不是面向对象的语言。在 JavaScript 中,甚至类也是函数。然后,因为类中的方法也是函数,你最终会得到函数在函数内部运行,可能传递对其他函数的引用,突然之间一切变得混乱和困难。JavaScript 中的闭包之所以令人困惑,并不是因为函数本身复杂,而是因为闭包包含了其执行的 环境。闭包可能引用其外部声明的变量,在 JavaScript 中,确定这些值很容易迷失方向。
我直到遇到 Groovy 才知道闭包可以有多简单。^([14)] 在 Groovy 中,将闭包视为代码块很容易,但总是很清楚非局部变量在哪里被评估,因为没有关于当前对象的混淆。
^([14]) 其他人也可以说 Ruby 或其他 JVM 语言。这是我的历史。
闭包
实际上,闭包是一段代码及其执行环境。
在 Groovy 中,术语“闭包”被广泛用来指代代码块,即使它们不包含对外部变量的显式引用。闭包感觉像方法,可以那样调用。考虑这个简单的例子,它返回它所接收的任何内容:
def echo = { it }
assert 'Hello' == echo('Hello')
assert 'Hello' == echo.call('Hello')
echo 引用被分配给由大括号分隔的代码块(闭包)。闭包包含一个默认名为 it 的变量,其值在闭包被调用时提供。如果你把变量想象成方法参数,你就抓住了基本概念。
闭包可以通过两种方式之一被调用:要么像方法调用一样使用引用,要么通过显式调用其上的 call 方法。由于闭包计算出的最后一个值会自动返回,所以两种方式都返回闭包的参数,这也是为什么它最初被称为 echo 的原因。
闭包返回值
闭包中最后评估的表达式会自动返回。
如果闭包接受多个参数,或者你不想使用默认名称,请使用箭头将虚拟参数名称与闭包体分开。以下是一个简单的求和示例,一次使用默认名称,一次使用命名参数:
def total = 0
(1..10).*each* { num -> total += num }
assert (1..10).*sum*() == total
total = 0
(1..10).*each* { total += it }
assert (1..10).*sum*() == total
闭包在本书中被广泛使用,并在 GinA 中占据了一整章。这么一点信息就足以让你取得很大进步。
回到语言的基本结构,我现在将展示 Groovy 在使用循环和条件测试时与 Java 的不同之处。
B.6. 循环和条件
在本节中,我将讨论任何编程语言中都会出现的两个特性:遍历一组值和做出决策。
B.6.1. 循环
当 Groovy 首次创建时,以及在此之后的一段时间内,它不支持标准的 Java for 循环:
for (int i = 0; i < 5; i++) { ... }
然而,在 1.6 版本中,核心贡献者决定支持 Java 构造比尝试保持语言不受从其前辈那里继承的有些尴尬的语法更重要。许多 Groovy 的演示从 Java 类开始,将其重命名为 .groovy 扩展名,并显示它仍然可以用 Groovy 编译器成功编译。结果是远非 Groovy 的典型用法,但它确实说明了有效观点:Groovy 是 JVM 新家族语言中最接近 Java 的。
Java 循环
Groovy 支持标准的 Java for 循环和 for-each 循环,以及 while 循环。然而,它不支持 do-while 构造。
Java 中的 for-each 循环是在 Java SE 1.5 中引入的,适用于任何线性集合,包括数组和列表:
for (String s : strings) { ... }
for-each 循环很有用,因为它意味着你不必总是获取迭代器来遍历列表的元素。你付出的代价是没有显式的索引。在循环内部,你知道当前在哪个元素上,但不知道它在列表中的位置。如果你需要知道索引,你可以自己跟踪索引,或者回到传统的 for 循环。
Groovy 提供了一种对 for-each 循环的变体,避免了冒号语法,称为 for-in 循环:
def words = "I'm a Groovy coder".*tokenize*()
def capitalized = ''
for (word in words) {
capitalized += word.*capitalize*() + ' '
}
assert capitalized == "I'm A Groovy Coder "
注意,与 for-each 循环不同,值变量没有被声明为具有类型:甚至不是 def。
尽管如此,这些循环都不是 Groovy 中最常见的迭代方式。与前面的例子中显式编写循环不同,Groovy 更喜欢直接实现迭代器设计模式。Groovy 向集合添加了each方法,该方法接受一个闭包作为参数。然后each方法将闭包应用于集合的每个元素:
(0..5).each { println it }
再次强调,因为闭包是方法的最后一个参数,所以它可以放在括号之后。因为没有其他参数传递给each方法,所以可以完全省略括号。
每个
each方法是 Groovy 中最常见的循环结构。
迭代器设计模式建议将遍历集合元素的方式与计划对这些元素做什么分开。each方法在内部执行迭代。用户通过提供闭包来确定如何处理元素,如所示。这里闭包打印其参数。each方法逐个将范围内的每个值提供给闭包,因此结果是打印从零到五的数字。
就像for-in循环一样,在闭包内部你可以访问每个元素,但不能访问索引。但是,如果你想得到索引,有一个额外的eachWithIndex方法可用:
def strings = ['how','are','you']
def results = []
strings.*eachWithIndex* { s,i -> results << "$i:$s" }
assert results == ['0:how', '1:are', '2:you']
传递给eachWithIndex方法的闭包接受两个虚拟参数。第一个是集合中的值,第二个是索引。
我应该提到,尽管所有这些循环都工作正常,但它们在执行时间上可能会有所不同。如果你处理的是一个包含几十个元素或更少的集合,这些差异可能不会很明显。如果迭代的次数将达到数万次或更多,你可能应该对生成的代码进行性能分析。
B.6.2. 条件语句
Java 有两种类型的条件语句:if语句及其相关结构,如if-else和switch语句。两者都由 Groovy 支持。if语句的工作方式与 Java 中几乎相同。然而,switch语句是从 Java 的受损形式中提取出来的,并恢复到其以前的辉煌。
Groovy 的if语句版本与 Java 的类似,区别在于所谓的 Groovy 真理。在 Java 中,if语句的参数必须是布尔表达式,否则语句无法编译。在 Groovy 中,除了布尔表达式之外,许多东西都会评估为 true。
例如,非零数字是真实的:
if (1) {
assert true
} else {
assert false
}
结果是true。这个表达式在 Java 中不起作用。在那里,你必须将参数与另一个值进行比较,从而产生布尔表达式。
回归 C?
Groovy 的真理是一个 Java 限制而 C 支持的情况(决策语句中的非布尔表达式),但 Groovy 将其恢复。这当然可能导致 Java 会避免的 bug。
从哲学的角度来看,为什么要这样做?通过限制允许的内容,Java 使得某些类型的错误发生的可能性大大降低。Groovy 通过恢复这些特性,再次增加了这些错误的可能性。这种收益是否值得?
我认为这是开发社区中测试重要性增加的副作用。如果你必须编写测试来证明你的代码是正确的,为什么不利用这种更大的能力呢?当然,你引入了编译器可能无法捕捉到一些错误的可能性,但仅仅因为编译成功并不意味着它是正确的。测试证明了正确性,所以为什么不在可能的情况下使用更短、更强大的代码呢?
回到决策语句,Java 也支持三元操作符,Groovy 也是如此:
String result = 5 > 3 ? 'x' : 'y'
assert result == 'x'
三元表达式读作:五是否大于三?如果是,将结果分配给 x,否则使用 y。它就像一个 if 语句,但更短。
三元操作符的简化形式突出了 Groovy 的有用性和幽默感:Elvis 操作符。
B.6.3. Elvis
考虑以下用例。你计划使用一个输入值,但它不是必需的。如果客户端提供了它,你将使用它。如果没有,你计划使用默认值。
我将以一个名为 name 的变量为例:
String displayName = name ? name : 'default'
这意味着如果 name 不为 null,则使用它作为 displayName。否则,使用默认值。我正在使用标准的三元操作符来检查 name 是否为 null。这种写法有一些重复。毕竟,如果 name 可用,我想使用它,为什么我还要重复自己呢?
这就是 Elvis 操作符发挥作用的地方。以下是修改后的代码:
String displayName = name ?: 'default'
Elvis 操作符是三元操作符中省略中间值形成的一个问号和冒号的组合。理念是如果问号前面的变量不为 null,则使用它。?: 操作符被称为 Elvis,因为如果你侧过头来看,结果看起来有点像国王:
def greet(name) { "${name ?: 'Elvis'} has left the building" }
assert greet(null) == 'Elvis has left the building'
assert greet('Priscilla') == 'Priscilla has left the building'
greet 方法接受一个名为 name 的参数,并使用 Elvis 操作符来确定要返回的内容。这样即使输入参数为 null,它仍然有一个合理的值。^([15])
¹⁵ 非常感谢,非常感谢。
B.6.4. Safe de-reference
Groovy 提供了一个可以节省许多代码行的最终条件操作符,它被称为安全解引用操作符,写作 ?.。
理念是避免不断检查 null 值。例如,假设你有名为 Employee、Department 和 Location 的类。如果每个员工实例都有一个部门,每个部门都有一个位置,那么如果你想为员工获取位置,你将编写如下代码(在 Java 中):
Location loc = employee.getDepartment().getLocation()
但如果员工引用为 null 呢?或者如果员工尚未分配部门,getDepartment 方法返回 null 呢?这些可能性意味着代码会扩展为
if (employee == null) {
loc = null;
} else {
Department dept = employee.getDepartment();
if (dept == null) {
loc = null;
} else {
loc = dept.getLocation();
}
}
仅为了检查空值就进行了如此大的扩展。以下是 Groovy 版本:
Location loc = employee?.department?.location
安全解引用操作符如果引用为 null 则返回null。否则,它将继续访问属性。这是一件小事,但节省的代码行数却非同小可。
在简化代码的主题上继续,考虑输入/输出流。Groovy 在 Groovy JDK 中引入了几个方法,帮助 Groovy 在处理文件和目录时简化 Java 代码。
B.7. 文件输入/输出
Groovy 中的文件 I/O 在本质上与 Java 方法没有区别。Groovy 添加了几个便利方法,并处理了像为你关闭文件这样的问题。几个简短的例子就足以让你了解可能实现的内容。
首先,Groovy 向File类添加了一个getText方法,这意味着通过请求文本属性,你可以一次性以字符串的形式检索文件中的所有数据:
String data = new File('data.txt').*text*
访问text属性会调用getText方法,就像通常一样,并返回文件中的所有文本。或者,你可以使用readLines方法检索文件中的所有行并将它们存储在一个列表中:
List<String> lines = new File("*data.txt"*).*readLines*()*.trim()
在此示例中,trim方法与扩展点操作符一起使用,用于删除每行的前导和尾随空格。如果你的数据以特定方式格式化,splitEachLine方法接受一个分隔符并返回一个元素列表。例如,如果你有一个包含以下行的数据文件
1,2,3
a,b,c
那么数据可以同时检索和解析:
List dataLines = []
new File("*data.txt"*).*splitEachLine*(',') {
dataLines << it
}
assert dataLines == [['1','2','3'],['a','b','c']]
向文件写入同样简单:
File f = new File("$base*/output.dat"*)
f.*write*('Hello, Groovy!')
assert f.*text* == 'Hello, Groovy!'
在 Java 中,如果你已经向文件写入,关闭文件是至关重要的,因为否则它可能不会刷新缓冲区,你的数据可能永远无法进入文件。Groovy 会自动为你处理这个问题。
Groovy 还使得向文件追加内容变得非常简单:
File temp = new File("*temp.txt"*)
temp.*write* 'Groovy Kind of Love'
assert temp.readLines().size() == 1
temp.append "\nGroovin', on a Sunday afternoon..."
temp << "\nFeelin' Groovy"
assert temp.readLines().size() == 3
temp.delete()
append方法做的是它听起来像的事情,左移操作符也被重载以执行相同的操作。
有几种方法可以遍历文件,如eachFile、eachDir,甚至eachFileRecurse。它们各自接受闭包以过滤你想要的内容。
最后,我必须给你举一个例子,说明 Groovy 的 I/O 流比 Java 流简单得多。考虑编写一个简单的应用程序,它执行以下操作:
1. 提示用户在一行中输入数字,数字之间用空格分隔
2. 读取行
3. 计算数字总和
4. 打印结果
没什么特别的,对吧?下一个列表显示了 Java 版本。
列表 B.4. SumNumbers.java,一个读取一行数字并求和的应用程序

仅用 30 行代码就完成了极其简单的事情。所有 Java 代码都必须在一个包含main方法的类中。输入流System.in是可用的,但我想读取一整行数据,所以我将流包装在InputStreamReader中,然后再将其包装在BufferedReader中,这样我就可以调用readLine方法。这可能会抛出 I/O 异常,所以我需要为它提供一个try/catch块。最后,传入的数据是字符串形式,所以在将数字相加并打印结果之前,我需要解析它。
下面是相应的 Groovy 版本:
println 'Please enter some numbers'
System.in.withReader { br ->
println br.readLine().tokenize()*.toBigDecimal().sum()
}
这就是整个程序。withReader方法创建了一个具有readLine方法的Reader实现,并在闭包完成后自动关闭它。对于输入和输出,都有几个类似的方法可用,包括withReader、withInputStream、withPrintWriter和withWriterAppend。
那很有趣,但这里还有一个功能更强大的版本。在这种情况下,代码中有一个循环,它会累加每一行的值并打印出结果,直到没有输入为止:
println 'Sum numbers with looping'
System.in.eachLine { line ->
if (!line) System.exit(0)
println line.*split*(' ')*.toBigDecimal().sum()
}
eachLine方法会重复执行闭包,直到行变量为空。
Groovy 对文件 I/O 的贡献是添加了一些便利方法,这些方法简化了 Java API 并确保流或文件被正确关闭。它为 Java I/O 包提供了一个干净的界面。
Groovy 使得输入/输出流比 Java 中更容易处理,所以如果我在一个 Java 系统中工作并且需要处理文件,我会尝试添加一个 Groovy 模块来达到这个目的。这是一个节省,但与在处理 XML 时使用 Groovy 而不是 Java 所带来的节省相比,这微不足道。在下一节中,我们将展示这一点。
B.8. XML
我把最好的留到了最后。XML 是 Groovy 和 Java 之间易用性差距最大的地方。在 Java 中处理 XML 最多是痛苦的,而在 Groovy 中解析和生成 XML 几乎是微不足道的。如果我在 Java 系统中必须处理 XML,我总是会添加一个 Groovy 模块来达到这个目的。本节旨在展示原因。
B.8.1. 解析和读取 XML
很久以前,我教了一个关于 XML 和 Java 的培训课程。其中一个练习是从展示一个类似于这个的 XML 文件开始的:
<books>
<book isbn=*"9781935182443"*>
<title>Groovy in Action (2nd edition)</title>
<author>Dierk Koenig</author>
<author>Guillaume Laforge</author>
<author>Paul King</author>
<author>Jon Skeet</author>
<author>Hamlet D'Arcy</author>
</book>
<book isbn=*"9781935182948"*>
<title>Making Java Groovy</title>
<author>Ken Kousen</author>
</book>
<book isbn=*"1933988932"*>
<title>Grails in Action</title>
<author>Glen Smith</author>
<author>Peter Ledbrook</author>
</book>
</books>
这个练习的目标是解析这个文件并打印出第二本书的标题。因为这个文件很小,你可以使用 DOM 解析器来读取它。在 Java 中这样做,你需要一个工厂,然后它会产生解析器,然后你可以调用parse方法来构建 DOM 树。然后,为了提取数据,有三个选项:
-
通过获取子元素并遍历它们来遍历树。
-
使用
getElementById方法找到正确的节点,然后获取第一个文本子节点并检索其值。 -
使用
getElementsByTagName方法,遍历结果NodeList以找到正确的节点,然后检索第一个文本子节点的值。
第一种方法会遇到空白字符的问题。这份文档包含回车符和制表符,因为没有提供 DTD 或模式,解析器不知道哪些空白字符是重要的。由于 getFirstChild 等方法会返回空白节点以及元素,遍历 DOM 变得复杂。这是可以做到的,但你将需要检查每个元素的节点类型,以确保你正在处理元素而不是文本节点。
第二种方法仅在元素具有类型为 ID 的属性时才有效,但这里不是这种情况。
你将使用 getElementsByTagName 方法,这将导致以下代码:
import java.io.IOException;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
public class ProcessBooks {
public static void main(String[] args) {
DocumentBuilderFactory factory =
DocumentBuilderFactory.newInstance();
Document doc = null;
try {
DocumentBuilder builder = factory.newDocumentBuilder();
doc = builder.parse("books.xml");
} catch (ParserConfigurationException e) {
e.printStackTrace();
} catch (SAXException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
NodeList titles = doc.getElementsByTagName("title");
Element titleNode = (Element) titles.item(1);
String title = titleNode.getFirstChild().getNodeValue();
System.out.println("The second title is " + title);
}
}
解析文档可能会抛出各种异常,如所示。假设没有出错,解析后,代码检索所有 title 元素。在从 NodeList 中获取适当的元素并将其转换为 Element 类型后,你必须记住元素中的字符数据位于元素的第一个文本子节点中,而不是元素本身。
这里是 Groovy 的解决方案:
root = new XmlSlurper().parse('books.xml')
assert root.book[1].title == 'Making Java Groovy'
哇。Groovy 包含了 XmlSlurper 类,该类位于 groovy.util 包中(无需导入)。XmlSlurper 有一个 parse 方法,它构建 DOM 树并返回根元素。然后就是遍历树,使用点表示法来表示子元素。元素多次出现形成一个集合,可以通过索引以正常方式访问。Groovy 版本和 Java 版本在大小和复杂性方面的对比是明显的。
下一个列表演示了如何处理 XML 文件。
列表 B.5. 吞噬 XML
String fileName = 'books.xml'
def books = new XmlSlurper().parse(fileName)
assert books.book.size() == 4
assert books.book[0].title == "Groovy in Action"
assert books.book.find {
it.@isbn == "9781935182948"
}.title == "Making Java Groovy"
def prices = []
books.book.price.each {
prices << it.toDouble()
}
assert prices == [39.99, 35.99, 35.99, 27.50]
assert prices.sum() == 139.47
Groovy 使用两个不同的类来处理 XML。上一个例子使用了 XmlSlurper。Groovy 还包括一个 XmlParser。XmlParser 创建一个 Node 实例的树,所以如果你需要从节点角度接近文件,请使用解析器。结果是,你需要在每个节点上调用 text 方法来检索文本数据,但除此之外,两种方法几乎相同。
解析 XML 因此相当简单。那么生成 XML 呢?这是下一小节的主题。
B.8.2. 生成 XML
到目前为止,Groovy 展示的大多数功能与 Java 可以做到的类似,只是更简单或更容易。在本节中,我将展示一个 Groovy 构建器,它使用 Groovy 的元编程来超越 Java 可以做到的。
要生成 XML,Groovy 提供了一个名为 groovy.xml.MarkupBuilder 的类。你通过调用不存在的方法来使用 MarkupBuilder,构建器通过生成 XML 元素和属性来解释它们。
这听起来很奇怪,但在实践中很简单。接下来的列表显示了示例。
列表 B.6. 使用 MarkupBuilder 生成 XML
def builder = new groovy.xml.MarkupBuilder()
def department = builder.department {
deptName "Construction"
employee(id:1) {
empName "Fred"
}
employee(id:2) {
empName "Barney"
}
}
在实例化 MarkupBuidler 后,我在它上面调用了 department 方法,省略了可选的括号。MarkupBuilder 上没有 department 方法,那么 Groovy 会做什么呢?
如果这是 Java,我会因为MissingMethodException而失败。然而,每个 Groovy 类都有一个相关的meta类,而meta类有一个名为methodMissing的方法。meta类是 Groovy 代码生成能力的关键。当MarkupBuilder中的methodMissing方法被调用时,其实施最终是生成一个以方法名作为元素名的 XML 元素。
下面的花括号表示下一个子元素。子元素的名称将是deptName,其字符数据将是提供的字符串。下一个元素是employee,id的映射语法暗示在员工元素上需要一个属性,依此类推。
执行此脚本的结果是
<department>
<deptName>Construction</deptName>
<employee id='1'>
<empName>Fred</empName>
</employee>
<employee id='2'>
<empName>Barney</empName>
</employee>
</department>
MarkupBuilder生成 XML。很难想象有比这更简单的方法来解决该问题。
我想用 Groovy 演示 XML 处理的一个最终方面,这涉及到验证一个文档。
B.8.3. 验证
XML 文档可以通过两种方式之一进行验证:通过文档类型定义(DTD)或 XML 架构。DTD 系统较老,较简单,但不太有用,但 Java 解析器几乎从一开始就能对它们进行验证。架构验证来得较晚,但更为重要,尤其是在处理,例如,网络服务时。
使用 Groovy 验证 XML 是一个有趣的演示,展示了 Groovy 提供了什么,以及如果 Groovy 没有提供任何东西时应该怎么做。
首先,考虑对 DTD 的验证。这是前面显示的图书馆 XML 的 DTD:
<!ELEMENT library (book+)>
<!ELEMENT book (title,author+,price)>
<!ATTLIST book
isbn CDATA #REQUIRED>
<!ELEMENT title (#PCDATA)>
<!ELEMENT author (#PCDATA)>
<!ELEMENT price (#PCDATA)>
想法是library元素包含一个或多个book元素。book元素包含一个title,一个或多个author元素,以及一个price,顺序如下。book元素有一个名为isbn的属性,它是一个简单的字符串,但必需。title、author和price元素都由简单的字符串组成。
为了将 XML 文件与 DTD 关联起来,我在根元素之前添加以下行:
<!DOCTYPE library SYSTEM "library.dtd">
将 XML 文件与 DTD 进行验证几乎是微不足道的。XmlSlurper类有一个重载的构造函数,它接受两个参数,都是布尔值。第一个参数用于触发验证,第二个参数用于命名空间感知。在讨论 DTD 时,命名空间并不相关,但开启这两个属性也无妨:
def root = new XmlSlurper(true, true).parse(fileName)
这就是进行验证所需的所有内容。如果 XML 数据不满足 DTD,解析过程将报告错误。
与 XML 架构的验证一直是一个更大的挑战。架构理解命名空间和命名空间前缀,并且在架构中你可以做很多在 DTD 中不能做的事情。
考虑下面的列表,它展示了图书馆的架构。
列表 B.7. 图书馆 XML 的 XML 架构
<?xml version=*"1.0"* encoding=*"UTF-8"*?>
<schema
xmlns=*"http://www.w3.org/2001/XMLSchema"*
targetNamespace=*"http://www.kousenit.com/books"*
xmlns:tns=*"http://www.kousenit.com/books"*
elementFormDefault=*"qualified"*>
<element name=*"library"* type=*"tns:LibraryType"* />
<complexType name=*"LibraryType"*>
<sequence>
<element ref=*"tns:book"* maxOccurs=*"unbounded"* />
</sequence>
</complexType>
<element name=*"book"*>
<complexType>
<sequence>
<element name=*"title"* type=*"string"* />
<element name=*"author"* type=*"string"*
maxOccurs=*"unbounded"* />
<element name=*"price"* type=*"tns:PriceType"* />
</sequence>
<attribute name=*"isbn"* type=*"tns:ISBNtype"* />
</complexType>
</element>
<simpleType name=*"PriceType"*>
<restriction base=*"decimal"*>
<fractionDigits value=*"2"* />
</restriction>
</simpleType>
<simpleType name=*"ISBNtype"*>
<restriction base=*"string"*>
<pattern value=*"\d{10}|\d{13}"* />
</restriction>
</simpleType>
</schema>
这与 DTD 相同,只是它指出价格元素有两位小数,而isbn属性由 10 或 13 位十进制数字组成。通过修改根元素如下,可以将 XML 文档绑定到这个模式:
<library
xmlns:xsi=*"http://www.w3.org/2001/XMLSchema-instance"*
xmlns=*"http://www.kousenit.com/books"*
xsi:schemaLocation=*"*
*http://www.kousenit.com/books*
*books.xsd"*>
library的其余部分与之前相同。以下是用于验证 XML 文档与模式的代码:
String file = "b*ooks.xml"*
String xsd = "*books.xsd"*
SchemaFactory factory = SchemaFactory.*newInstance*(
XMLConstants.*W3C_XML_SCHEMA_NS_URI*)
Schema schema = factory.newSchema(new File(xsd))
Validator validator = schema.newValidator()
validator.validate(new StreamSource(new FileReader(file)))
这看起来相对简单,但有趣的部分是:所使用的机制是 Java。如果我要用 Java 编写这段代码,它看起来几乎相同。与用于 DTD 验证的XmlSlurper不同,Groovy 没有添加任何特殊的功能来进行模式验证。因此,你将回退到 Java 方法并在 Groovy 中编写它。因为 Groovy 没有添加任何东西,这些行可以根据你的需要用任何一种语言编写。
尽管如此,Groovy 通常还是有所帮助的,正如本附录中的大部分代码所示。
现在,每当提到 XML 时,总有人询问关于 JSON 支持的问题。我将在下一节中解决这个问题。
B.9. JSON 支持
行业趋势已经从 XML 转向 JavaScript 对象表示法(JSON)。如果你的客户端是用 JavaScript 编写的,JSON 是自然的,因为 JSON 对象是语言的原生部分。Java 不包含 JSON 解析器,但有几个好的库可用。
截至 1.8 版本的 Groovy,Groovy 包括一个groovy.json包,其中包含一个 JSON 解析器和 JSON 构建器。
B.9.1. 吞噬 JSON
groovy.json 包含一个名为JsonSlurper的类。这个类并不像XmlSlurper类那样多功能,因为它有更少的方法。它包含一个parse方法,该方法接受一个Reader作为参数,以及一个parseText方法,该方法接受一个String。
一个 JSON 对象看起来就像花括号内的一个映射。解析它会在 Groovy 中产生一个映射:
import groovy.json.JsonSlurper;
def slurper = new JsonSlurper()
def result = slurper.parseText('{"first":"Herman","last":"Munster"}')
assert result.first == 'Herman'
assert result.last == 'Munster'
实例化解析器并调用其parseText方法,结果是一个可以按常规方式访问的映射,如下所示。列表也可以工作:
result = slurper.parseText(
'{"first":"Herman","last":"Munster","kids":["Eddie","Marilyn"]}')
assert result.kids == ['Eddie','Marilyn']
这两个子元素最终会出现在一个ArrayList实例中。你还可以添加数字,甚至包含的对象:
result = slurper.parseText(
'{"first":"Herman","last":"Munster","address":{"street":"1313 Mockingbird
Lane","city":"New York","state":"NY"},"wife":"Lily",
"age":34,"kids":["Eddie","Marilyn"]}')
result.with {
assert wife == 'Lily'
assert age == 34
assert address.street == '1313 Mockingbird Lane'
assert address.city == 'New York'
assert address.state == 'NY'
}
age 变成了整数。address 对象也被解析成一个映射,其属性也可以按照标准方式访问。顺便说一句,我使用了with方法,它会将调用它的任何值添加到包含的表达式之前。wife 是 result.wife 的简称,以此类推。
如果解析简单,构建也是一个简单的操作,就像使用MarkupBuilder一样。
B.9.2. 构建 JSON
我之前讨论了构建器,并在整本书中使用它们。在各个章节中,我使用了MarkupBuilder(在本章中展示)、SwingBuilder和AntBuilder。在这里,我将展示用于生成 JSON 的构建器,称为JsonBuilder。
JsonBuilder类可以与列表、映射或方法一起使用。例如,这里有一个简单的列表:
import groovy.json.JsonBuilder;
def builder = new JsonBuilder()
def result = builder 1,2,3
assert result == [1, 2, 3]
这个构建器接受一个数字列表作为参数,并构建一个包含这些数字的 JSON 对象。这里是一个使用映射的示例:
result = builder {
first 'Fred'
last 'Flintstone'
}
assert builder.toString() == '{"first":"Fred","last":"Flintstone"}'
结果是一个标准的 JSON 对象(包含在大括号中),其属性是构建器中提供的字符串。
在构建器语法中,您可以使用括号来构建一个包含的对象,所以让我们继续使用示例:
result = builder.people {
person {
first 'Herman'
last 'Munster'
address(street:'1313 Mockingbird Lane',
city:'New York',state:'NY')
wife 'Lily'
age 34
kids 'Eddie','Marilyn'
}
}
assert builder.toString() ==
'{"people":{"person":{"first":"Herman","last":"Munster",' +
'"address":{"street":"1313 Mockingbird Lane",' +
'"city":"New York","state":"NY"},"wife":"Lily","age":34,' +
"kids":["Eddie","Marilyn"]}}}'
生成的 JSON 可能难以阅读,因此该类添加了一个 toPrettyString() 方法:
println builder.toPrettyString()
这将产生格式良好的输出,如下所示:
{
"people": {
"person": {
"first": "Herman",
"last": "Munster",
"address": {
"street": "1313 Mockingbird Lane",
"city": "New York",
"state": "NY"
},
"wife": "Lily",
"age": 34,
"kids": [
"Eddie",
"Marilyn"
]
}
}
}
因此,JSON 数据在创建和管理时几乎与 XML 一样容易处理。


浙公网安备 33010602011771号