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

已发表: 2022-03-11

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

在本文中,您将了解什么是特定领域的语言,何时应该使用它们,以及如何使用高级元编程技术在 Ruby 中创建自己的 DSL。

本文基于 Nikola Todorvic 对 Ruby 元编程的介绍,该介绍也发表在 Toptal 博客上。 因此,如果您不熟悉元编程,请务必先阅读该内容。

什么是领域特定语言?

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 块实现的,并且使用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

这段代码还包含fluent interfaces的示例,它允许将声明作为简单的英语句子大声朗读,从而更容易理解代码在做什么:

 # 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 类的配置属性。 向类添加配置功能是 Ruby 世界中非常常见的需求,尤其是在配置外部 gems 和 API 客户端时。 通常的解决方案是这样的界面:

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

让我们先实现这个接口——然后,以它为起点,我们可以通过添加更多特性、清理语法和使我们的工作可重用来逐步改进它。

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

 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_, @title="My App", @cookie_name="my_app_session"> MyApp.config.title => "My App" MyApp.config.app_ => "not_my_app"

到目前为止,这种实现感觉不像是一种足以被视为 DSL 的自定义语言。 但是,让我们一步一步来。 接下来,我们将从MyApp类中解耦配置功能,并使其足够通用,以便在许多不同的用例中使用。

使其可重用

现在,如果我们想为不同的类添加类似的配置功能,我们必须将Configuration类及其相关的设置方法复制到另一个类中,并编辑attr_accessor列表以更改接受的配置属性。 为了避免这样做,让我们将配置功能移动到一个名为Configurable的单独模块中。 这样,我们的MyApp类将如下所示:

 class MyApp #BOLD include Configurable #BOLDEND # ... end

与配置相关的所有内容都已移至Configurable模块:

 #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方法外,这里没有太大变化。 我们需要这个方法是因为包含一个模块只混入了它的实例方法,所以我们的configconfigure类方法默认不会添加到宿主类中。 但是,如果我们在一个模块上定义了一个名为 include 的特殊方法,那么只要该模块included在一个类中,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对象作为其参数。 只要我们向它传递一个返回Module的表达式,它就会愉快地包含它。 因此,我们需要一个名称为with的方法,而不是直接包含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

这里有很多东西要解压。 整个Configurable模块现在只包含一个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 中的块可以访问外部变量的事实也是它们有时被称为闭包的原因,因为它们包含或“关闭”它们定义的外部环境。请注意,我使用了“定义在”这个短语而不是“执行”。 这是正确的——无论我们的define_method块最终将在何时何地执行,它们总是能够访问变量config_classclass_methods ,即使with方法完成运行并返回之后也是如此。 以下示例演示了此行为:

 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

唷,那已经是一些非常核心的元编程了。 但是增加的复杂性值得吗? 看看它是多么容易使用并自己决定:

 class SomeClass include Configurable.with(:foo, :bar) # ... end SomeClass.configure do |config| config.foo = "wat" config.bar = "huh" end SomeClass.config.foo => "wat"

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

清理语法

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

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

让我们实现它,好吗? 从表面上看,我们需要两件事。 首先,我们需要一种方法在配置对象的上下文中执行传递给configure的块,以便块内的方法调用转到该对象。 其次,我们必须更改访问器方法,以便它们在向它们提供参数时写入值,并在没有参数的情况下将其读回。 一个可能的实现如下所示:

 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_idapp_id=titletitle=等等在。 在我们的新版本中,我们希望保留标准的编写器方法,以便这样的分配仍然可以正常工作:

 MyApp.config.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是一个有人可能想要为配置属性设置的有效值。 如果我们测试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_nameapp_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!

我们不必对Configurable模块进行大的更改来添加对使用块的延迟评估的支持。 其实我们只需要改变属性方法定义:

 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 || value 如果传入了一个block || 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。 如果您确实创建了 DSL,请确保包含一个全面的测试套件,并正确记录其语法,因为仅从实现中很难弄清楚。 未来您和您的开发人员会为此感谢您。


进一步阅读 Toptal 工程博客:

  • 如何从头开始编写解释器