内容


演化架构和紧急设计

使用 JRuby 构建 DSL

通过在 Java 代码之上使用 JRuby 来利用 Ruby 的表现力

Comments

系列内容:

此内容是该系列 # 部分中的第 # 部分: 演化架构和紧急设计

敬请期待该系列的后续内容。

此内容是该系列的一部分:演化架构和紧急设计

敬请期待该系列的后续内容。

在前几期中,我通过使用域特定语言已经开始介绍域惯用模式 的收获(针对紧急业务问题的解决方案)。对于此任务来说,DSL 工作是良好的,因为它们很简洁(包含尽可能少的嘈杂语法)并可读(甚至非开发人员也可以阅读),且它们从更多的以 API 为中心的代码中脱颖而出。在 上一期 中,我已经展示了如何使用 Groovy 来建立 DSL,以便充分利用它的一些功能。在此部分,通过显示如何在 Ruby 中建立更复杂的 DSL,以及利用 JRuby,我将结束使用 DSL 来获取惯用模式的讨论。

Ruby 是当前用于建立内部 DSL 最流行的语言。当在 Ruby 上开发时您所考虑的大部分基础设施都是基于 DSL 的 — Ruby on Rails、RSpec、Cucumber、Rake 以及许多其他方面(请参考 参考资料)— 因为它是服从于主机托管内部 DSL 的。行为驱动开发 (BDD) 的新潮技术需要一个强大的 DSL 基础来实现其普及。本期将帮助您了解 Ruby 为何在 DSL 迷中如此流行。

Ruby 中的打开类

使用打开类将新方法添加到内置类是一种将表现力添加到 DSL 的常用技术。在 上一期 中,我展示了 Groovy 中打开类的两种不同的语法。在 Ruby 中,除了使用单一语法外,您拥有相同的机制。例如,要创建配方 DSL,您需要一种方法来捕获量。请考虑清单 1 中的 DSL 片段:

清单 1. 适用于我的基于 Ruby 的配方 DSL 的目标语法
recipe = Recipe.new "Spicy bread"
recipe.add 200.grams.of "flour"
recipe.add 1.lb.of "nutmeg"

要使此代码可执行,我必须通过打开 Numeric 类以便将 gramlb 方法添加到数字中,如清单 2 所示:

清单 2. Ruby 中的打开类定义
class Numeric
  def gram
    self
  end
  alias_method :grams, :gram

  def pound
    self * 453.59237
  end
  alias_method :pounds, :pound
  alias_method :lb, :pound
  alias_method :lbs, :pound

在 Ruby 中,类名称必须用大写字母开始,其也是 Ruby 常量的规则,这意味着每一个类名称也是一个常量。在 Ruby “看到” 类定义时,其会查看是否已经在其类路径上加载了此类。因为类名称是常量,所以您只能有一个给定名称的类。如果已经加载了类,则类定义会重新打开类以允许我进行变更。在 清单 2 中,我重新打开了 Numeric 类(其处理固定和浮点数字)以便添加 grampound 方法。与 Groovy 不同,Ruby 没有针对接收不到参数的方法必须与空括号一起调用的规则,这意味着 Ruby 无需区分属性和方法。

Ruby 还包括另外一个方便的 DSL 机制:alias_method 类方法。如果您想尽量提高您的 DSL 流畅性,则建议您应该处理类似多元化的案例。(如果您想看到着力实现这一结果的工作,请查看 Ruby on Rails 中用于处理复数模型类名的多元化代码。)在我清楚地添加超过一个 gram 时,我不想在我的 DSL 中语法化地形成像 recipe.add 2.gram.of("flour") 那样笨拙的句子。Ruby 中的 alias_method 机制使其更容易为方法创建备用名称以便增强可读性。为此,清单 2gram 添加了一个多元化的方法,并为 pound 添加了备用缩写和多元化版本。

建立流畅的界面

使用 DSL 来捕获惯用模式的目标之一是能够从抽象的编程语言版本中消除嘈杂的语法。请考虑清单 3 中嘈杂的配方 DSL 代码片段:

清单 3. 嘈杂的配方定义
recipe = Recipe.new "Spicy bread"
recipe.add 200.grams.of "flour"
recipe.add 1.lb.of "nutmeg"
recipe.directions << "mix ingredients"
recipe.directions << "cook for 30 minutes at 250 degrees"

虽然适用于添加配方成分和方向的 清单 3 中的语法相当简洁,但是通过托管主机变量名(recipe)体现出了嘈杂的重复。更清晰的版本如 清单 4 所示:

清单 4. 情景化配方定义
alternate_recipe = Recipe.new("Milky Gravy")
alternate_recipe.consists_of {
  add 1.lb.of "flour"
  add 200.grams.of "milk"
  add 1.gram.of "nutmeg"
  
  steps(
    "mix ingredients",
    "cook for some amount of time"
  )
}

对流畅界面添加 consists_of 方法允许我使用包容关系(在 Ruby 中体现使用花括号 ({}) 界定的封闭块)来消除嘈杂的主机托管对象重复。在 Ruby 中这种方法的实现很简单,如清单 5 所示:

清单 5. Recipe 类定义,包括 consists_of 方法
class Recipe
  attr_reader :ingredients
  attr_accessor :name
  attr_accessor :directions

  def initialize(name="")
    @ingredients = []
    @directions = []
    @name = name
  end

  def add ingredient
    @ingredients << ingredient
    return self
  end
  
  def steps *direction_list
    @directions = direction_list.collect
  end
  
  def consists_of &block
    instance_eval &block
  end
end

consists_of 方法接受代码块。(这是您在参数名称以前与符号一同看到的语法。该符号将参数识别为代码块的持有者。)使用 instance_eval 方法可使该方法执行代码块,这是 Ruby 中的内置方法之一。通过变更主机托管对象的定义 instance_eval 方法可执行传递给它的代码。换句话说,在您通过 instance_eval 执行代码时,您可以将 self(Java 语言 this 的 Ruby 版本)变更为名为 instance_eval 的变量。因此,如果您与 recipe.instance_eval 一起调用 addsteps 方法,则您可以在不使用 recipe 主机托管对象的情况下调用它们,这就是 consists_of 方法要做的。

经常阅读本系列的读者将认出这一来自 “利用可重用代码,第 2 部分” 的 Java 语法伪装的概念,如清单 6 所示:

清单 6. 使用实例初始值设定项在 Java 代码中流畅化代码块
MarketingDescription desc = new MarketingDescriptionImpl() {{
    setType("Box");
    setSubType("Insulated");
    setAttribute("length", "50.5");
    setAttribute("ladder", "yes");
    setAttribute("lining type", "cork");
}};

虽然语法大致类似,但是 Java 版本有两个严重的局限性。首先,它是 Java 语言中不寻常的语法。(大多数开发人员从来没有在日常的编码过程中遇到此种实例初始值设定项。)其次,因为其使用匿名的内部类(Java 中唯一的类似代码块的机制),任何来自外部范围的变量都必须被声明为 final,这使代码块内部功能受到严重限制。在 Ruby 中,instance_eval 方法是标准的(且常规的)语言功能,这意味着它更常用。

抛光

一种许多 DSL 都使用的常用技术(特别是针对非开发人员的技术)是利用口语。如果您的基础计算机语言足够灵活,则针对口语的模型计算机语法是有可能的。考虑到迄今为止我所创建的配方 DSL。创建一个完整的 DSL 只是为了保持简单的数据结构(如成分和方向的清单)好像有点大材小用了;为什么不干脆在标准的数据结构中保留此信息呢?通过在 DSL 中编码操作,除了填充数据结构外我还可以采取额外的行动(如有益的副作用)。例如,也许我想为每种成分都捕获营养信息就如同我在 DSL 中定义的那样,所以在我完成此项操作时会允许我提供配方的总体营养价值。NutritionProfile 类是一个简单的数据持有者,如清单 7 所示:

清单 7. 配方营养记录
class NutritionProfile
  attr_accessor :name, :protein, :lipid, :sugars, :calcium, :sodium

  def initialize(name, protein=0, lipid=0, sugars=0, calcium=0, sodium=0)
    @name = name
    @protein, @lipid, @sugars =  protein, lipid, sugars
    @calcium, @sodium = calcium, sodium
  end
  
  def self.create_from_hash(name, h)
    new(name, h['protein'], h['lipid'], h['sugars'], h['calcium'], h['sodium'])
  end

  def to_s()
    "\tProtein: " +   @protein.to_s       +
    "\n\tLipid: " +   @lipid.to_s         +
    "\n\tSugars: " +  @sugars.to_s        +
    "\n\tCalcium: " + @calcium.to_s       +
    "\n\tSodium: " +  @sodium.to_s
  end

end

要填充这些营养记录的数据库,我创建了一个在每一行都包含一个记录的文本文件,即:

ingredient "flour" has protein=11.5, lipid=1.45, sugars=1.12, calcium=20, and sodium=0

正如您猜到的,此定义文件的每一行都是一个基于 Ruby 的 DSL。不要只将它的语法视为一行文本,而是要从计算机语言的角度上考虑它看起来像什么,如图 1 所示。

作为方法调用的成分文本定义
作为方法调用的成分文本定义
作为方法调用的成分文本定义

每一行都以 ingredient 开始,即方法名称。第一个参数是成分的名称。单词 has 被称为泡沫字— 即此单词使 DSL 更可读但是这无助于最终定义。剩余的行包含名称/值对,以逗号进行分隔。鉴于这并不是合法的 Ruby 语法,我要如何将其翻译为 Ruby 呢?此项工作即被称为抛光:采用几乎合法的语法并将其抛光为实际语法。抛光 DSL 的工作是通过 NutritionProfileDefinition 类处理的,如清单 8 所示:

清单 8. NutritionProfileDefinition
class NutritionProfileDefinition
  
  def polish_text(definition_line)
    polished_text = definition_line.clone
    polished_text.gsub!(/=/, '=>')
    polished_text.sub!(/and /, '')
    polished_text.sub!(/has /, ',')
    polished_text
  end

  def process_definition(definition)
    instance_eval polish_text(definition)
  end

  def ingredient(name, ingredients)
    NutritionProfile.create_from_hash name, ingredients
  end    
   
end

此类的入口点是 process_definition 方法,如清单 9 所示:

清单 9. process_definition 方法
def process_definition(definition)
  instance_eval polish_text(definition)
end

使用 instance_eval,此方法调用 polish_text ,将 polish_text 的执行上下文切换为 NutritionProfileDefinition 实例。 清单 10 所示的 polish_text 方法进行了必要的替换和翻译,以便将近似的代码转换成代码:

清单 10. polish_text 方法
def polish_text(definition_line)
  polished_text = definition_line.clone
  polished_text.gsub!(/=/, '=>')
  polished_text.sub!(/and /, '')
  polished_text.sub!(/has /, ',')
  polished_text
end

polish_text 方法包含简单的字符串替换以便将定义语法转换成 Ruby 语法,将等号转换为哈希标识符 (=>),删除多余单词 and,并将 has 转换为逗号。此已抛光的代码行被传递给 instance_eval,并通过 NutritionProfileDefinition 类的 ingredient 方法来执行。

虽然您可以用 Java 语言编写此代码,但是 Java 的语法限制将会造成如此多的干扰,这将使您失去流畅化界面的优势,导致呈现出讨论会的情况。Ruby 提供足够的语法优势以便使其可执行(并可取)投放抽象概念作为 DSL。

方法缺失

与前面的示例不同,即使通过繁琐的语法,下一个示例也无法用 Java 代码完成。在诸多语言中一种常用于托管 DSL 的方便机制是方法缺失。在您调用不存在于 Ruby 中的方法时,它不会立即产生异常。您有机会将 method_missing 方法添加到将处理任何方法缺失调用的类中。这在 DSL 中被大量使用以建立内部数据结构。研究下面这个来自 Ruby 中的 XMLBuilder 的示例(请参考 参考资料),如清单 11 所示:

清单 11. 在 Ruby 中使用 XMLBuilder
xml = Builder::XmlMarkup.new(:indent => 2)
xml.person {
  xml.name("Neo")
  xml.catch_phrase("Whoa")
}
puts xml.target!

通过 DSL 所示的结构,此代码输出一个 XML 文档。通过 method_missing,Builder 实现了自己的魔力。当您在 xml 变量上调用方法时,该方法尚不存在,因此它属于 method_missing,其构建了相应的 XML。这使得该代码对于 Builder 库来说非常小;其大多数机制都依赖于 Ruby 的底层语言特性。然而,这种方法还有一个问题,如清单 12 所示:

清单 12. 方法缺失与内置方法的冲突
xml = Builder::XmlMarkup.new(:indent => 2)
xml.person {
  xml.name("Neo")
  xml.catch_phrase("Whoa")
  xml.class("pod-born")
}
puts xml.target!

如果您只依赖 method_missing,则 清单 12 中的代码将不会产生作用,因为 class 方法已经在 Ruby 中被定义为 Object 的一部分,(与 Java 语言一样)它是所有类的基础类。显然,method_missing 不会与现有的方法一起工作,这似乎注定了这种方法的命运。然而,Jim Weirich(Builder 的创建者)想出了一个优雅的解决方案:他创建了 BlankSlate。虽然 BlankSlate 是从 Object 继承而来的类,但以编程方式删除了通常在 Object 中发现的所有方法。这样就可以在没有任何烦人的副作用的情况下利用 method_missing 基础结构了。

这个 BlankSlate 机制非常强大和有用,正在被内置到 Ruby 的下一个主要版本中。在 Ruby 1.9 中,SimpleObject 成为对象层级的顶端,Object 作为它的直接后代。拥有 SimpleObject 使构建 Builder DSL 更加容易,因为您将不再需要 BlankSlate

创建像 Builder 那样的 DSL 的能力说明了为什么语言表现力和语言力量如此重要。Ruby Builder 中的代码数量比源自其他语言的类似库更小,因为它是在更灵活的设计中介(即 Ruby)上编写的。

结束语

自从本系列开始以来,我一直在从事软件系统的设计包括其完整源代码的工作,这意味着如果使用更富表现力的语言,您将会有一个更广泛的设计项目。此应用不仅适用于您选择的通用语言(Java、Ruby、Groovy、Clojure),而且也适用于使用 DSL 在基础语言上编写的语言。构建准确表现您业务理念的语言成为您组织的宝贵资产:您正在高度适用于目的的语言中捕捉解决真实问题的重要方法。

对于大多数开发来说,即便您的组织不将语言切换为 Ruby 或 Groovy,您也可以通过使用在它们中间已经实现的工具 “潜入” 这些语言,如 RSpec 和 easyb (请参考 参考资料)。通过偷偷引入这些替代语言,您可以帮助那些对引入新语言毫无戒心的人们了解这些语言所提供的重要优势。


相关主题

  • The Productive Programmer(Neal Ford,O'Reilly Media,2008 年):Neal Ford 的新书扩展了本系列中的一些主题。
  • JRuby:在任何平台上,JRuby 都是 Ruby 的最佳实现之一。
  • Ruby on Rails:Rails 是流行的 web 开发平台,其使用了许多 DSL。
  • RSpec:RSpec 是用使用了 DSL 技术的 Ruby 编写的 BDD 测试框架。
  • Rake:Rake 是 Ruby 平台的构建工具。
  • Cucumber:Cucumber 是一个强大的 BDD 测试工具,其在 Ruby 中演示了许多强大的 DSL 技术。
  • easyb:easyb 是一个适用于 Java 平台的基于 Groovy 的 BDD 框架。
  • Builder:Builder 是一个 Ruby 库,它使以编程方式生成 XML 文档更加轻松。
  • developerWorks Java 技术专区:这里有数百篇关于 Java 编程各个方面的文章。

评论

添加或订阅评论,请先登录注册

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