美文网首页
创建一个ruby的DSL:进阶元编程的一个guide(译)

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

作者: peterzd | 来源:发表于2017-08-09 14:57 被阅读0次

    从订阅的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的时候,最好写上足够多的测试用户,并有对应的文档,这样以后也比较方便维护。

    相关文章

      网友评论

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

          本文链接:https://www.haomeiwen.com/subject/lwjfrxtx.html