演化架构和紧急设计: 使用 Groovy 构建 DSL

更富于表现力的惯用模式捕获

内部特定领域语言(DSL)是可行的,但是由于 Java™ 语言的限制性语法使其使用并不灵活。JVM 中的其他语言更适合构建它们。这一期的 演化架构和紧急设计 涵盖了许多您可以利用的功能,以及在您使用 Groovy 构建内部 DSL 时将要遇到的问题。

Neal Ford, 软件架构师/ Meme Wrangler, ThoughtWorks

Neal Ford 照片Neal Ford 是一家全球性 IT 咨询公司 ThoughtWorks Neal Ford 是一家全球性 IT 咨询公司。他的工作还包括设计和开发应用程序、教材、杂志文章、课件和视频/DVD 演示,而且他是各种技术书籍的作者或编辑,包括最近的新书 The Productive Programmer。他主要的工作重心是设计和构建大型企业应用程序。他还是全球开发人员会议上的国际知名演说家。请访问他的 Web 站点



2010 年 9 月 30 日

上个月的这一专栏 中,我讲述了使用特定领域语言(DSL)的示例,在您的代码中定义为通用设计习惯。(我在 “组合方法和 SLAP” 一文中介绍了惯用模式的概念。)DSL 是捕获模式的一个良好介质,因为它们是声明式的,比 “普通” 源代码更容易阅读,使您的捕获模式从周围的代码中脱颖而出。

构建 DSL 的语言技术通常使用巧妙的方法来为您的代码隐式地提供包装上下文。换句话说,DSL 试图使用潜在语言特性 “隐藏” 杂乱的语法来使您的代码更具可读性。尽管您可以使用 Java 语言构建 DSL,但是 DSL 用于隐藏上下文的贫乏的构造,及其死板和无常的语法,使它不适合这一技术。但是其他基于 JVM 的语言可以填补这一空缺。在本期以及下一期中,我将向您介绍如何扩展您的 DSL 构建调板,包含更富于表现力的在 Java 平台上运行的语言,从 Groovy 开始(见 参考资料)。

关于本系列

系列 旨在从全新的视角来介绍经常讨论但是又难以理解的软件架构和设计概念。通过具体示例,Neal Ford 将帮助您在演化架构紧急设计 的敏捷实践方面打下坚实的基础。通过将重要的架构和设计决定推迟到最后责任时刻,可以防止不必要的复杂度降低软件项目的质量。

Groovy 提供各种特性使构建 DSL 更为容易,在 DSL 中支持数量是一个常见的需求。人们总是需要很多数量:7 英寸、4 英里、13 天等。Groovy 允许您通过开放类 直接添加对数量的支持。开源类允许您重新打开现存类并通过在类中添加、删除或修改方法对其进行修改 — 一个强大但危险的机制。幸运的是,这有安全的方法来实现这一任务。Groovy 支持两种不同的开放类语法:categoriesExpandoMetaClass

通过 categories 开放类

categories 的概念是从 Smalltalk 和 Objective-C 语言(见 参考资料)中借用的。一个 categories 可以使用 use 块指令,围绕代码调用创建一个包装器,含有一个或多个开放类。

通过一个示例更好的理解 categories 概念。清单 1 演示了我已经添加到 String 中的新方法 camelize() 的测试,该方法可以将带下划线的字符串转换成驼峰式大小写:

清单 1. 测试演示 camelize()方法
class TestStringCategory extends GroovyTestCase {
    def expected = ["event_map" : "eventMap", 
            "name" : "name", "test_date" : "testDate", 
            "test_string_with_lots_of_breaks" : "testStringWithLotsOfBreaks",
            "String_that_has_init_cap" : "stringThatHasInitCap" ]

    void test_Camelize() {
        use (StringCategory) {
            expected.each { key, value ->
                assertEquals value, key.camelize()
            }
        }
    }
}

清单 1 中,我使用原始的和转换后的案例创建了一个 expected 散列值,然后根据映射的迭代包装 StringCategory,希望将每个关键词驼峰化(camelized)。注意在 use 块中,您不需要特别做什么就可以调用类中的新方法。

StringCategory 的代码在清单 2 中显示:

清单 2. StringCategory
class StringCategory {

  static String camelize(String self) {
    def newName = self.split("_").collect() { 
      it.substring(0, 1).toUpperCase() +  it.substring(1, it.length())
    }.join()
    newName.substring(0, 1).toLowerCase() +  newName.substring(1, newName.length())      
  }
}

categories 是一个常规类,包含静态方法。静态方法必须要有一个参数,这是您将要增加的类型。在 清单 2 中,我声明了一个单独的静态方法,接收 String 参数(通常称为 self,但是您可以随意为其命名),代表我向其中添加方法的类。方法体包含 Groovy 代码,通过下划线将字符串分成带分隔符的几块(这就是 split("_") 方法所做的),然后将字符串收集到一起,在合适的地方使用 大写字母将它们拼接起来。最后一行确保返回的第一个字符是小写的。

当您使用 StringCategory 时,您必须在 use 块中访问它。在 use 块的圆括号中有多个 categories 类,之间用逗号隔开,这是合法的。

这是在 DSL 中使用开放类表示数量的另一个实例,考虑清单 3 中的代码,实现了一个预约日历:

清单 3. 一个简单的日历 DSL
def calendar = new AppointmentCalendar()

use (IntegerWithTimeSupport) {
    calendar.add new Appointment("Dentist").from(4.pm)
    calendar.add new Appointment("Conference call")
                 .from(5.pm)
                 .to(6.pm)
                 .at("555-123-4321")
}
calendar.print()

清单 3 实现了和 “连贯接口” 中 Java 实例一样的功能,但是增强了语法,其中包括了在 Java 代码中所不能实现的。例如,请注意 Groovy 允许在有些地方删除括号(像围绕 add() 方法的参数这种情况)。我也可以调用像 5.pm 这样对开发人员来说稀奇古怪的命令。这是一个打开 Integer 类(在 Groovy 中所有的数字可以自动使用 type-wrapper 类,即使 5 是一个真正的 Integer )和添加一个 pm 属性的实例。实现该开放类的类在清单 4 中显示:

清单 4. IntegerWithTimeSupport 类定义
class IntegerWithTimeSupport {
    static Calendar getFromToday(Integer self) {
        def target = Calendar.instance
        target.roll(Calendar.DAY_OF_MONTH, self)
        return target
    }

    static Integer getAm(Integer self) {
        self == 12 ? 0 : self
    }

    static Integer getPm(Integer self) {
        self == 12 ? 12 : self + 12
    }
}

这个 categories 类包括三个 Integer 新方法:getFromToday()getAm()getPm()。注意,这些事实上是新属性,而不是方法,在这我之所以说是新属性,是和 Groovy 处理方法调用的方式有关。当您调用一个没有参数的 Groovy 方法时,您必须使用一对空括号调用它,这使得 Groovy 可以分清一个属性访问和一个方法调用之间的区别。如果我将其扩展作为方法,我的 DSL 将需要调用 ampm 扩展作为 5.pm(),这会影响 DSL 的阅读性。我使用 DSL 一个最主要的原因是增强阅读性,因此我想要丢弃额外的杂乱语法。在 Groovy 中您也可以通过创建扩展作为属性进行这一操作,声明属性的语法和 Java 语言中的 get/set 方法对是一样的 — 但是您可以在没有参数的情况下调用它们。

在这个 DSL 中,测量单位是小时,这意味着我需要为 3.pm 返回 15。在构建以数量为特性的 DSL 时,您需要确定您的单位,并将它们添加到 DSL(可选)使其更可读。记住我将使用 DSL 来捕获一个领域惯用模式,这意味这非程序员也可以阅读。

现在,您已经看到了在日历 DSL 中如何实现时间,Appointment 类在清单 5 中显示,简单易懂:

清单 5. Appointment
class Appointment {
  def name;
  def location;
  def date;
  def startTime;
  def endTime;

  Appointment(apptName) {
    name = apptName
    date = Calendar.instance
  }

  def at(loc)  {
    location = loc
    this
  }

  def formatTime(time) {
    time > 12 ? "${time - 12} PM" : "${time} AM"
  }

  def getStartTime() {
    formatTime(startTime)
  }

  def getEndTime() {
    formatTime(endTime)
  }

  def from(start_time) {
    startTime = start_time
    date.set(Calendar.HOUR_OF_DAY, start_time)
    this
  }

  def to(end_time) {
    endTime = end_time
    date.set(Calendar.HOUR_OF_DAY, end_time)
    this
  }

  def display() {
    print "Appointment: ${name}, Starts: ${formatTime(startTime)}"
    if (endTime) print ", Ends: ${formatTime(endTime)}"
    if (location) print ", Location: ${location}"
    println()
  }
}

即使您一点也不了解 Groovy,阅读 Appointment 类也没有一点问题。注意,在 Groovy 中方法的最后一行是它的返回值。这使 at()from()to()this 方法返回值)方法的最后一行成为该类中的流畅接口调用。

categories 允许您以一种受控的方式改变现有的类。改变被严格限制在由 use() 语句定义的词典块中。然而,有时候您需要一个开放类的添加方法来扩展范围,这时候 Groovy 的 ExpandoMetaClass 就派上用场了。


通过 expando 开放类

Groovy 中最初的开放类语法仅使用 categories。然而,Groovy web 框架的构造器,Grails(见 参考资料),发现 categories 固有的作用域限制太严格了,这导致开发了开放类另一种语法,ExpandoMetaClass。当您使用一个 expando 时,您需要访问类的元类(这是 Groovy 为您随机创建的)并向其中添加属性和方法。使用 expando 的日历示例在清单 6 中显示:

清单 6. 使用 expando 开放类的日历
def calendar = new AppointmentCalendar()

calendar.add new Appointment("Dentist")
             .from(4.pm)
calendar.add new Appointment("Conference call")
             .from(5.pm)
             .to(6.pm)
             .at("555-123-4321")
        
calendar.print()

清单 6 中的代码看起来和 清单 3 几乎一样,只是缺少了 categories 必须的 use 块。要实现对 Integer 的改变,您需要访问清单 7 中的元类:

清单 7. Integer 的 Expando 定义
Integer.metaClass.getAm = { ->
  delegate == 12 ? 0 : delegate
}                              

Integer.metaClass.getPm = { ->
  delegate == 12 ? 12 : delegate + 12
}                                

Integer.metaClass.getFromToday = { ->
  def target = Calendar.instance
  target.roll(Calendar.DAY_OF_MONTH, delegate)
  target
}

和 categories 实例一样,我需要 ampm 作为参数而不是作为 Integer 的方法(以便在我调用它们时,不使用括号就可以进行访问),因此我向元类添加一个新属性作为 Integer.metaClass.getAm。这些代码块可以接收参数,但在这我不需要(因此在代码行的开始只需要一个 -> 即可)。在代码块中,delegate 关键字指向您将要向其中添加方法的类的实例。例如,注意在 getFromToday 属性中,我创建了一个新 Calendar 实例,然后使用 delegate 值滚动日历天数(由 Integer 实例指定的)。当我执行 5.fromToday 时,我需要将日历向前滚动 5 天。


在 categories 和 expando 之间选择

既然 categories 和 expandos 给您提供相同类型的表示,您选择哪个呢? categories 的好处是固有的词典块范围限制。对语言的核心类进行根本改变(可能是破坏)是一个常见的 DSL 反模式。Categories 强制使用限制来缓解。另一方面,Expandos 本质上是全局的:一旦 expando 代码执行,这些改变将会出现在应用程序的其余部分中。

一般来说,选择 categories,当您修改重要的类时会有潜在的副作用,您需要限制这些修改的范围。Categories 允许您将修改范围限制得很窄。然而,如果您发现使用相同的 categories 包装的代码越来越多时,您应该使用 expandos。一些修改需要被扩展,而且迫使所有修改适应块可能会导致代码错综复杂。一般说来,如果您发现自己在一个 category 中包装超过 3 个根本不同的代码块时,考虑使用 expando。

最后一点:在这测试不是可选的。许多开发人员认为他们的大量代码可以选择测试,但是修改现有类的任何代码都需要综合测试。核心类的修改能力很强,能够生成良好的问题解决方案。但是与能力随之而来的还有责任,表现为测试。


一个示例

到目前为止,介绍 DSL 作为一种捕获惯用模式的方法可能有点抽象,因此我接下来将使用一个真实的示例作为结束。

easyb(见 参考资料)是一个基于 Groovy 的行为驱动开发测试工具,允许您创建将非开发人员的友好格式和代码结合的场景来实现测试。清单 8 中是一个 easyb 场景的示例:

清单 8. easyb 场景测试一个队列
package org.easyb.bdd.specification.queue

import org.easyb.bdd.Queue

description "This is how a Queue must work"

before "initialize the queue for each spec", {
    queue = new Queue()
}

it "should dequeue item just enqueued", {
    queue.enqueue(2)
    queue.dequeue().shouldBe(2)
}

it "should throw an exception when null is enqueued", {
    ensureThrows(RuntimeException.class) {
        queue.enqueue(null)
    }
}

it "should dequeue items in same order enqueued", {
    [1..5].each {val ->
        queue.enqueue(val)
    }
    [1..5].each {val ->
        queue.dequeue().shouldBe(val)
    }
}

清单 8 中的代码为队列定义了适当的行为。每个声明块以 it 开始,后面是一个字符串描述和一个代码块。it 的方法定义看起来像这样,其中 spec 预计将描述测试,而 closure 保持代码块:

def it(spec, closure)

注意在 清单 8 最后一行,我验证了来自调用 dequeue() 的值,使用下面这行代码:

queue.dequeue().shouldBe(val)

但是 Queue 类的检查显示它没有一个 shouldBe()方法。那么它是从哪里来的呢?

如果您查看 it() 方法的定义,您将可以看到 categories 用在何处来扩展已有类。清单 9 显示 it() 方法的声明:

清单 9. it() 方法的声明
def it(spec, closure) {
    stepStack.startStep(listener, BehaviorStepType.IT, spec)
    closure.delegate = new EnsuringDelegate()
    try {
        if (beforeIt != null) {
            beforeIt()
        }
        listener.gotResult(new Result(Result.SUCCEEDED))
    use(BehaviorCategory) {
            closure()
        }
        if (afterIt != null) {
            afterIt()
        }
    } catch (Throwable ex) {
        listener.gotResult(new Result(ex))
    }
    stepStack.stopStep(listener)
}

大约一半的方法,作为参数传递的封闭块将在 BehaviorCategory 类中执行,清单 10 显示了其中的一部分:

清单 10. BehaviorCategory 类的一部分
static void shouldBe(Object self, value, String msg) {
    isEqual(self, value, msg)
}

private static void isEqual(self, value, String msg) {
    if (self.getClass() == NullObject.class) {
        if (value != null) {
            throwValidationException(
                "expected ${value.toString()} but target object is null", msg)
        }
    } else if (value.getClass() == String.class) {
        if (!value.toString().equals(self.toString())) {
            throwValidationException(
                "expected ${value.toString()} but was ${self.toString()}", msg)
        }
    } else {
        if (value != self) {
            throwValidationException("expected ${value} but was ${self}", msg)
        }
    }
}

BehaviorCategory 是一个 category ,其方法扩增了 Object,阐述了开放类令人难以置信的力量。向 Object 添加一个新方法,这使得向每个类(包括 Queue)添加一个 shouldBe() 方法变得十分容易。您不能使用核心 Java 代码进行这一操作,这样做太麻烦了,甚至没法入手。categories 的使用加强了我之前的建议:它将对 Object 的修改范围限制为 easyb DSL 中 use 子句。


结束语

我想让我捕获的惯用模式从其余的代码中脱颖而出,DSL 提供一个令人信服的机制来实现这一目标。使用支持它们的语言编写 DSL 非常容易,不像使用 Java 语言那样。如果您组织的外部力量妨碍您利用非 Java 语言的优势,不要放弃。像 Spring 框架这类语言越来越多地支持其他语言,比如 Groovy 或 Clojure(见 参考资料)。您可以使用这些语言来创建部件并让 Spring 将其注入到您应用程序的合适位置。许多组织在使用其他语言方面太过保守,但是通过 Spring 这类框架很容易增加线路。

下一期中,我将使用几个 JRuby 实例集中精力介绍使用 DSL 作为一种方法捕获领域惯用模式,阐述可以将语言的表现力呈现得多深入 。

参考资料

学习

  • The Productive Programmer(Neal Ford,O'Reilly Media,2008 年):Neal Ford 最近的一本书,进一步阐述了本系列中的许多主题。
  • 实战 Groovy:该系列 developerWorks 文章探究了 Groovy 的实际使用,帮助您学习何时、怎样成功地使用它们。
  • 精通 Grails:在 developerWorks 的该系列文章中深度挖掘 Grails。
  • 用 easyb 驱动开发”(Andrew Glover,developerWorks,2009 年 11 月):查看该教程了解 easyb 如何提高开发人员和利益干系人之间的交流。
  • Objective_C:这篇维基百科文章通过示例讨论了 categories 的起源。
  • developerWorks Java 技术专区:寻找关于 Java 编程各个方面的数百篇文章。

获得产品和技术

  • Groovy:Groovy 是一个动态的现代 Java 语言,被许多 Java 生态系统所支持。
  • Grails:Grails 是编写在 Groovy 之上的 web 框架,受 Ruby on Rails 激发。
  • easyb:easyb 是一个在 Groovy 中实现的行为驱动的开发测试工具,使用了这一期中涉及的许多技术。
  • Clojure:Clojure 是一个现代 Lisp 语言,重写为纯函数式语言,在 JVM 中运行。

讨论

条评论

developerWorks: 登录

标有星(*)号的字段是必填字段。


需要一个 IBM ID?
忘记 IBM ID?


忘记密码?
更改您的密码

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件

 


在您首次登录 developerWorks 时,会为您创建一份个人概要。您的个人概要中的信息(您的姓名、国家/地区,以及公司名称)是公开显示的,而且会随着您发布的任何内容一起显示,除非您选择隐藏您的公司名称。您可以随时更新您的 IBM 帐户。

所有提交的信息确保安全。

选择您的昵称



当您初次登录到 developerWorks 时,将会为您创建一份概要信息,您需要指定一个昵称。您的昵称将和您在 developerWorks 发布的内容显示在一起。

昵称长度在 3 至 31 个字符之间。 您的昵称在 developerWorks 社区中必须是唯一的,并且出于隐私保护的原因,不能是您的电子邮件地址。

标有星(*)号的字段是必填字段。

(昵称长度在 3 至 31 个字符之间)

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件.

 


所有提交的信息确保安全。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Java technology
ArticleID=548679
ArticleTitle=演化架构和紧急设计: 使用 Groovy 构建 DSL
publish-date=09302010