原文请见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 的块来实现的,而诸如get
和resources
这要的方法调用则用于定义迷你语言的关键字。
元编程在 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 构建的示例是一个可重用的配置引擎,通过非常简单的语法即可指定一个 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
方法。我们需要这个方法是因为引入一个模块只会混入它的实例方法,所以默认情况下config
和configure
类方法不会被添加到宿主类。然而,如果在模块里定义了一个叫做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_class
和class_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。