演化架构和紧急设计: 使用 JRuby 构建 DSL
通过在 Java 代码之上使用 JRuby 来利用 Ruby 的表现力
在前几期中,我通过使用域特定语言已经开始介绍域惯用模式 的收获(针对紧急业务问题的解决方案)。对于此任务来说,DSL 工作是良好的,因为它们很简洁(包含尽可能少的嘈杂语法)并可读(甚至非开发人员也可以阅读),且它们从更多的以 API 为中心的代码中脱颖而出。在 上一期 中,我已经展示了如何使用 Groovy 来建立 DSL,以便充分利用它的一些功能。在此部分,通过显示如何在 Ruby 中建立更复杂的 DSL,以及利用 JRuby,我将结束使用 DSL 来获取惯用模式的讨论。
Ruby 是当前用于建立内部 DSL 最流行的语言。当在 Ruby 上开发时您所考虑的大部分基础设施都是基于 DSL 的 — Ruby on Rails、RSpec、Cucumber、Rake 以及许多其他方面(请参考 参考资料)— 因为它是服从于主机托管内部 DSL 的。行为驱动开发 (BDD) 的新潮技术需要一个强大的 DSL 基础来实现其普及。本期将帮助您了解 Ruby 为何在 DSL 迷中如此流行。
使用打开类将新方法添加到内置类是一种将表现力添加到 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
类以便将gram
和lb
方法添加到数字中,如清单 2 所示:
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
类(其处理固定和浮点数字)以便添加gram
和pound
方法。与 Groovy 不同,Ruby 没有针对接收不到参数的方法必须与空括号一起调用的规则,这意味着 Ruby 无需区分属性和方法。Ruby 还包括另外一个方便的 DSL 机制:
alias_method
类方法。如果您想尽量提高您的 DSL 流畅性,则建议您应该处理类似多元化的案例。(如果您想看到着力实现这一结果的工作,请查看 Ruby on Rails 中用于处理复数模型类名的多元化代码。)在我清楚地添加超过一个 gram 时,我不想在我的 DSL 中语法化地形成像recipe.add 2.gram.of("flour")
那样笨拙的句子。Ruby 中的alias_method
机制使其更容易为方法创建备用名称以便增强可读性。为此,清单 2 为gram
添加了一个多元化的方法,并为pound
添加了备用缩写和多元化版本。使用 DSL 来捕获惯用模式的目标之一是能够从抽象的编程语言版本中消除嘈杂的语法。请考虑清单 3 中嘈杂的配方 DSL 代码片段:
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 所示:
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
一起调用add
和steps
方法,则您可以在不使用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 所示:
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 所示:
def process_definition(definition) instance_eval polish_text(definition) end使用
instance_eval
,此方法调用polish_text
,将polish_text
的执行上下文切换为NutritionProfileDefinition
实例。 清单 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 所示:
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 所示:
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 编程各个方面的文章。
讨论
- 加入 developerWorks 社区。查看开发人员推动的博客、论坛、组和 wikis,并与其他 developerWorks 用户交流。
Neal Ford 是一家全球性 IT 咨询公司 ThoughtWorks 的软件架构师和 Meme Wrangler。他的工作还包括设计和开发应用程序、教材、杂志文章、课件和视频/DVD 演示,而且他是各种技术书籍的作者或编辑,包括最近的新书
The Productive Programmer
。他主要的工作重心是设计和构建大型企业应用程序。他还是全球开发人员会议上的国际知名演说家。请访问他的 Web 站点。