几天前 ,我开始介绍如何使用特定于域的语言来收集域惯用模式 (解决紧急业务问题的解决方案)。 DSL可以很好地工作,因为它们简洁明了(包含尽可能少的嘈杂语法)并且可读性强(即使是非开发人员也可以理解),并且在以API为中心的代码中脱颖而出。 在上一部分中 ,我展示了如何利用Groovy的一些功能来构建DSL。 在本期中,我将通过展示如何利用JRuby在Ruby中构建更复杂的DSL来结束有关使用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
类将gram
和lb
方法添加到数字中,如清单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
类(它处理定点和浮点数)以添加gram
和pound
方法。 与Groovy不同,Ruby没有规则,即必须使用空括号调用不接受任何参数的方法,这意味着Ruby不需要区分属性和方法。
Ruby还包括另一种便捷的DSL机制: alias_method
类方法。 您希望尽可能提高DSL的流畅性,建议您应该处理多种情况。 (如果recipe.add 2.gram.of("flour")
精力来实现这个结果,请查看Ruby on Rails中的复数代码以处理复数模型类名称。)我不想形成语法笨拙的句子,例如recipe.add 2.gram.of("flour")
当我明显要添加一克以上时,在DSL中添加recipe.add 2.gram.of("flour")
。 Ruby中的alias_method
机制使创建方法的备用名称变得容易,以增强可读性。 为此, 清单2添加了gram
的复数方法,并替换了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"
)
}
在Fluent接口中添加了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
的变量。 因此,你可以调用add
和steps
方法不使用recipe
,如果你给他们打电话与主机对象recipe.instance_eval
,这是什么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中对操作进行编码,除了填充数据结构外,我还可以采取其他措施(例如有益的副作用)。 例如,也许我想捕获我在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所示。
成分文本定义作为方法调用
![成分文本定义作为方法调用](https://i-blog.csdnimg.cn/blog_migrate/8631916a62cc4c287c1fa98c5e2b3cda.png)
每行都以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
此方法调用polish_text
使用instance_eval
,开关的执行上下文polish_text
到NutritionProfileDefinition
实例。 清单10中所示的polish_text
方法执行必要的替换和转换,以将几乎代码转换为代码:
清单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文档。 Builder通过method_missing
发挥其魔力。 当您在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
一部分,而Object
是Java类的基类。 显然, method_missing
不适用于现有方法。 这似乎注定了这种方法。 但是,Jim Weirich(Builder的创建者)提出了一个优雅的解决方案:他创建了BlankSlate
。 BlankSlate
是一个继承自Object
的类,但以编程方式删除了通常在Object
找到的所有方法。 这使他可以利用method_missing
基础结构而不会产生任何烦人的副作用。
BlankSlate
机制是如此强大和有用,以至于它被内置到了下一个主要的Ruby版本中。 在Ruby 1.9中, SimpleObject
成为对象层次结构的最顶层,而Object
是其直接后代。 使用SimpleObject
使构建构建器DSL更容易,因为您将不再需要BlankSlate
。
创建像Builder这样的DSL的能力说明了为什么语言的表现力和功能如此重要。 Ruby的Builder中的代码量比其他语言的相似库要少得多,因为它是在更灵活的设计媒介Ruby上编写的。
结论
自本系列文章开始以来,我一直在论证软件系统的设计包含其完整的源代码,这意味着如果您使用更具表现力的语言,则您将拥有更广泛的设计调色板。 这不仅适用于您选择的通用语言(Java,Ruby,Groovy,Clojure),而且还适用于您可以使用DSL在基础语言之上编写的语言。 建立一种能够准确表达您的业务概念的语言,对于您的组织而言将成为宝贵的资产:您正在捕获使用一种非常适合该目的的语言来解决实际问题的重要方法。
即使您的组织将不会切换到像Ruby或Groovy语言对于大多数的开发,可以将这些语言通过使用它们实现的工具,如RSpec的和easyb中(参见“瞒天过海” 相关主题 )。 通过将这些备用语言引入后门,您可以帮助那些对引入新语言毫无戒心的人理解它们可以带来巨大的好处。
翻译自: https://www.ibm.com/developerworks/java/library/j-eaed16/index.html