使用 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)的概念。
洁净室是一个用来执行块的环境。理想的洁净室是不应该有任何的方法以及实例变量的,所以不会产生任何方法名或者变量名的冲突。因此BasicObject
和Object
往往被用来充当洁净室。
实例:
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 语言中使用不同变量的方法,学习了洁净室的使用方法。