跨越边界: 活动记录和 Java 编程中特定于域的语言

Bruce Tate, 总裁, J2Life, LLC

2006 年 4 月 04 日

DSL 是专门解决特定于域问题的语言。通过更接近问题的操作,DSL 可以提供在通用语言中可能找不到的好处。Java 世界中充满了 DSL。属性文件、Spring 上下文、标注的某种用法以及 Ant 任务,都是 DSL 的示例。

在开始研究其他像 Ruby 这样的语言的时候,我开始理解到 Java 语言目前对于 DSL 还没有良好的把握。在这篇文章中,将看到 Ruby 使用的四种集成干净的 DSL 的技巧。然后,将看到在 Java 语言中可能存在的选项是什么。

关于这个系列

跨越边界 系列中,作者 Bruce Tate 提出了这样一个主张:今天的 Java 程序员通过学习其他技术和语言,会得到很好的帮助。Java 技术是所有开发项目的明显最好选择的情况,在编程领域中已经发生了变化。其他框架正在影响 Java 框架构建的方式,而从其他语言学到的概念也有助于 Java 编程。对 Python(或 Ruby、Smalltalk 等等)代码的编写,可能改变 Java 编码的方式。

这个系列介绍的编程概念和技术,与 Java 开发有根本的不同,但却可以直接适用于 Java 编程。在某些情况下,需要整合这些技术来利用它们。在其他情况下,可以直接应用这些概念。比起其他语言和框架能够影响 Java 社区的开发人员、框架甚至基本方式的理念来说,单独的工具并不那么重要。

隐藏语言的世界

虽然您可能不知道,但实际上您无处不遇到 DSL,从日常生活到使用的应用程序,到您编写的程序。在法庭上,可以看到速记员用 DSL 迅速地进行记录。音乐使用几种不同的标注来描述音量、音调和每个音的时长,采用一种适合特定乐器的格式。(我使用吉它六线谱,里面每条线都代表吉它上的一根弦。)使用 DSL 是因为它们比口述或笔录更能有效地解决问题。

在使用日常的应用程序时,也在使用 DSL。最好的示例是电子表格。编写电子表格,要比使用最简单的会计程序还要容易。电子表格的 DSL 从根本上改变了为特定问题进行编程的实质。

Java 编程中的 DSL

回头来看,Java 也在到处使用 DSL:

  • JSP 使得构建定制的用户界面更容易。
  • SQL 代表数据库操作。
  • 属性文件代表程序的配置。
  • XML 描述数据。
  • XML 描述程序配置,例如在 EJB、Hibernate 或 Spring 中。
  • XML 描述动作,例如 Ant 任务或某种引擎中的业务规则。

Java 语言并不特别擅长特定于域的语言,因为这个语言很难按照对 DSL 开发人员来说最有吸引力的方式进行扩展。这就是为什么 XML 这么泛滥的一个原因。XML 是可扩展的,Java 和它的集成很好,可以容易地构建解释它的工具,而且它也不需要和 Java 类一起编译。但是 XML 对于人类阅读来说很不友好。所以,可以看到对于在 Java 语言中 XML 的过度使用有广泛的抱怨。

使用 Ruby 和活动记录的 DSL

跨越边界 系列的 第一篇文章 中,您看到了活动记录(Ruby on Rails 背后的持久化引擎)。在这篇文章中,我又回到活动记录,因为它在多个地方对 DSL 概念进行了精彩的应用:

  • 特定于域的语句结构和词汇表。 活动记录构建了一个用 Ruby 对象包装关系数据库的词汇表。例如,在数据库支持的对象中,可以用 has_many :people 来构建与另一个数据库支持的对象的一对多关系映射。

  • 扩展类的行为。 根据命名规范,声明叫作 People 的活动记录类,就会拥有与数据库中每个列对应的属性。

  • 修饰现有类型。 Rails 通常修饰 Fixnum 这样的类以提供对域友好的体验。

  • 动态地扩展词汇表。 活动记录提供了一些惊喜,例如根据数据库的结构添加定制查找器。

  • 英语建模。 活动记录根据上下文修改类的复数形式。

随着继续阅读本文,将看到让这些技巧成为可能的 Ruby 特性。您将真正体会到在 Ruby 和 Java 操作方式之间的区别。要跟随本文一起编写代码,需要安装 Ruby 和 Ruby on Rails,其中包含了活动记录(请参阅 参考资料)。

Ruby 中的词汇表

Ruby 语法开放的结构和符号的包含,使得定义词汇相当容易。可以使用方法、符号和类来形成词汇。请输入 irb 来启动 Ruby 解释器。输入清单 1 中的代码。(清单 1 显示了输入的内容和 Ruby 中的结果。只需要输入黑体的代码。)


清单 1. 创建 Ruby 类


irb(main):001:0> class Person
irb(main):002:1> attr_accessor :name, :email
irb(main):003:1> end
=> nil
irb(main):004:0> person = Person.new
=> #<Person:0x2b61a80>
irb(main):005:0> person.name = "Elvis"
=> "Elvis"
irb(main):006:0>

在清单 1 中,创建了叫作 Person 的类,它有两个实例变量分别叫作 nameemail。请特别注意 attr_accessor :name, :email 这一行。有两个概念应当引起注意:

  • 类定义中的方法调用
  • 符号的使用

方法调用

清单 1 中的 attr_accessor :name, :email 语句创建两个属性,分别带有 getter 和 setter 存取器。attr 实际上是个方法调用 —— 是 Ruby 语言本身元编程的精彩示例。Java 开发人员习惯于在类体中看到方法声明,而不习惯看到方法调用。这个方法调用把方法和实例变量添加到 Person 类中。

如果没有 attr_accessor :name, :email,就必须为每个需要的属性输入清单 2 的代码:


清单 2. Ruby 存取器


def name=(value)
 @name = value
end

def name
 return @name
end

清单 2 —— Ruby 版的 getter 和 setter —— 看起来应当有点儿熟悉。name= 实际上是个方法名称,而 @ 加在所有实例变量前作为前缀,但剩下的就与 Java 的 getter 和 setter 很类似了。

如果不用清单 2 中的代码,也可以用 @attr 的另一个版本来创建带有 getter、setter 或两者都有的属性。

符号

第二个值得注意的概念是符号。可以把 :email 当成名为 email 的东西。Ruby 符号像字符串,但是是不可修改的字符串,而且只有一个实例。只能使用一个 :email 符号。

现在看起来像下面这样的活动记录代码应当让您有点儿感觉了:


class Manager < ActiveRecord::Base
 has_one :department
end

has_one 是个方法,:department 是个符号,活动记录只是把它解释成类的名称。因为 Ruby 并不强制要求在方法参数两边使用括号,还因为 Rails 可以使用专门为活动记录设计的符号和方法名称,所以这个词汇畅通无阻。

可选的扩展

活动记录充分利用了 Ruby 的另一个特性。会经常看到带有可选参数的 Ruby 方法,可选参数是一个默认为空的哈希 map。可以用这种方式模拟命名参数。例如,活动记录方法 belongs_to 的定义看起来像这样:


def belongs_to(association_id, options = {})

现在可以把选项传递给 belongs_to 来优化它的行为:


class Manager < ActiveRecord::Base
 has_one :department, :foreign_key => "department_number"
end

在 Ruby 中,用 key => value 指定哈希 map 的条目。意思很清楚:想让活动记录覆盖默认值(department_id,根据命名规范)而采用 department_number。因为可以修剪选项的名称来满足语法的要求,所以 DSL 就得到了另一个强大的特性:可选的扩展。下面需要的能力是用自己的词汇来扩展 Ruby 语言。

修饰现有类型

Ruby 是种动态语言,所以向现有类(甚至指定类的实例)添加行为很容易。现在先使用这项技术来针对某个域修饰现有类,然后再根据词汇扩展现有类。

罗马数字的使用不太频繁,但是在某些上下文中会有用。我们并不想直接把罗马数字添加到 Ruby 的 Fixnum 基类,但是它们对于特定于域的语言可能是有用的。可以把 to_roman 方法添加到 Fixnum 类,这个方法把 fixnum 转换成罗马数字。这件事做起来极为容易。只要再次打开类定义,并定义新方法即可。清单 3 显示了一个粗糙的罗马数字处理方法:


清单 3. 罗马数字处理方法


class Fixnum
 def to_roman
  value = self
  str = ""
  (str << "C"; value = value - 100) while (value >= 100)
  (str << "XC"; value = value - 90) while (value >= 90)
  (str << "L"; value = value - 50) while (value >= 50)
  (str << "XL"; value = value - 40) while (value >= 40)
  (str << "X"; value = value - 10) while (value >= 10)
  (str << "IX"; value = value - 9) while (value >= 9)
  (str << "V"; value = value - 5) while (value >= 5)
  (str << "IV"; value = value - 4) while (value >= 4)
  (str << "I"; value = value - 1) while (value >= 1)
  str
 end
end

一旦理解了分号分隔了两个不同的 Ruby 语句,清单 3 就简单了。当我想让两个不同的想法挂在一起的时候,就经常用这种方式。可以用这项技术添加或修改任何 Ruby 类的定义。这一特殊实现的好处在于使用模型。可以把它粘贴到一个文件中,并在 Ruby 解释器中使用它,如清单 4 所示:


清单 4. 使用 to_roman 扩展


irb(main):001:0> load 'to_roman.rb'
=> true
irb(main):002:0> 10.to_roman
=> "X"
irb(main):003:0> 199.to_roman
=> "CXCIX"
irb(main):004:0>

Rails 利用这个能力处理像时间测量之类的事情。例如,在 Rails 应用程序中,可以说 10.days ,或 2.hours.ago,或 5.minutes.from_now。使用这个技术,可以把现有 Ruby 词汇扩展到自己的域中,处理类似测量、转换或其他语法组合的事情。最终结果是一个干净漂亮的 Ruby 核心类,带有一些扩展,提供特定于域的类,可以在域的上下文中做正确的事。

动态地构建类

在得到了词汇和扩展类的能力之后,下一步是根据词汇动态地 扩展类。在 清单 1 中的 attr 就是这种技术的示例。现在将介绍如何实现它(感谢 Glenn Vanderburg;请参阅 参考资料)。清单 5 显示了初步的尝试:


清单 5. 动态扩展类的初步尝试


class Person
 def my_attr
  self.class.class_eval "def name; @name; end"
  self.class.class_eval "def name=(val); @name = val; end"
 end
end
 

这个示例稍微复杂了一些。self.class 返回 Person 的类。然后 class_eval 在这个类的上下文环境下计算以下字符串。第一行定义 getter,第二行定义 setter。这个示例把 name 属性添加到 Person

清单 5 有两个主要问题。首先,需要显式地调用 my_attr。还不能从类中调用它,因为它还没有定义。其次,硬编码的 name 应当是个符号。第一个问题可以通过声明一个模块并从这个模块进行继承来解决。第二个问题可以通过传递进一个符号来解决。清单 6 显示了第二次尝试:


清单 6. 动态地扩展类的第二次尝试


class Module
 def my_attr(symbol)
  class_eval "def #{symbol}; @#{symbol}; end"
  class_eval "def #{symbol}=(value); @#{symbol} = value; end"
 end
end
 

清单 6 看起来有点儿神秘,但是不用担心。可以在一点儿帮助下理解这段代码。刚才只改变了三件事:

  • 没有声明新的 Person 类,而是打开了超类 —— Ruby 的 Class

  • 没有硬编码 name,而是传递进一个叫作 symbol 的参数。用 #{symbol} 代替了 name。Ruby 用代表符号的字符串替换 #{symbol}

  • class_eval 代替了 self.class.class_eval。代码已经在类中操作了,所以不需要得到 self.class

要查看它的工作,可以在 Ruby 解释器中输入清单 7 中黑体部分的代码:


清单 7. 定义定制属性


irb(main):001:0> require "my_attr.rb"
=> true
irb(main):002:0> class Person
irb(main):003:1> my_attr :name
irb(main):004:1> end
=> nil
irb(main):005:0> person = Person.new
=> #<Person:0x2b5fb90>
irb(main):006:0> person.name = "Bruce"
=> "Bruce"
irb(main):007:0> person.name
=> "Bruce"
 

正如所期望的,可以把行为添加到任何现有类。现在看到了怎样才能把行为绑定到可以添加到类的附加功能上。这项技术就是活动目录添加高级概念(例如 belongs_tohas_many)的方式。但是活动记录没有把行为添加到类,而是添加到叫作 ActiveRecord::Base 的模块。

现在已经看到了一些相当精密的功能的作用,但是 Ruby 还能做更多支持 DSL 的事。

method_missing 和动态行为

有时,想根据外部情况把方法添加到类。例如,假设想在 Ruby 中表示罗马数字。要把它们与字符串分开,可以用 Roman.III 的形式把数字 3 表示成罗马数字。要为每个可能的罗马数字都向 Roman 添加类方法,是不现实的,而且使用 Ruby 时也不需要这么做。可以利用一个小技巧。

在 Ruby 中,在遗漏了一个方法时,Ruby 就会调用 method_missing 方法。可以覆盖它来提供罗马数字,如清单 8 所示:


清单 8. 覆盖 method_missing 方法


class Roman
 def self.method_missing name, *args
  roman = name.to_s
  if(roman =~ /^[IVXLC]*$/)
   roman.gsub!("IV", "IIII")
   roman.gsub!("IX", "VIIII")
   roman.gsub!("XL", "XXXX")
   roman.gsub!("XC", "LXXXX")
   return(roman.count("I") +
       roman.count("V") * 5 +
       roman.count("X") * 10 +
       roman.count("L") * 50 +
       roman.count("C") * 100)
  else
   super(name, *args)
  end
 end
end

这个代码相当简单,但是确实使用了 Java 程序员不熟悉的一些 Ruby 特性。由于覆盖了 method_missing,所以只要这个类的客户调用一个不存在的方法,Ruby 就会调用这个方法。下面说明细节:

  1. 使用两个参数:
    • name 代表方法名
    • *args 代表遗漏方法的参数
  2. name 是个符号,所以首先用 to_s 把它转换成 String

  3. 用正则表达式进行数字是否罗马数字的合理猜测。

  4. 如果数字是罗马数字,就进行一系列替换,让罗马数字更容易处理。IV 是 4 ,IX 是 9,所以只计算 XVI 的出现,还不能得到它们的值。

  5. 为罗马字母的每次出现分配一个值,分别是:I(1)、V(5)、X(10)、 L(50)或 C(100)。

  6. 如果方法不是罗马数字,就调用超类,超类报告方法遗失。

对于 DSL,这个技术极为强大。活动记录使用这个功能实现动态查找器。活动记录没有为每个列实际地添加查找器,而是使用了 method_missing。使用这个策略,活动记录不仅能匹配一个列,还能匹配列的组合。例如,把 nameemail 列添加到 people 表,可以支持 Person 类的 People.find_by_name_and_email 查找器。像这样的细节使得活动记录的用户体验非常舒服。它们也让活动记录的实现非常简洁而有意义,所以在活动记录做的工作不符合自己的要求时,随时可以实现自己的补丁。

Java 编程中的 DSL 回顾

在使用 Java 语言时,选项就非常有限了。元编程更困难,所以很少能够得到活动记录那样的体验。但是如果真的急需 DSL,还是有些选项的。而且不用总是求助于 XML 或标注。下面是一些常用的方法:

  • 对于需求不太迫切的 DSL,可以使用 Java 类、方法和名称构建对英语友好的词汇,并通过消息调用做需要的事。

  • 对于典型的 Java 用户,可以用 XML 构建自己的语言。XML 难以阅读,但是在某些情况下可能有用,并在 Java 世界中相当普遍。

  • 对于已经要求 XML 的解决方案,可以使用 XML 的派生物来简化。Craig Walls 有一个贴子介绍了如何用 XBean 为 Spring 上下文做这件事(请参阅 参考资料)。

  • 可以使用 XML 的替代表示(例如 Relax NG)来简化 XML(请参阅 参考资料)。

  • 当 Java 代码和 XML 都不够用的时候,可以在 JVM 中嵌入一种语言。最好的方式是通过 BeanShell(请参阅 参考资料)。

  • 对于在 Java 应用程序中需要动态脚本的解决方案,可以利用已经有 BeanShell 集成的更加动态的语言。好的示例有 Jython、JRuby 和 Groovy(请参阅 参考资料)。

  • 可以从头开始构建 DSL。在 Java 语言中这很难做到,但是对于某些应用程序来说还是值得一做。

这些主意,每个都有一系列 developerWorks 文章,所以我在这里对它们就不做太多详细介绍了,但是有一点我要提一下。如果需要在 Java 语言中使用 DSL,需要问自己四个问题:

  • 真的需要 DSL 么?通过 Java 技术的一些更聪明的使用,可能可以做到自己需要的事。

  • XML 或 XML 的派生物足够吗?Java 开发人员对于 XML 经常有点儿太热心了,但是有些派生物可以把事情略微简化。

  • 可以在 Java 语言内部 使用其他语言吗?JRuby 正在越来越好,Groovy 正在就位,Jython 也正在变得更稳定。

  • 从头开始构建 DSL 值得吗?用 Java 语言做这件事很难 —— 需要词法器、解析器和语法器。但是可以做到,可能值得做,具体取决于应用程序。


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值