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


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

领域特定语言(DSL)是一个强大到令人难以置信的工具,因为它把编写或者配置复杂的系统变得更为简单。同时它们无处不在——作为一名软件工程师,你很可能在日常事务中使用了多种不同的DSL。

在这篇文章,你将会学习到什么是领域特定语言,什么时候应该使用它们,以及如何使用Ruby的高级元编程技术创建你自己专属的DSL。

此文章基于Nikola Todorovic发布在Toptal博客上关于Ruby元编程的介绍,如果你是初次接触元编程,请先阅读一下那篇文章。

什么是领域特定语言?

对于DSL的普遍定义是,它们是某一特定应用域或用例的专门语言。这意味着,你只能在指定的方面使用DSL——它们不适合普遍目的的软件开发。如果这听起来有点虚,是因为——DSL本来就千奇百怪。以下是一些重要的分类:

  • 标记性语言,例如HTML和CSS设计用于描述类似结构、内容、网站页面风格这些指定的事物。标记性语言不能用于编写任何算法,所以它们适合DSL的描述。

  • 在某个特定系统或者另一门编程语言之上的微型和查询语言(例如SQL),并且通常受限于他们能做什么。所以它们明显被界定为领域特定语言。

  • 很多DSL没有自己的语法——相反,它们通过一种机智的方式使用了某一公认编程语言的语法,这感觉就像正在使用的是另一门迷你语言。

最后这种分类称为内部DSL,它也是我们即将作为示例而创建的DSL之一。但在开始之前,来看一下一些内部DSL著名的例子。在Rails中的路由定义就是其中一个:

Rails.application.routes.draw do  
  root to: "pages#main"

  resources :posts do
    get :preview

    resources :comments, only: [:new, :create, :destroy]
  end
end  

得益于各种元编程技术,才造就了如此干净、易于使用的接口。这是Ruby代码,然而它感觉更像是一种客户端路由定义语言。注意到,此DSL的结构是通过Ruby的块来实现的,而诸如getresources这要的方法调用则用于定义迷你语言的关键字。

元编程在RSpec测试类库中用得更为疯狂:

describe UsersController, type: :controller do  
  before do
    allow(controller).to receive(:current_user).and_return(nil)
  end

  describe "GET #new" do
    subject { get :new }

    it "returns success" do
      expect(subject).to be_success
    end
  end
end  

这块代码还包含了流式接口的例子,即声明可以像简明英语句子那样大声朗读出来,这样可以更容易理解代码正在做什么:

# 对controller上的current_user方法进打桩,使其总是返回nil
allow(controller).to receive(:current_user).and_return(nil)

# 断言subject.success?为真
expect(subject).to be_success  

流式接口的另一个例子是Arel ActiveRecord的查询接口,对于构建复杂的SQL查询,它使用了抽象语法树

Post.                               # =>  
  select([                          # SELECT
    Post[Arel.star],                #   `posts`.*,
    Comment[:id].count.             #     COUNT(`comments`.`id`)
      as("num_comments"),           #       AS num_comments
  ]).                               # FROM `posts`
  joins(:comments).                 # INNER JOIN `comments`
                                    #   ON `comments`.`post_id` = `posts`.`id`
  where.not(status: :draft).        # WHERE `posts`.`status` <> 'draft'
  where(                            # AND
    Post[:created_at].lte(Time.now) #   `posts`.`created_at` <=
  ).                                #     '2017-07-01 14:52:30'
  group(Post[:id])                  # GROUP BY `posts`.`id`

尽管干净、富有表现力的语法以及天生的元编程能力使得Ruby非常适合于构建领域特定语言的,但是DSL也存在于其他语言中。以下是使用Jasmine框架进行JavaScript测试的一个例子:

describe("Helper functions", function() {  
  beforeEach(function() {
    this.helpers = window.helpers;
  });

  describe("log error", function() {
    it("logs error message to console", function() {
      spyOn(console, "log").and.returnValue(true);
      this.helpers.log_error("oops!");
      expect(console.log).toHaveBeenCalledWith("ERROR: oops!");
    });
  });
});

上面的语法可能没有Ruby例子那样干净,但这表明了通过命名和对语法的创造性使用,几乎可以使用任何语言构建内部DSL。

内部DSL的好处是不需要单独的解析器,要想正确实现解析器是众所周知的困难。此外,由于使用的是宿主语言的语法,还可以和代码库的其他部分实现无缝集成。

作为代价,我们需要放弃的是语法自由——内部DSL必须要符合宿主语言的语法。你要做出的妥协,更大程度上依赖于所选择的语言,在谱的一端是带有短语、静态类型的语言,诸如Java和VB.NET,而另一端则是带有可扩展元编程能力的动态语言,例如Ruby。

为类配置构建一个自己的Ruby DSL

我们准备用Ruby构建的示例是一个可重用的配置引擎,通过非常简单的语法即可指定一个Ruby类的配置属性。在Ruby世界中,为一个类添加配置的能力是很常见的需求,尤其是需要配置额外的gem和API客户端时。通常的解决方法是一个类似这样的接口:

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

我们先来简单实现它——然后把它作为一个起点,通过添加更多特性、精简语法以及让成果可重用,进行逐步完善。

为了让这个接口可工作,我们需要做什么呢?MyApp类应该要有一个configure类方法,它接收一个block代码块然后通过yield来执行,传入的是一个拥有读、写配置值的访问器方法的配置对象:

class MyApp  
  # ...

  class << self
    def config
      @config ||= Configuration.new
    end

    def configure
      yield config
    end
  end

  class Configuration
    attr_accessor :app_id, :title, :cookie_name
  end
end  

一旦运行了配置代码块,就能轻易访问和修改这些值:

MyApp.config  
=> #<MyApp::Configuration:0x2c6c5e0 @app_id="my_app", @title="My App", @cookie_name="my_app_session">

MyApp.config.title  
=> "My App"

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

到目前为止,此实现并不像是一种定制的语言而被看待是一个DSL。但别急,一步步来。接下来,我们会把配置功能从MyApp类中解耦出来,让它足够通用以便能在很多不同的场景下重用。

让它变得可重用

现在,如果想添加类似的配置能力到另一个不同的类,需要同时复制Configuration类和其相关的初始化方法到那一个类,还要编辑attr_accessor列表以便修改可接收的配置属性。为避免这样做,可先把配置特性移到另外一个叫做Configurable的模块。这样的话,我们的MyApp类看起来像是:

class MyApp  
  include Configurable

  # ...
end  

任何与配置相关的都被移到了Configurable的模块:

module Configurable  
  def self.included(host_class)
    host_class.extend ClassMethods
  end

  module ClassMethods
    def config
      @config ||= Configuration.new
    end

    def configure
      yield config
    end
  end

  class Configuration
    attr_accessor :app_id, :title, :cookie_name
  end
end  

这里要改动的并不多,除了新的self.included方法。我们需要这个方法是因为引入一个模块只会混入它的实例方法,所以默认情况下configconfigure类方法不会被添加到宿主类。然而,如果在模块里定义了一个叫做included的特别方法,Ruby将会在类一引入模块时就调用此方法。这样我们就可以手动地让宿主类继承ClassMethods的方法:

def self.included(host_class)     # 在MyApp类引入此模块时被调用  
  host_class.extend ClassMethods  # 把我们的类方法添加到MyApp类
end  

到这里还没结束——下一步是让它能够指定引入Configurable模块的宿主类支持哪些属性。一个不错的解决方案是:

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

  # ...
end  

也许某种程度上会让人觉得奇怪,但实际上以上代码在语法上是正确的——include不是一个关键字,而是一个简单的普通方法,它期待的参数是一个Module对象。只要我们传给它的表达式返回的是一个Module,Ruby就会很乐意地引入它。所以,取代直接引入Configurable的方式,我们需要一个名字叫做with的方法来生成一个新的、可通过指定属性定制化的模块:

module Configurable  
  def self.with(*attrs)
    # 使用配置属性定义匿名类
    config_class = Class.new do
      attr_accessor *attrs
    end

    # 为可混入的类方法定义匿名模块
    class_methods = Module.new do
      define_method :config do
        @config ||= config_class.new
      end

      def configure
        yield config
      end
    end

    # 创建并返回新的模块
    Module.new do
      singleton_class.send :define_method, :included do |host_class|
        host_class.extend class_methods
      end
    end
  end
end  

这里的内容有点多。整个Configurable模块现在只是包含了一个with方法,并在这个方法内完成全部的事情。首先,通过Class.new创建了一个新的匿名类来接收属性访问器方法。因为Class.new将类的定义作为一个代码块来接收,而代码块又能访问外部变量,所以把attr_accessor传递给attrs变量是没有问题的。

def self.with(*attrs)           # attrs在这创建  
  # ...

  config_class = Class.new do   # 类定义作为代码块传入
    attr_accessor *attrs        # 在这可以访问到attrs

  end

Ruby的代码块能够访问外部变量这一事实,也是为什么有时它们被叫做闭包的原因,因为它们包含了,或者说“沉浸在”所定义时的外部环境。注意,我使用的短语是“所定义时的”而非“所执行时的”。下面说法是对的——不管define_method代码块最终何时、何地被执行,它们都能访问到变量config_classclass_methods,哪怕是在with方法执行完毕并返回结果之后。以下示例演示了这一行为:

def create_block  
  foo = "hello"            # 定义局部变量

  return Proc.new { foo }  # 返回一个返回值为foo的新代码块
end



block = create_block       # 调用create_block接收代码块



block.call                 # 尽管create_block已经返回  

=> "hello"                 #   代码块依然返回了foo给我们

既然我们已经知道代码块这一巧妙的行为,那么可以继续前进,在class_methods里为引入生成的模块时将会添加到宿主类的类方法定义一个匿名模块。这里不得不使用define_method来定义config方法,因为需要访问来自于方法内的外部的config_class变量。使用关键字def来定义此方法将会没有访问权限,因为用def定义的普通方法并不是闭包——然而,define_method接收一个代码块,所以可以这么干:

config_class = # ...               # 在这定义config_class  
# ...

class_methods = Module.new do      # 使用代码块定义新模块  

  define_method :config do         # 通过代码块定义方法
    @config ||= config_class.new   # 即使有两层代码,我们依然可以can still

  end                              #   访问config_class

最后,调用Module.new创建待返回的模块。这里需要定义我们的self.included方法,不幸的是依然不能使用def关键字,原因你懂的,因为此方法需要访问class_methods以外的变量。所以再一次需要通过代码块来使用define_method,但这一次在模块的单例类上,相当于在模块实例本身定义了一个方法。注意有坑!因为define_method是单例类的私有方法,需要用send来调度而不是直接调用:

class_methods = # ...  
# ...

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

    host_class.extend class_methods  # 此代码块可以访问class_methods
  end

end  

恩……,这些硬编码的元编程已经算得是漂亮的了。但所添加的复杂性是否值得呢?来看一下好不好用,你再来决定:

class SomeClass  
  include Configurable.with(:foo, :bar)

  # ...
end

SomeClass.configure do |config|  
  config.foo = "wat"
  config.bar = "huh"
end

SomeClass.config.foo  
=> "wat"

但我们可以做到更好。下一步,将稍微精简配置代码块的语法,让使用更简便。

译者注:下一篇章,继续阅读,请访问(下)高级元编程指南:创建一个Ruby DSL

dogstar

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

广州