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

dogstar · 2017年08月02日 · 最后由 kafei 回复于 2017年12月21日 · 13031 次阅读
本帖已被管理员设置为精华贴

原文请见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

jasl 将本帖设为了精华贴。 08月03日 02:16

下篇似乎并没有?

Ruby 的元编程能力 相对于 Lisp 来说,只是冰山一角

元编程还是 Elixir 好,完全在编译时完成,几乎不会带来性能损耗,甚至可以把一些计算挪到编译时完成。跟楼上说的 Lisp macro 一脉相承,但语法更加友好。

Ecto 的语法感觉比 AR 的要好用和灵活,基本就是 SQL(毕竟 SQL 是最好的语言)。

query = from p in Post,
join: c in Comment,
where: p.category_id == 1 and p.inserted_at <= ^datetime,
group_by: p.id,
select: [p, count(c.id)]
tony612 回复

Ruby 的元编程也不过就是方法调用而已,没有性能损耗一说(Ruby 跑的不快是另一方面问题),Ecto 也有 AR 式的 pipeline 写法,在 Ruby 上实现 Linq 风格的 query 并不复杂。

AR 里的元编程不可能在编译期完成(即使 Ruby 存在编译期),模型类反射数据库 Schema 生成字段的访问器这件事只能在运行时做(当然生产环境就在初始化的时候进行了,所以在运行时并不会有额外开销)。而 Ecto 是要显式声明字段的,自然不会有问题

jasl 回复

下篇还在翻译中,晚点发出。翻译水平有限,但也是用心翻译了,所以分享下到我们 Ruby 社区。

jasl 回复

我也没有说 Ruby 的不好啊,只是在陈述 Elixir 的好而已。也不打算拿 Ecto 跟 AR 比语法,毕竟这方面的优势也不是很明显,其他方面优势也已经够多了😜

但不可否认的是,为了支持运行时元编程,确实拖累了 Ruby,参考 http://rubykaigi.org/2016/presentations/shyouhei.html 。更不用说 method_missing 这种根本不应该存在的东西了。

撇开性能问题,Ruby 元编程带来的难维护性也是不可忽视的。

@jasl 下篇翻译已完成。

tony612 回复

我也没说 Elixir/Ecto 不如 Ruby/AR,我想讲的是元编程是 feature,本质上还是方法调用。你指出 Ecto 的写法,我表达的是在 Ruby 上也是可以实现的(不过流行的库目前还没有人这样做,这个就是社区问题了)。

AR 的模型的属性的访问器是基于表的定义生成的,这件事如果不选择类似 .Net Entity Framework 的方式开发期代码生成的话,在运行前是无法做到的,这样做并不一定是好事,但是,没有 Ruby 的运行时元编程的支持(eval),这件事是做不到的。

至于性能问题,这个是解释器实现的问题,PHP 也提供了魔术方法系列,性能就(远)好于 Ruby,我比较赞同 Matz 的理念,语言(DSL、API)的设计者、语言的实现者、语言的使用者三种角色分离。

至于 Method missing,这个是建立在 SmallTalk 风格面向对象上的概念,在对象的消息模型的概念之内,method missing 是合理的。

维护性是一个玄学问题,即使是严格如 Java 的面向对象语言,还是有人试图通过设计模式来规范编码,这不就是为了提高维护性么?但是,Java 明显比 Ruby 对编码的局限性要高很多呀

jasl 回复

你如果坚持 Ruby 的 method_missing 是合理的话,那就无话可说了

tony612 回复

合不合理要看在什么语境下讨论,Elixir 和 Ruby 虽然像,但是根本理念不同,套用 Elixir 的理念去说 Ruby 的某条不合理,这个是没有办法讨论的

看完原文了,文章写得还挺细致的。翻译辛苦了 🍻

看完以后还是不知其所以然,对 ruby 的掌握还不够,欠火候,需要继续努力学习。感谢楼主分享这么优秀的文章。

如果需要多套配置时,比如 SomeClass 的 foo 和 bar,是不是就不能用这种方式了,而是用读取.yml 文件?

992699452 回复

「多套配置」是指:Cls.config_a.foo; Cls.config_b.foo?如果是这个意思,那么我粗糙地实现了一下:

# configurable.rb
module Configurable
  module Enable
    cattr_accessor :current_config

    def method_missing(method, *args)
      cattr_accessor method
      define_singleton_method method do
        self.current_config = method

        config = instance_variable_get("@#{current_config}_config")
        config.nil? ? self : config
      end
      send method
    end
  end

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

    # 为可混入的类方法定义匿名模块
    class_methods = Module.new do
      define_method :config do
        config_var = "@#{current_config}_config"
        instance_variable_set(config_var, config_class.new) if instance_variable_get(config_var).nil?
        instance_variable_get(config_var)
      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
# my_config.rb
class MyConfig
  extend Configurable::Enable
  include Configurable.with :foo
end
# Usage
MyConfig.config_a.configure do |config|
  config.foo = 'bar'
end
MyConfig.config_b.configure do |config|
  config.foo = 'foo'
end

puts MyConfig.config_a.foo
puts MyConfig.config_b.foo
zhandao 回复
# Usage
MyConfig.config.configure do |config|
  config.sign = '1'
  config.foo = 'foo1'
  config.bar = 'bar1'

  config.sign = '2'
  config.foo = 'foo2'
  config.bar = 'bar2' 
end

我说的是这个意思,因为有些设置有多种情况,不知道使用这种配置是否能实现;我都是用 yml 文件配置的

992699452 回复

这不都是同名的 key 吗,不知道你的 yml 文件是怎么配置?

zhandao 回复

不知道能不能用你上面所说的那种方式实现

992699452 回复

我觉得也可行,简单的想法是在 setter 写入值的时候,按照前面设置的 @sign 实例变量的值存成不同名的实例变量,比如 @foo_1@foo_2,分别是 sign 1 和 2 的配置值。大概这样:

config_class = Class.new do
  attrs.each do |attr|
    define_method attr do
      attr.eql?(:sign) ? @sign : instance_variable_get("@#{attr}_#{@sign}")
    end
    define_method "#{attr}=" do |val|
      attr.eql?(:sign) ? (@sign = val) : instance_variable_set("@#{attr}_#{@sign}", val)
    end
  end

配置的时候就可以:

config.sign = '1'
config.foo = 'foo1'
config.bar = 'bar1'

config.sign = '2'
config.foo = 'foo2'
config.bar = 'bar2' 

使用:

MyConfig.config.sign = '1'
MyConfig.config.foo # => 'foo1'

手写的这一段,还没试过,可以参考一下 ... 不过我是觉得上面的 MyConfig.config_a 的用法更好,读某一套配置只需要一行。 再多写一点,可以继续把 sign 方法的名字可配置化,而不是硬编码。

zhandao 回复

谢谢!我去试试

22 楼 已删除
需要 登录 后方可回复, 如果你还没有账号请 注册新账号