个性化阅读
专注于IT技术分析

创建Ruby DSL:高级元编程指南

本文概述

领域特定语言(DSL)是一种非常强大的工具, 可让你更轻松地对复杂的系统进行编程或配置。它们也无处不在-作为软件工程师, 你最有可能每天使用几种不同的DSL。

创建自己的Ruby DSL

在本文中, 你将学习什么是领域特定的语言, 何时应使用它们, 以及最后如何使用高级元编程技术在Ruby中制作自己的DSL。

本文基于Nikola Todorovic对Ruby元编程的介绍, 该介绍也发表在srcmini Blog上。因此, 如果你不熟悉元编程, 请务必先阅读。

什么是领域特定语言?

DSL的一般定义是它们是专用于特定应用程序域或用例的语言。这意味着你只能将它们用于特定的事物, 它们不适用于通用软件开发。如果听起来很宽泛, 那是因为它是DSL具有许多不同的形状和大小。以下是一些重要的类别:

  • 诸如HTML和CSS之类的标记语言旨在描述特定的事物, 例如网页的结构, 内容和样式。不可能用它们编写任意算法, 因此它们适合DSL的描述。
  • 宏和查询语言(例如SQL)位于特定系统或另一种编程语言之上, 通常受其作用的限制。因此, 它们显然有资格作为特定领域的语言。
  • 许多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块实现的, 并且使用诸如get和resource之类的方法调用来定义此迷你语言的关键字。

元编程在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

这段代码还包含流利接口的示例, 这些示例使声明能够像普通的英语句子一样大声读出, 从而使理解代码的工作变得容易得多:

# Stubs the `current_user` method on `controller` to always return `nil`
allow(controller).to receive(:current_user).and_return(nil)

# Asserts that `subject.success?` is truthy
expect(subject).to be_success

流畅接口的另一个示例是ActiveRecord和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`

尽管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中构建的DSL示例是一个可重用的配置引擎, 用于使用非常简单的语法指定Ruby类的配置属性。在类中, 向类添加配置功能是非常普遍的要求, 尤其是在配置外部gem和API客户端时。通常的解决方案是这样的接口:

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

让我们首先实现此接口, 然后以它为起点, 可以通过添加更多功能, 清理语法以及使我们的工作可重用来逐步改进它。

我们需要什么来使该界面起作用? MyApp类应该具有一个配置类方法, 该方法接受一个块, 然后通过产生该块来执行该块, 并传入一个配置对象, 该对象具有用于读取和写入配置值的访问器方法:

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
#BOLD
  include Configurable
#BOLDEND

  # ...
end

与配置相关的所有内容均已移至”可配置”模块:

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

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

    def configure
      yield config
    end
#BOLD
  end
#BOLDEND

  class Configuration
    attr_accessor :app_id, :title, :cookie_name
  end
#BOLD
end
#BOLDEND

除了新的self.included方法之外, 此处没有太大变化。我们需要此方法, 因为包括模块仅在其实例方法中混合使用, 因此默认情况下, 我们的config和configure类方法不会添加到主机类中。但是, 如果我们定义一个称为模块的特殊方法, 则只要该模块包含在类中, Ruby就会调用它。在那里, 我们可以使用ClassMethods中的方法手动扩展宿主类:

def self.included(host_class)     # called when we include the module in `MyApp`
  host_class.extend ClassMethods  # adds our class methods to `MyApp`
end

我们尚未完成-我们的下一步是使在包含Configurable模块的主机类中指定受支持的属性成为可能。这样的解决方案看起来不错:

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

  # ...
end

也许有些出乎意料的是, 以上代码在语法上是正确的-include不是关键字, 而只是希望将Module对象作为其参数的常规方法。只要我们传递一个返回模块的表达式, 它就会很高兴地包含它。因此, 除了直接包含Configurable之外, 我们还需要一个带有名称的方法, 该方法可以生成一个使用指定属性定制的新模块:

module Configurable
#BOLD
  def self.with(*attrs)
#BOLDEND
    # Define anonymous class with the configuration attributes
#BOLD
    config_class = Class.new do
      attr_accessor *attrs
    end
#BOLDEND

    # Define anonymous module for the class methods to be "mixed in"
#BOLD
    class_methods = Module.new do
      define_method :config do
        @config ||= config_class.new
      end
#BOLDEND

      def configure
        yield config
      end
#BOLD
    end
#BOLDEND

    # Create and return new module
#BOLD
    Module.new do
      singleton_class.send :define_method, :included do |host_class|
        host_class.extend class_methods
      end
    end
  end
#BOLDEND
end

这里有很多要解压的东西。现在, 整个可配置模块仅包含一个with方法, 该方法内发生的一切。首先, 我们使用Class.new创建一个新的匿名类来保存我们的属性访问器方法。因为Class.new将类定义作为一个块, 并且块可以访问外部变量, 所以我们能够毫无问题地将attrs变量传递给attr_accessor。

def self.with(*attrs)           # `attrs` is created here

  # ...

  config_class = Class.new do   # class definition passed in as a block

    attr_accessor *attrs        # we have access to `attrs` here

  end

Ruby中的块可以访问外部变量的事实也是为什么有时将它们称为闭包的原因, 因为它们包括或”封闭”了定义它们的外部环境。请注意, 我使用了短语” defined in”而不是”执行”。没错-不管最终在何时何地执行我们的define_method块, 即使with方法运行完毕并返回后, 它们也始终可以访问变量config_class和class_methods。下面的示例演示此行为:

def create_block

  foo = "hello"            # define local variable

  return Proc.new { foo }  # return a new block that returns `foo`

end



block = create_block       # call `create_block` to retrieve the block



block.call                 # even though `create_block` has already returned, 
=> "hello"                 #   the block can still return `foo` to us

现在我们知道了块的这种巧妙行为, 我们可以继续在class_methods中定义一个匿名模块, 用于将包含生成的模块时添加到主机类中的类方法。在这里, 我们必须使用define_method来定义config方法, 因为我们需要从方法内部访问外部config_class变量。使用def关键字定义方法不会给我们访问权限, 因为带有def的常规方法定义不是闭包–但是, define_method带有一个块, 因此可以使用:

config_class = # ...               # `config_class` is defined here

# ...

class_methods = Module.new do      # define new module using a block

  define_method :config do         # method definition with a block

    @config ||= config_class.new   # even two blocks deep, we can still

  end                              #   access `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  # the block has access to `class_methods`

  end

end

ew, 那已经是一些相当顽固的元编程了。但是增加的复杂性值得吗?看一下使用和自行决定有多容易:

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

  # ...
end

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

SomeClass.config.foo
=> "wat"

但是我们可以做得更好。在下一步中, 我们将稍微清理一下configure块的语法, 以使我们的模块更易于使用。

清理语法

最后一件事仍然困扰着我当前的实现-我们必须在配置块中的每一行上重复配置。适当的DSL会知道configure块中的所有内容都应在我们的配置对象的上下文中执行, 并使我们能够通过以下方式实现相同的目的:

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

让我们实施它, 对吧?从外观上, 我们将需要两件事。首先, 我们需要一种方法来执行传递的块, 以在配置对象的上下文中进行配置, 以使块内的方法调用转到该对象。其次, 我们必须更改访问器方法, 以便如果向它们提供了参数, 则它们将写入值, 并在不带参数的情况下被调用时将其读回。可能的实现如下所示:

module Configurable
  def self.with(*attrs)
#BOLD
    not_provided = Object.new
#BOLDEND
  
    config_class = Class.new do
#BOLD
      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 *attrs
#BOLDEND
    end

    class_methods = Module.new do
      # ...

      def configure(&block)
#BOLD
        config.instance_eval(&block)
#BOLDEND
      end
    end

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

此处最简单的更改是在配置对象的上下文中运行configure块。在对象上调用Ruby的instance_eval方法可让你执行任意代码块, 就像它在该对象中运行一样, 这意味着, 当配置块在第一行调用app_id方法时, 该调用将转到我们的配置类实例。

对config_class中的属性访问器方法的更改有些复杂。要了解它, 我们首先需要了解attr_accessor在幕后到底在做什么。以下面的attr_accessor调用为例:

class SomeClass
  attr_accessor :foo, :bar
end

这等效于为每个指定的属性定义一个reader和writer方法:

class SomeClass
  def foo
    @foo
  end

  def foo=(value)
    @foo = value
  end

  # and the same with `bar`
end

因此, 当我们在原始代码中编写attr_accessor * attrs时, Ruby为attrs中的每个属性定义了属性读取器和写入器方法, 即, 我们获得了以下标准访问器方法:app_id, app_id =, title, title =等上。在新版本中, 我们希望保留标准的writer方法, 以便这样的分配仍然可以正常工作:

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

我们可以通过调用attr_writer * attrs来自动生成writer方法。但是, 我们不能再使用标准的读取器方法, 因为它们还必须能够编写属性以支持此新语法:

MyApp.configure do
  app_id "my_app" # assigns a new value
  app_id          # reads the stored value
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

在这里, 我们使用Ruby的instance_variable_get方法读取具有任意名称的实例变量, 并使用instance_variable_set为其分配新值。不幸的是, 在两种情况下, 变量名都必须以” @”作为前缀, 因此必须进行字符串插值。

你可能想知道为什么我们必须使用空白对象作为”未提供”的默认值, 以及为什么不能为此简单地使用nil。原因很简单-nil是有人可能要为配置属性设置的有效值。如果我们测试为零, 我们将无法区分这两种情况:

MyApp.configure do
  app_id nil # expectation: assigns nil
  app_id     # expectation: returns current value
end

存储在not_provided中的空白对象将永远只等于它自己, 因此可以确定没有人将其传递到我们的方法中并导致意外的读取而不是写入。

添加参考支持

我们可以添加一个新功能, 以使我们的模块更加通用-能够从另一个功能中引用配置属性:

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属性。请注意, 包含引用的表达式将作为一个块传入-为了支持对属性值的延迟求值, 这是必需的。想法是只在稍后读取属性时才对块进行评估, 而不是在定义属性时对块进行评估;否则, 如果我们以”错误”的顺序定义属性, 则会发生有趣的事情:

SomeClass.configure do
  foo "#{bar}_baz"     # expression evaluated here
  bar "hello"
end

SomeClass.config.foo
=> "_baz"              # not actually funny

如果将表达式包装在一个块中, 则将阻止立即对其求值。相反, 我们可以保存该块, 以便稍后在检索到属性值时执行:

SomeClass.configure do
  foo { "#{bar}_baz" }  # stores block, does not evaluate it yet
  bar "hello"
end

SomeClass.config.foo    # `foo` evaluated here
=> "hello_baz"          # correct!

我们不必对可配置模块进行重大更改即可添加对使用块进行延迟评估的支持。实际上, 我们只需要更改属性方法定义即可:

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

设置属性时, ||如果传入了值, 则value表达式将保存该块, 否则将保存该值。然后, 当稍后读取该属性时, 我们检查它是否为一个块, 并使用instance_eval对其进行评估, 或者如果它不是一个块, 则像以前一样返回它。

当然, 支持参考也有其自身的注意事项和优势案例。例如, 你可能可以弄清楚如果你在此配置中读取任何属性, 将会发生什么:

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

成品模块

最后, 我们获得了一个漂亮的模块, 可以使任意类可配置, 然后使用干净简单的DSL指定这些配置值, 该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, 请确保包含一个全面的测试套件, 并正确记录其语法, 因为仅从实现中很难找出它。将来, 你和你的其他开发人员将感谢你。

赞(0)
未经允许不得转载:srcmini » 创建Ruby DSL:高级元编程指南

评论 抢沙发

评论前必须登录!