by Jim Freeze
英文原文見:http://www.artima.com/rubycs/articles/ruby_as_dsl.html
摘要:
總
的說來,有兩種創建 DSL 的方法。 一種方法是從零開始發明一個新的語法,并為之構造一個編譯器或解釋器。
另一種方法是裁剪一個現存的通用目的語言, 增加或修改方法(methods), 操作符(operators),以及缺省的行為。
本文講述使用第二種方法來在 Ruby 之上創建 DSL.
一個 DSL, 是一個針對相對窄的應用范圍設計的編程或描述語言。相對于通用目的語言, 它被設計來處理特定的計算任務, DSL 僅適用于特定領域。 你可以用兩種方式創建一個 DSL.
- 從零開始發明一個新的語法,并為之構造一個編譯器或解釋器。
- 裁剪一個現存的通用目的語言, 增加或修改方法(method), 操作符(operator),以及缺省的行為。
第
二種方法的優勢是你節省了時間, 因為你不需要生成和調試一個新的語言, 這樣你有更多的時間用于解決最終用戶面臨的問題。缺點是 DSL
會受限于其下的通用目的語言的語法和能力。 更進一步來說,基于另一個語言來構造DSL
常常意味著最終用戶可以獲得這個基礎語言所有的威力,這可能是優點也可能是缺點, 它取決于特定的應用環境。
這篇文章講述怎么使用第二種方法來在Ruby之上創建 DSL.
描述堆疊模型(stackup models)
我
是一個互連模型(interconnect modeling) 工程師,在工作中,
我們需要一種方式來描述半導體晶片上的電路的垂直幾何結構(vertical geometric
profile)。這些描述被保存在一個堆疊模型(stackup model)
文件中。(我們造了一個詞stackup,因為金屬連線是在裝配過程(fabrication
process)中通過層層堆疊形成的)。現在的問題是,每一個供貨商有他們自己的描述堆疊模型(stackup) 的格式,
而我們想要一個公共的格式使得我們可以在不同的格式間進行轉換。 換句話說, 我們需要定義一個公共的堆疊模型(stackup) DSL,
并且寫一個程序用來從我們格式轉換為不同供貨商的特定的格式。
供貨商沒有使用一個復雜的DSL
語言,他們的語言僅僅包含靜態的數據元素于一個基本平坦的文本數據庫中。他們的文件格式不允許有參數化類型(parameterized type),
變量, 常量, 以及等式(equation)。 只有靜態數據。更進一步來說,格式非常簡單。它是基于行的,或者基于只有一個層次的塊結構。
我
們開始描述我們的堆疊模型格式時,要求并不高,因為我們僅僅需要達到供貨商實現的程度就行。但我們很快擴展了我們的格式。為什么我們可以做到這一點,
而我們的供貨商卻做不到。我相信這是因為我們使用了 Ruby, 而不象我們的供貨商用 C 從零開始。我相信也可以用其他的語言,
但是我不認為最終的產品可以同樣出色。通用語言的選擇是關鍵的一步。
我也相信供貨商的開發速度因為使用 C 而受到阻礙,
因為他們要保持他們的堆疊模型的語法盡量簡單以便于分析(parse). 可能并非偶然,很多供貨商在他們的文件格式中使用一些共同的簡單的語法結構。
因為這些簡單的語法結構出現頻繁, 我們將先在 Ruby 中仿制它們, 然后再轉到更復雜的語法結構。
基于行和塊級別的 DSL 結構(constructs)
基于行的結構是一種將一個值或一個范圍的值賦予一個參數(parameter)的方式。 在我們所考察的供貨商的文件中,使用了下面的格式。
1. parameter = value
2. parameter value
3. parameter min_value max_value step_value
除了2 中沒有顯式出現’=’ , 格式1和2 是等價的。 格式3 將一個范圍的值賦給了一個參數。
更為復雜的格式包含一個塊結構。有兩種格式如下所示。 這兩種塊結構可以使用一個基于行的分析器和一個堆棧 ,或一個帶堆棧的關鍵字分析器(key-letter and word parser)來手動分析。
begin
type = TYPE
name = NAME
param1 = value1
param2 = value2
...
end
下面一種塊格式使用 C 風格的大括號{}來標識一個塊, 但參數/值對用空格來分隔。
TYPE NAME {param1 = value1 param2 = value2 }
三次是一個咒語
在我們構建我們的堆疊文件的DSL時, 我們把問題解決了三次。 首先, 我們寫了自己的語法分析器,然后發現那樣的話有太多的工作要維護。不僅僅是代碼,而且還有文檔。因為我們的DSL 足夠復雜,如果沒有足夠文檔,沒有顯然的方法去使用它的所有特性。
接
著,在一個短時間內,我們用 XML實現了 DSL。 這樣,我們不需要去寫自己的語法分析器,因為XML 有現成的分析器。但是
XML引入太多的噪聲,模糊了文件的實際內容 。我們的工程師發現思維在理解堆疊文件的含義和理解XML之間進行切換很困難。由此我認識到,XML
不適合人來閱讀,XML可能不是用來創建DSL 的好的選擇, 盡管我們有不用開發語法分析器(parser)的好處。
最后,我們用Ruby實現了DSL。 因為利用了 Ruby 解釋器的分析功能,實現起來是很快的。我們不需要寫分析器(就是Ruby)的文檔,因為它已經存在。而且,最終的DSL 非常容易被人理解,也很簡潔靈活。
好的。 讓我們用Ruby來創建一個 DSL,該DSL允許我們定義形如‘parameter = value’的語句。請考慮以下假想的DSL 文件。
% cat params_with_equal.dsl
name = fred
parameter = .55
這不是合法的Ruby代碼,我們需要稍微修改一下語法使得Ruby可以接受它。讓我們將它改為:
% cat params_with_equal.dsl
name = "fred"
parameter = 0.55
一旦我們讓DSL 遵循Ruby的語法,Ruby 就為我們做了所有的分析工作,并且提供了一種方式訪問分析的結果。 現在,讓我們寫一些 Ruby 代碼來讀DSL。
首先,我們想要將這些參數用某種方式封裝起來。 一個好的方法是將它們放到一個類中。我們稱這個類為 MyDSL。
% cat mydsl.rb
class MyDSL
...
end#class MyDSL
從開發者的角度看,我們需要一個簡單和直接的方式來分析DSL 文件。就如下面所示:
my_dsl = MyDSL.load(filename)
接著,讓我們來寫類方法 load:
def self.load(filename)
dsl = new
dsl.instance_eval(File.read(filename), filename)
dsl
end
類
方法load 產生一個MyDSL對象, 并且以DSL
文件的內容為參數調用該對象的instance_eval。Instance_eval的第二個參數是可選的, 它使得Ruby
在出現語法分析錯誤時可以報告文件名。 一個可選的第三個參數(沒有使用)可以使Ruby 在出現分析錯誤時能提供錯誤開始的行號
這個代碼能工作嗎? 讓我們看看發生了什么?
% cat dsl-loader.rb
require 'mydsl'
my_dsl = MyDSL.load(ARGV.shift) # put the DSL filename on the command line
p my_dsl
p my_dsl.instance_variables
% ruby dsl-loader.rb params_with_equal.dsl
#
[]
發
生了什么? name 和parameter到那里去了? 這是因為name和parameter在等號的左側,Ruby
認為他們是局部變量。我們可以告訴Ruby它們是實例變量。有兩種方式,一種是使用 self.name = “fred”
self.parameter = 0.55 , 另一種是使用@符號。
@name = "fred"
@parameter = 0.55
但是對我來說,這樣很丑陋。寫成下面的形式也是一樣。
$name = "fred"
$parameter = 0.55
還
有一個辦法讓Ruby 知道這些方法(method)執行的上下文, 那就是利用塊(block)和 yield self(MyDsl的對象實例)
來顯式的聲明作用域。 為了做到這一點,我們將加一個頂層方法來開始我們的DSL, 并且將實際內容放進所附的塊(block)中。 修改過的 DSL
看起來是這樣:
% cat params_with_equal2.dsl
define_parameters do |p|
p.name = "fred"
p.parameter = 0.55
end
define_parameter 已經被定義為一個實例方法(instance method)。
% cat mydsl2.rb
class MyDSL
def define_parameters yield self
end
def self.load(filename) dsl = new
dsl.instance_eval(File.read(filename), filename)
dsl
end end#class MyDSL
修改dsl-loader中的require,讓它使用mydsl2.rb 中的新版本的MyDSL 類:
% cat dsl-loader.rb
require 'mydsl2'
my_dsl = MyDSL.load(ARGV.shift)
p my_dsl
p my_dsl.instance_variables
理論上,這可以工作, 讓我們測試一下。
% ruby dsl-loader.rb params_with_equal2.dsl
params_with_equal2.dsl:2:in `load': undefined method `name=' for # (NoMethodError)
噢,我們忘記了為name 和parameter 定義訪問函數(accessor)。 讓我們加上它們, 然后看一下完整的程序:
% cat mydsl2.rb
class MyDSL
attr_accessor :name, :parameter
def define_parameters
yield self
end
def self.load(filename)
# ... same as before
end end
現在, 再測試一遍。
% ruby dsl-loader.rb params_with_equal2.dsl
#
["@name", "@parameter"]
成
功! 現在工作了。但是我們在DSL文件中加了額外的兩行, 還有額外的 .p ,
這些都引入了噪聲。這樣的記法(notation)更適合于當DSL文件中存在多個層次, 并且需要顯式指定上下文的情況。
在我們的簡單例子里,我們應該隱式的定義上下文, 且讓Ruby 知道name 和parameter 是方法(method)。 讓我們刪掉 ‘=’
, 將DSL 文件寫成:
% cat params.dsl
name "fred"
parameter 0.55
現
在,我們需要為name 和 parameter 定義新的訪問方法(accessor)。這里的竅門是:不帶參數的name
是@name的讀方法(reader), 帶一個或多個參數的name
是@name的寫方法(setter)。(注意:使用這個辦法很方便,即使是DSL文件有多個層次而且上下文是顯式聲明的)。 我們下面為name
和parameter 定義訪問方法, 刪除attr_accessor那一行,加入以下代碼:
% cat mydsl3.rb
class MyDSL
def name(*val) if val.empty? @name
else @name = val.size == 1 ? val[0] : val
end end
def parameter(*val)
if val.empty? @parameter
else @parameters = val.size == 1 ? val[0] : val
end end
def self.load(filename) # ... same as before
end end#class MyDSL
如果 name 或parameter 不帶參數,它們將返回它們的值。如果帶參數:
- 如果帶一個參數,它們會被賦予該參數的值
- 如果帶多個參數,它們會被賦予一個數組,該數組包含所有的參數值
讓我們運行我們的分析器(現在是mydsl3.rb)來測試一下:
% ruby dsl-loader.rb params.dsl
#
["@parameter", "@name"]
又成功了。但是顯式地定義訪問方法( accessors) 很煩人。讓我們定義一個定制的訪問方法,并且讓所有的類都可以使用它。 我們通過將此方法(method)放到 Module class 中來做到這一點。
% cat dslhelper.rb
class Module
def dsl_accessor(*symbols) symbols.each { |sym| class_eval %{ def #{sym}(*val) if val.empty? @#{sym}
else @#{sym} = val.size == 1 ? val[0] : val
end end } } end end
上面的代碼簡單的定義了一個 dsl_accessor 方法, 它是我們的DSL特定的訪問方法。現在我們用它取代attr_accessor:
% cat mydsl4.rb
require 'dslhelper'
class MyDSL
dsl_accessor :name, :parameter
def self.load(filename)
# ... same as before
end end#class MyDSL
再一次,我們更新dsl-loader.rb 中的require 語句,加載mydsl4.rb, 然后運行loader:
% ruby dsl-loader.rb params.dsl
#
["@parameter", "@name"]
一
切都很好。但是如果我不能事先知道參數的名字怎么辦? 在實際使用中,參數名應該可以由用戶來生成。 別害怕。有Ruby 在, 我們可以使用
method_missing 的威力。給 MyDSL加一個兩行的方法, 我們可以用dsl_accessor
根據需要隨時定義新的屬性(attribute)。 也就是說,如果一個值被賦予一個不存在的參數,method_missing 會定義一個
getter 和一個setter ,并且將該值賦予新生成的參數。
% cat mydsl5.rb
require 'dslhelper'
class MyDSL
def method_missing(sym, *args) self.class.dsl_accessor sym
send(sym, *args)
end
def self.load(filename)
# ... Same as before
end end
% head -1 dsl-loader.rb
require 'mydsl5'
% ruby dsl-loader.rb params.dsl
#
["@parameter", "@name"]
哇!是不是感覺很好? 僅僅寫了一點代碼,我們有了一個可以讀和定義任意數目參數的分析器。還可以吧。但是如果最終用戶不知道Ruby,且使用了與現存的Ruby 方法沖突的名字,怎么辦? 舉例來說,如果我們的DSL文件包含以下內容:
% cat params_with_keyword.dsl
methods %w(one two three)
id 12345
% ruby dsl-loader.rb params_with_keyword.dsl
params_with_keyword.dsl:2:in `id': wrong number of arguments (1 for 0) (ArgumentError)
噢,
真不好意思。不過我們可以迅速的解決這個問題。 這里要用到一個類叫BlankSlate, 它最初是由 Jim Weirich構思出來的。
用在這的BlankSlate 和Jim 的有細微的差別,因為我們想要多保留一些功能。 我們將留下七個方法。
你可以試一試看看那些是絕對需要的,那些是用來輔助我們看MyDSL 的對象實例的內容。
% cat mydsl6.rb
require 'dslhelper'
class BlankSlate
instance_methods.each { |m| undef_method(m) unless %w(
__send__ __id__ send class
inspect instance_eval instance_variables
).include?(m)
} end#class BlankSlate
# MyDSL now inherits from BlankSlate
class MyDSL < BlankSlate
# ... nothing new here, move along...
end#class MyDSL
現在我們試一下加載包含關鍵字(keyword)的DSL 文件, 我們會看到一些更合理的東西。
% head -1 dsl-loader.rb
require 'mydsl6'
% ruby dsl-loader.rb params_with_keyword.dsl
#
["@id", "@methods"]
可
以確信, 我們成功了。 這是一個好消息,
我們可以去掉那些沒用的方法,給予我們的最終用戶更自由的使用參數名字的權利。但是不管怎樣,請注意,我們終究不能讓最終用戶完全自由的使用參數名。這是
使用通用編程語言創建DSL的一個缺點, 但我認為,禁止最終用戶使用’class’作為參數名,應該不會給我們的產品銷路帶來多大的風險。
更復雜的DSL
我
們現在來實現更復雜的DSL 特性。 不僅僅操作數據,而且要執行更具體的行為。
想象一下我們厭煩了在每次開始一個新的項目的時候,手動的生成一個通用的目錄集和文件集。
如果Ruby可以幫我們做這些就好了。更進一步,如果我們有一個小的DSL使得我們可以直接修改項目目錄結構而不用去編寫低級的代碼,豈不更好。
我們現在開始為這個問題定義一個DSL。 下面的文件是這個DSL 的0.01 版本:
% cat project_template.dsl
create_project do
dir "bin" do
create_from_template :exe, name
end
dir "lib" do
create_rb_file name
dir name do
create_rb_file name
end
end
dir "test"
touch :CHANGELOG, :README, :TODO
end
在
這個DSL文件里,我們生成了一個項目,在其中加了三個目錄和三個文件。在’bin ‘
目錄中,我們使用’:exe’模板生成了一個與項目名字同名的可執行文件。在’lib’目錄,我們生成了一個.rb 文件和一個目錄,
都與項目名字同名。在這個內部子目錄中,又生成另一個與項目名字同名的’.rb’
文件。最后,在項目頂級目錄下,生成了一個’test’目錄,和三個空文件。
這個DSL需要的方法(method)是:create_project,dir,create_from_template,create_rb_file, 以及 touch。 讓我們逐個的看一下這些方法。
方法create_project是最外層的殼(wrapper)。 這個方法提供了一個作用域讓我們將所有的DSL代碼都放在一個塊(block)中。(完整的代碼列表請看文章的最后)
def create_project()
yield
end
方法dir 完成實質性的工作。該方法不僅僅生成目錄,而且將當前的工作目錄保存在實例變量 @cwd中。 在這里,使用ensure 來保證@cwd 的始終有正確的值。
def dir(dir_name)
old_cwd = @cwd
@cwd = File.join(@cwd, dir_name)
FileUtils.mkdir_p(@cwd)
yield self if block_given?
ensure
@cwd = old_cwd
end
方法touch 和 create_rb_file 基本是一樣的,除了后面一個給文件名加了一個后綴’rb’以外。 這些方法可以接受一個或多個文件名,這些名字可以是字符串或符號(symbols)。
def touch(*file_names)
file_names.flatten.each { |file|
FileUtils.touch(File.join(@cwd, "#{file}"))
}
end
最后,方法create_from_template 是一個粗略的例子用于說明怎么樣可以在一個DSL中實現一些實際的功能。(請看代碼的完整列表)
為了運行這些代碼,我們構建了一個小的測試應用。
% cat create_project.rb
require 'project_builder'
project_name = ARGV.shift
proj = ProjectBuilder.load(project_name)
puts "== DIR TREE OF PROJECT '#{project_name}' =="
puts `find #{project_name}`
運行結果是:
% ruby create_project.rb fred
== DIR TREE OF PROJECT 'fred' ==
fred
fred/bin
fred/bin/fred
fred/CHANGELOG
fred/lib
fred/lib/fred
fred/lib/fred/fred.rb
fred/lib/fred.rb
fred/README
fred/test
fred/TODO
% cat fred/bin/fred
#!/usr/bin/env ruby
require 'rubygems'
require 'commandline
require 'fred'
class FredApp < CommandLine::Application
def initialize
end
def main
end
end#class FredApp
哇!工作得很好。 并且沒費多少力氣。
總結
我做過的很多項目要求一個非常詳細的控制流描述。 在每個項目中,這常常讓我停下來并思考怎么將這些詳細的配置數據引入到應用(application)中。 現在,Ruby作為一個DSL,幾乎是最適合的,而且常常可以非常高效和快速的解決問題。
在
培訓Ruby
的時候,我會讓整個班級用以下方法來解決問題,我們先用英語來描述問題,然后用偽代碼,然后用Ruby。但是,在某些情況下,偽代碼就是合法的
Ruby 代碼。 我認為,Ruby的高度可讀性使得 Ruby是一個可用做DSL的理想語言。 當Ruby 為更多的人所了解, 用Ruby
寫的DSL 將成為一個與應用通信的流行的方式
項目 ProjectBuilder DSL 的代碼列表:
% cat project_builder.rb
require 'fileutils'
class ProjectBuilder
PROJECT_TEMPLATE_DSL = "project_template.dsl"
attr_reader :name
TEMPLATES = {
:exe =>
<<-EOT
#!/usr/bin/env ruby
require 'rubygems'
require 'commandline
require '%name%'
class %name.capitalize%App < CommandLine::Application
def initialize
end
def main
end
end#class %name.capitalize%App
EOT
}
def initialize(name)
@name = name
@top_level_dir = Dir.pwd
@project_dir = File.join(@top_level_dir, @name)
FileUtils.mkdir_p(@project_dir)
@cwd = @project_dir
end
def create_project
yield
end
def self.load(project_name, dsl=PROJECT_TEMPLATE_DSL)
proj = new(project_name)
proj = proj.instance_eval(File.read(dsl), dsl)
proj
end
def dir(dir_name)
old_cwd = @cwd
@cwd = File.join(@cwd, dir_name)
FileUtils.mkdir_p(@cwd)
yield self if block_given?
ensure
@cwd = old_cwd
end
def touch(*file_names)
file_names.flatten.each { |file|
FileUtils.touch(File.join(@cwd, "#{file}"))
}
end
def create_rb_file(file_names)
file_names.each { |file| touch(file + ".rb") }
end
def create_from_template(template_id, filename)
File.open(File.join(@cwd, filename), "w+") { |f|
str = TEMPLATES[template_id]
str.gsub!(/%[^%]+%/) { |m| instance_eval m[1..-2] }
f.puts str
}
end
end#class ProjectBuilder
# Execute as:
# ruby create-project.rb project_name
資源
1.BlankSlate 是一個Ruby class,用于產生沒有方法(method-free)的對象實例。 參見:
http://onestepback.org/index.cgi/Tech/Ruby/BlankSlate.rdoc
2.JimWeirich是BlankSlate 的創建者,也是很多著名的Ruby 工具和庫的創建者。
http://onestepback.org
關于作者
Jim Freeze 從2001年初學習Ruby以來,一直是 Ruby 的熱愛者。
Jim 是 CommandLine and Stax gems 的作者