从订阅的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时,只会引入它里面的实例方法,我们的config
和configure
两个类方法就不会被引入到宿主类里。在一个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这个Configurable
module,我们需要一个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. 我们需要一个运行传入到configure
block方式; 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 对象的上下文里运行configure
block。在一个对象里调用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的时候,最好写上足够多的测试用户,并有对应的文档,这样以后也比较方便维护。