(下)高级元编程指南:创建一个Ruby DSL


原文请见Creating a Ruby DSL: A Guide to Advanced Metaprogramming

精简语法

当前的实现还有最后一个地方让人揪心——在配置代码块里不得不重复配置每一行。恰如其分的DSL都应知道,配置代码块里的所有东西将在配置对象的上下文内执行,并且这样可以实现同样的效果:

MyApp.configure do  
  app_id "my_app"
  title "My App"
  cookie_name "my_app_session"
end  

来,撸起袖子继续加油干!从表面上看,需要做两件事。首先,需要一种方式来执行在配置对象上下文内传递给configure的代码块,以便在代码块的方法调用作用于那个对象。其次,要修改访问器方法,以便做到有一个参数时就写入该值、没有参数时就读取。一个可能的实现是:

module Configurable  
  def self.with(*attrs)
    not_provided = Object.new

    config_class = Class.new do
      attrs.each do |attr|
        define_method attr do |value = not_provided|
          if value === not_provided
            instance_variable_get("@#{attr}")
          else
            instance_variable_set("@#{attr}", value)
          end
        end
      end

      attr_writer *args
    end

    class_methods = Module.new do
      # ...

      def configure(&block)
        config.instance_eval(&block)
      end
    end

    # 创建并返回新的模块
    # ...
  end
end  

这里的修改很简单,主要是改为在配置对象的上下文内执行configure代码块。在对象上调用Ruby的instance_eval方法可以执行任意代码块,就好像它是在这个对象内执行一样,这意味着,当配置对象调用第一行的app_id方法时,此调用将会作用于配置类的实例上。

对于在config_class内属性访问器方法的修改则有些许复杂。为了理解它,首先需要知道attr_accessor在背后具体做了什么。以下面的attr_accessor调用为例:

class SomeClass  
  attr_accessor :foo, :bar
end  

这相当球迷每一个指定的属性定义一个写入和读取的方法:

class SomeClass  
  def foo
    @foo
  end

  def foo=(value)
    @foo = value
  end

  # bar亦如此
end  

所以当我们在源代码里编写attr_accessor *attrs时,Ruby实际上为attrs的每个属性定义了读取和写入的方法——那就是说,我们获得了以下标准访问器方法:app_idapp_id=titletitle=等。在新的版本中,我们想保留标准写入方法以便像这样的赋值仍能正常工作:

MyApp.config.app_id = "not_my_app"  
=> "not_my_app"

通过调用attr_writer *attrs可以保留自动自成的写入方法。然而,不能再使用标准读取方法,因为需要兼容属性写入以支持这种新的语法:

MyApp.configure do  
  app_id "my_app" # 赋予一个新值
  app_id          # 读取已存储的值
end  

为了生成自己的读取方法,我们循环了attrs数组,并且为每一个属性定义了一个方法,如果未提供新的值此方法就返回匹配的实例变量,如果指定新的值则写入:

not_provided = Object.new  
# ...
attrs.each do |attr|  
  define_method attr do |value = not_provided|
    if value === not_provided
      instance_variable_get("@#{attr}")
    else
      instance_variable_set("@#{attr}", value)
    end
  end
end  

在这里,我们用到了通过属性名称读取实例变量的instance_variable_get方法,以及赋予一个新值的instance_variable_set方法。不幸的是,在这两者中变量名都必须使用“@”符号作为前缀——即字符串插值。

你可能会疑惑,为什么对于“未提供”的默认值使用了一个空对象,而不是简单地使用nil呢?原因很简单——nil是一个有效值,可能会有人把它赋给某个配置属性。测试nil时,将无法区分这两种情况:

MyApp.configure do  
  app_id nil # 期望:设为nil
  app_id     # 期望:返回当前的值
end  

存放在not_provided的空对象只会等于它本身,这样的话就可以确保不会有人把它传给我们的方法而触发非期望的读取操作(实际上应该是写入操作)。

添加对引用的支持

还可以再添加多一个特性,让我们的模块变得更多才多艺——引用另一个配置属性的能力:

SomeClass.configure do  
  foo "#{bar}_baz"     # 表述式在这就被执行了
  bar "hello"
end

SomeClass.config.foo  
=> "_baz"              # 一点都不好玩,结果错误了

如果表达式包装在代码块里,则可以防止它被立即执行。因此,可以保存此代码块以便属性值可获得时再来执行:

SomeClass.configure do  
  foo { "#{bar}_baz" }  # 存放在代码块,尚未执行
  bar "hello"
end

SomeClass.config.foo    # foo在这执行  
=> "hello_baz"          # 正确了!

为了添加支持使用代码块进行延迟执行,不需要对Configurable模块做太多改动。实际上,只需修改属性方法的定义即可:

define_method attr do |value = not_provided, &block|  
  if value === not_provided && block.nil?
    result = instance_variable_get("@#{attr}")
    result.is_a?(Proc) ? instance_eval(&result) : result
  else
    instance_variable_set("@#{attr}", block || value)
  end
end  

当进行属性设置时,如果有代码块传入,表达式block || value就会保存block,否则保存value。随后,当属性被读取时,如果检测到它是代码块就使用instance_eval来执行它,否则就像之前那样直接返回。

当然,支持引用就像一把双刃剑。例如,如果在下面配置中这样读取属性,你很可能已经知道会出现什么问题了。

SomeClass.configure do  
  foo { bar }
  bar { foo }
end  

实现的模块

最后,我们得到了一个相当简洁的模块,它可以生成任意类配置,然后使用干净、简单的DSl指定配置值,还能引用其他配置属性:

class MyApp  
  include Configurable.with(:app_id, :title, :cookie_name)

  # ...
end

SomeClass.configure do  
  app_id "my_app"
  title "My App"
  cookie_name { "#{app_id}_session" }
end  

以下是实现了我们DSL的模块的最终版本——总共36行代码:

module Configurable  
  def self.with(*attrs)
    not_provided = Object.new

    config_class = Class.new do
      attrs.each do |attr|
        define_method attr do |value = not_provided, &block|
          if value === not_provided && block.nil?
            result = instance_variable_get("@#{attr}")
            result.is_a?(Proc) ? instance_eval(&result) : result
          else
            instance_variable_set("@#{attr}", block || value)
          end
        end
      end

      attr_writer *attrs
    end

    class_methods = Module.new do
      define_method :config do
        @config ||= config_class.new
      end

      def configure(&block)
        config.instance_eval(&block)
      end
    end

    Module.new do
      singleton_class.send :define_method, :included do |host_class|
        host_class.extend class_methods
      end
    end
  end
end  

看一下这段神奇的Ruby代码,它几乎是难以理解的,因此也就难以维护,你可能会怀疑为了使领域特定语言稍微更友好一点,真的值得付出那么多努力吗?简短的回答是:视情况而定——这也就引出了这篇文章最后的话题。

Ruby DSL——用与不用

在阅读此DSL的实现步骤过程中,你可能已经注意到,为了让此语言的外在语法更为简洁、更为易用,我们需要用到比以往还要多得多的元编程技巧。这导致了一个难以理解,在未来难以修改的实现。正如在软件开发中其他很多东西一样,这也是必须仔细衡量的交易。

对于领域特定语言来说,要想对得住它的实现成本和维护成本,它必须能带来更大的价值。这通常可通过使此语言尽可能在其他不同场景中重用来获得,从而将成本在众多不同的使用场景中摊销。框架和类库很可能会包含它们自己确切的DSL并被众多开发者所使用,而每个开发者都能享受这些嵌入式语言创造性的好处。

所以,作为经验法则,如果你,其他开发人员或者应用程序的终端用户会频繁使用这些DSL的话,就构建它。如果真的要创建一个DSL,确保拥有全面的测试套件,以及对语法进行正确解释的文档,因为要想单纯从实现洞悉语法是非常困难。日后的你和开发使用者都会为此而感谢你的。

dogstar

一位喜欢翻译的开发人员,艾翻译创始人之一。

广州