使用 Ruby 编写 DSL 语言

领域特定语言(英语:domain-specific language、DSL)指的是专注于某个应用程序领域的计算机语言。又译作领域专用语言。同名著作是 DSL 领域的丰碑之作,由世界级软件开发大师和软件开发“教父” Martin Fowler 历时多年写作。

Ruby 中很多的框架都采用了 DSL 语言的风格,比如:Grape 和 Rspec。今天让我们学习使用 Ruby 的语言来写一下 DSL。

1. 编写第一个 DSL 语言

现在经理给我们提了一个需求:让我们监听几个数据:用户成功创建订单数、用户成功付款订单数、商家及时放货的订单数、等等几十个事件,每天更新一次,后台管理员可以从后台看到这些数据。

我们理想中的 DSL 代码格式应该是这样的:

listen "用户成功创建订单数:" do
  # 从数据库获取用户今天创建的订单数
  # order = ...
  # order.count
end

代码块(Block)中返回需要显示的数量。

由此我们可以写出这个代码。

实例:

def listen description
  puts "#{description}#{yield}" if yield
end

listen "用户成功创建订单数:" do
  300
end

listen "用户成功付款订单数:" do
  150
end

listen "商家及时放货的订单数:" do
  130
end

# ---- 输出结果 ----
用户成功创建订单数:300
用户成功付款订单数:150
商家及时放货的订单数:130

2. DSL中使用变量

2.1 定义常规变量

如果我们现在要在 DSL 中插入变量应该怎们办呢,比如增加一个必须大于 150 才通知的限制。在上一章节的作用域中我们可以学到,变量是可以在闭包外定义作用于闭包内的,所以我们可以这样的改动代码。

实例:

limit = 150

listen "用户成功创建订单数:" do
  order_count = 300
  order_count > limit ? order_count: nil
end

listen "用户成功付款订单数:" do
  order_count = 150
  order_count > limit ? order_count: nil
end

listen "商家及时放货的订单数:" do
  order_count = 130
  order_count > limit ? order_count: nil
end

# ---- 输出结果 ----
用户成功创建订单数:300

2.2 变量集中初始化

但是如果我们需要增加很多种变量,这样定义变量的方式会让人感觉散乱又无序,一般DSL的语法会让我们定义变量在一个专门定义变量的块中,下面就是一个例子。

实例:

define do
  @limit = 150
end

listen "用户成功创建订单数:" do
  order_count = 300
  order_count > @limit ? order_count: nil
end

listen "用户成功付款订单数:" do
  order_count = 150
  order_count > @limit ? order_count: nil
end

listen "商家及时放货的订单数:" do
  order_count = 130
  order_count > @limit ? order_count: nil
end

我们将上述代码制成一个 event.rb,然后在实现代码中进行引用:

实例:

@defines = []
@listens = []

def define &block
  @defines << block
end

def listen description, &block
  @listens << {description: description, condition: block}
end

load 'event.rb'

@listens.each do |listen|
  @defines.each do |define|
    define.call
  end 
  condition = listen[:condition].call
  puts "#{listen[:description]}#{condition}" if condition
end

# ---- 输出结果 ----
用户成功创建订单数:300

Tips:load 方法会加载 event.rb 并执行文件中的代码。

解释:

在实例中我们将块yield换成了&proc的形式,我们定义了两个处于顶级作用域中的变量:@defines@listens,我们将每次从define中定义的proc对象都保存在了@defines中,将所有监听的事件也都保存到了@listens中,在后面的代码里面,每一次我们处理listen事件的时候都会运行define,这样就完成了变量集中初始化。

2.3 消除事件之间共享顶级作用域变量

2.3.1 洁净室

上述处理方法中,不同事件顶级实例变量会存在一个共享的问题,处理这个问题之前,首先让我了解一下洁净室(Clean Room)的概念。

洁净室是一个用来执行块的环境。理想的洁净室是不应该有任何的方法以及实例变量的,所以不会产生任何方法名或者变量名的冲突。因此BasicObjectObject往往被用来充当洁净室。

实例:

obj = Object.new
obj.instance_eval do
  @a = 1
  @b = 2
  @c = 3
end

obj.instance_eval do 
  puts "@a == #{@a}"
  puts "@b == #{@b}"
  puts "@c == #{@c}"
  puts "sum == #{@a + @b + @c}"
end

# ---- 输出结果 ----
@a == 1
@b == 2
@c == 3
sum == 6

解释:

让我们创建一个Object的实例作为洁净室,使用instance_eval在洁净室第一个块中里面定义三个变量,在第二个块中定义4个方法。因为他们的作用域是这个洁净室的实例,所以会得到最后的输出结果。

2.3.2 用洁净室来处理上述问题

让我们使用洁净室处理刚刚顶级作用域的问题。

先修改一下event.rb。

define do
  @limit = 150 
end

listen "用户成功创建订单数:" do
  @num = 1 
  puts "@num1 == #{@num}"
  order_count = 300 
  order_count > @limit ? order_count: nil 
end

listen "用户成功付款订单数:" do
  @num = @num.to_i + 1
  puts "@num2 == #{@num}"
  order_count = 150 
  order_count > @limit ? order_count: nil 
end

listen "商家及时放货的订单数:" do
  @num = @num.to_i + 1
  puts "@num3 == #{@num}"
  order_count = 130 
  order_count > @limit ? order_count: nil 
end

重新运行一下脚本,得到结果:

@num1 == 1
用户成功创建订单数:300
@num2 == 2
@num3 == 3

每一个事件之间应该是独立的,不应该共享不必要的变量,为此,我们使用洁净室修改一下实现代码。

实例:

@defines = []
@listens = []

def define &block
  @defines << block
end

def listen description, &block
  @listens << {description: description, condition: block}
end

load 'event.rb'

@listens.each do |listen|
  env = Object.new
  @defines.each do |define|
    env.instance_eval &define
  end 
  condition = env.instance_eval &listen[:condition]
  puts "#{listen[:description]}#{condition}" if condition
end

# ---- 输出结果 ----
@num1 == 1
用户成功创建订单数:300
@num2 == 1
@num3 == 1

解释:

我们在之前的基础上,每一次定义事件的时候创建了一个洁净室,这样实例变量的作用范围就从顶级作用域变为了洁净室对象之中,事件之间就不会存在共享变量的情况了。

3. 小结

本章节中我们学习到了如何使用 Ruby 去写一个 DSL 语言,了解了 DSL 语言中使用不同变量的方法,学习了洁净室的使用方法。