创建一个ruby的DSL:进阶元编程的一个guide(译)

从订阅的ruby-weekly邮件里看到这篇文章,看完后觉得写得不错,比较细致,简单翻译一下,留备后用
原文章地址

DSL(领域特定语言)在对简化一个程序或是配置一个复杂系统的工作时,是一个很强大的工具。作为一个软件工程师,可能在每天的工作中会接触几种不同的DSLs。

在这篇文章里,我们会看到什么是领域特定语言何时应该使用,以及你自己如何使用进阶元编程方式来写一个你自己的DSL。
如果不太明白元编程的内容,可以参考另一篇文章 (跟本文章是一家公司里的员工)

什么是DSL?

通常来说,DSL就是一个在某领域或某些条件下使用的特定的语言,也就是说,你只能在这些特定的场景里才能使用这些语言。它们不是通用的语言(译:像是ruby, java等我们知道的编程语言)。听上去比较无聊,但它有自己的一些特性:

  • 像是HTML, CSS这样的标记性语言,设计用来描述结构,内容等,不适合写任意的逻辑,所以它们适合DSL的场景
  • Macro和一些查询语言(像是SQL),是基于某些特定系统或其他编程语言,并且被限制能使用的场景。所以比较适合于DSL
  • 很多DSL没有自己的语言,他们是基于另一个语言而来的,但从语法上看感觉像是另一种独立的小语言

上面列举的最后一种,被称为内部DSL,也是我们将要在下面来创建的示例。开始之前,我们先来看一下Rails里route的定义方式,来感觉一下:

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

  resources :posts do
    get :preview

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

这就是Ruby code,但感觉上又像是一种自定义的路径定义语言,这也多亏是有丰富的元编程方式,能让我们创造出这样简洁易用的接口方式。注意到这里的结构实现是用到的ruby blocks,像是get, resources这样的方法调用则是用来作为这种mini语言的关键字。

在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

这段代码也包括了fluent interfaces(流式接口?自然语言?)的例子,可以像普通的英文句子一样读出来,让使用这些接口来编程的人很容易明白这段代码在做些什么:

#  假设让controller上的current_user方法总是返回nil
allow(controller).to receive(:current_user).and_return(nil)

# 声明`subject.success?` 是正确的
expect(subject).to be_success

另一个fluent interface的例子是AR和Arel,使用抽象语法树来内构一些复杂的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`

DSL不是只在ruby里有,在其他语言里也有。

内部DSL的优势是它们不需要额外的解释器。又因为它使用的是跟项目其他地方一样的语言来实现的,所以能很好的集成在一起。

搭建一个自己的DSL——类配置(Class Configuration)

我们要做的示例是一个可复用的配置引擎。这个需求在ruby世界里用到的很多,尤其是需要配置一些外部的gems或者API。通常的解决方法如下:

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

我们先来实现这种方式,接下去以这个为起点一步一步来增加features。

如何实现?MyApp类应该有一个configure类方法,接收一个block,然后通过yield方式来执行它,传入一个 configuration 对象,这个configuration类有accessor方法来read和write注册的值:

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

一旦等到 configuration block运行起来,我们可以得到并可以修改里面的值:

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。让我们一点一点往前走,接下来,我们把configuration的功能从MyApp里解耦出来,让它更通用,能在不同场景里使用。

重用(reusable)

现在的问题是,如果我们想要在其他的类里也加入类似的注册功能,我们就必须要把上面里的 Configuration类和相关的初始化步骤都copy过去,同样,也要修改attr_accessor里的字段。为了避免这样操作,我们把注册的功能移到一个单独的模块(module)里,把它叫作Configurable。有了它之后,我们的MyApp类看上去就会像这样:

class MyApp
  include Configurable

  # ...
end

所有跟注册相关的功能代码都被移到这个module里:

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以外,没有其他什么变化。在这里使用它的原因是这样的,因为通过 include 来混入(mix in)一个module时,只会引入它里面的实例方法,我们的configconfigure两个类方法就不会被引入到宿主类里。在一个module里使用included方法,那么在这个module被include的时候,这个方法就会被执行。所以在这里,我们手动地让宿主类来extend ClassMethods里的方法,这样宿主类里就会有ClassMethods里定义的类方法。

def self.included(host_class)     # 当include这个module时,这个方法就会被调用
  host_class.extend ClassMethods  # 把我们的类方法加入到`MyApp`
end

现在还没完成。接下来我们想做的是,在宿主类里来指定可注册的字段。一种可能的解决办法是这样的:

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

  # ...
end

可能会有些惊讶,但上面的代码在语法上是正确的。include不是一个关键字,而只是一个普通的方法,并期待它的参数是一个Module类型。只要我们传进去一个能返回Module的表达式,它就能正常运行。所以,这里不直接去include这个Configurablemodule,我们需要一个with方法,能返回一个module,并能指定字段:

module Configurable
  def self.with(*attrs)
    # 用注册的属性来定义一个匿名类
    config_class = Class.new do
      attr_accessor *attrs
    end

    # 定义一个匿名module,来定义要被混入的类方法
    class_methods = Module.new do
      define_method :config do
        @config ||= config_class.new
      end

      def configure
        yield config
      end
    end

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

这里有许多需要说明的地方。现在整个Configurable module都是由一个with方法组成,所有事情都是定义在这个方法里。首先,我们通过Class.new来创建了一个匿名类,来持有我们的属性访问方法。因为Class.new是用一个block来做类的定义,而block可以访问外部变量,我们就可以把外部的attrs变量传递给attr_accessor

def self.with(*attrs)
  # ...

  config_class = Class.new do
    attr_accessor *attrs
  end
end

block可以得到外部变量的值,这个方式叫作“闭包”(closures)。因为它们可以include,或者“关闭”它们所定义的外部环境。这里使用的定义,而非执行。这是没错的,不管我们的define_method是在何时何地被最终执行的,它都能访问config_class这个变量和class_methods。示例如下:

def create_block
  foo = "hello"            # 定义一个本地变量
  return Proc.new { foo }  # 返回一个block,里面使用到了foo
end

block = create_block       # 调用 `create_block`来得到这个block

block.call                 # 执行这个block,虽然已经在定义的代码外
=> "hello"                 #   但这个block还是可以给我们返回 foo 的值

现在我们知道了block的一些小行为,现在我们可以在class_methods里定义一个匿名module,来存放类方法,这些类方法之后在include的时候被宿主类当成宿主类的类方法。这里我们必须要使用define_method来定义config方法,因为我们需要访问外部的config_class变量。如果是用def来进行定义的话,我们是得不到这个变量,因为这种方法不是一个“闭包”形式,但define_method是可以的,因为它带有一个block。

最后,我们调用Module.new来创建一个要被返回的module。这里我们需要定义一个self.included方法,但不能使用def方法,因为方法体里需要访问外部的 class_methods变量。所以,我们要使用define_method加上block的组合,但这次是在一个单例类(singleton class)上进行操作,因为我们是在这个module的实例上定义一个方法。又因为define_method是单例类的private方法,所以又需要使用到send方法。

class_methods = # ...
# ...
Module.new do
  singleton_class.send :define_method, :included do |host_class|
    host_class.extend class_methods  # the block has access to `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"

我们还能做得更好。接下去我们要再简化使用语法,让它更易用。

整理语法

现在还有一个让人觉得有些麻烦的事存在:我们要在每行里都重复写上config。我们的DSL应该知道它所在的上下文,都是对应我们的configuration对象,并能让我们像这样来进行调用:

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

我们需要两点来实现它。1. 我们需要一个运行传入到configureblock方式; 2. 我们必须要改变访问方法,这样如果提供一个参数,可以写入,当没有参数的时候可以读取。一种可能的实现如下:

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

    # Create and return new module
    # ...
  end
end

这里简单的变化是在configuration 对象的上下文里运行configureblock。在一个对象里调用ruby的instance_eval方法,可以让我们像在这个对象里直接调用某方法一样来使用这个block,也就是说,在上面第一行里调用app_id,这个调用会进入到我们的configuration的实例里。

config_class里的属性访问方法的改变稍稍有一些复杂。要了解这个变化,我们需要先了解attr_accessor做了些什么事。例如下面:

class SomeClass
  attr_accessor :foo, :bar
end

相当于做了如下:

class SomeClass
  def foo
    @foo
  end

  def foo=(value)
    @foo = value
  end

  # and the same with `bar`
end

所以,当我们在之前的代码里写着attr_accessor *attrs,Ruby帮我们为每个属性定义相应的读、写方法。在我们的新版本里,也可以使用类似方法,但会有一个问题,我们需要支持如下语法:

MyApp.configure do
  app_id "my_app" # 赋值
  app_id          # 取值
end

所以这里我们做的方式是,如果attr后面有值,则使用赋值(_set),如果后面没有值,则使用取值(_get)。
在这里,我们使用instance_variable_get来得到实例变量,instance_variable_set来设置实例变量的值。注意实例变量前要加上一个"@"。
也许会好奇为什么我们使用了一个空的object做为"not provided"的默认值,而不是直接使用nil。原因很简单,我们可能会为某个属性字段设置值为nil,因为它是一个合法的值。

增加引用功能

接下来我们想再进一步,让一个属性值可以引用其他定义的属性值:

MyApp.configure do
  app_id "my_app"
  title "My App"
  cookie_name { "#{app_id}_session" }
End

MyApp.config.cookie_name
=> "my_app_session"

我们在这里的cookie_name里引用了app_id的值。注意到引用的方式是使用了一个block,原因是为了要支持延时计算。只有当这个属性值在被使用到的时候才去计算block里的值,而不是在定义的时候就进行计算,否则如果我们把顺序定义错,就不能正常运行:

SomeClass.configure do
  foo "#{bar}_baz"     # 在这里直接被执行
  bar "hello"
end

SomeClass.config.foo
=> "_baz"              # 不是我们想要的结果

如果一个表达式被包裹在一个block里,就会阻止它立即执行。我们可以先保存它,等到之后这个属性被使用的时候再去执行这个block里的内容。

SomeClass.configure do
  foo { "#{bar}_baz" }  # 先保存,但不去执行
  bar "hello"
end

SomeClass.config.foo    # `foo` evaluated here
=> "hello_baz"          # 得到我们想要结果

我们不需要对Configurable这个module做些大的修改就可以支持使用block来延迟执行,只需要改变一下属性定义的地方:

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,block || value这个表示式就会保存这个block,否则就去保存传进来的value。在之后得到这个属性值时,我们检查一下,如果是一个block(Proc的实例),我们使用instance_eval方法来执行这个代码块,如果不是一个block,就直接返回它的值。
这种引用的方式也会带来一些缺点和陷阱,下面就是一种极端情况:

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

最终的结果

我们想要的使用示例:

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

我们的实现代码如下:

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 DSL——什么时候使用,什么时候不要用

在看上面的实现的过程中,可能会注意到,为了让外部使用的时候看起来清洁好用,我们这里用到了非常多的元编程技巧。可能会让以后维护起来比较困难。这也是在开发过程中需要权衡的一点。

DSL实现起来有些困难,是否值得,就要看它是否能带来更多的便利。
当你自己在写DSL的时候,最好写上足够多的测试用户,并有对应的文档,这样以后也比较方便维护。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342

推荐阅读更多精彩内容