DSL-让你的 Ruby 代码更加优雅
DSL是Ruby这门语言较为广泛的用途之一,不过如果不熟悉Ruby的元编程的话,难免会被这类语法弄得一脸蒙蔽。今天主要就来看看DSL它是个什么东西,它在Ruby社区中地位怎么样,以及如何实现一门简单的DSL。
DSL与GPL
DSL的全称是domain specific language-领域特定语言。顾名思义,它是一种用于特殊领域的语言。我们最熟悉的HTML其实就是专门用于组织页面结构的“语言”,CSS其实就是专门用于调整页面样式的“语言”。SQL语句就是专用于数据库操作的“语句”。不过它们一般也就只能完成自己领域内的事情,别的几乎啥都做不了。就如同你不会想利用一支钢笔去弹奏乐曲或者利用一台钢琴来作画一样。此外,前端领域的最后一位“三剑客”JavaScript曾经也勉强能够算作一门专注于页面交互的DSL,不过随着标准化的推进,浏览器的进化还有进军服务端的宏图大志,它所能做的事情也就渐渐多起来,发展成了一门通用目的的编程语言。
与DSL相对的是GPL(这个简写跟某个开源证书相同),它的全称是general-purpose language-通用目的语言,指被设计来为各种应用领域服务的编程语言。一般而言通用目的编程语言不含有为特定应用领域设计的结构。我们常用的Ruby,Python,C语言都属于这类范畴。它们有自己的专门语法,但是并不限于特定领域。以Python为例子,如今它广泛用于人工智能领域,数据分析领域,Web开发领域,爬虫领域等等。遗憾的是这让许多人产生了一种只有Python才能做这些领域的幻觉。为了在指定的领域能够更加高效的完成工作,一些语言会研发出相应的框架,相关的框架越出色,对语言的推广作用就越好。Rails就是一个很好的例子,Matz也曾经说过
如果没有Ruby On Rails,Ruby绝对不会有如今的流行度。
语言之争也渐渐地演化成框架之争,如果哪天Ruby也开发出一个被广泛接受的人工智能框架,在效率与创新上能够吊打如今的龙头老大,说不定Ruby还能再度火起来吧(我还没睡醒)。不过今天的重点并非语言之争,让咱们再次回到DSL的怀抱中。
简要的DSL
我们遇到不少的Ruby开源库都会有其对应DSL,其中就包括Rspec,Rabl,Capistrano等。今天就以自动化部署工具Capistrano来做个例子。Capistrano的简介如下
A remote server automation and deployment tool written in Ruby.
它的作用可以简单概括为通过定义相关的任务来声明一些需要在服务端完成的工作,并通过限定角色,让我们可以针对特定的主机完成特定的任务。Capistrano的配置文件大概像下面这样
role :demo, %w{example.com example.org example.net}
task :uptime do
on roles(:demo) do |host|
uptime = capture(:uptime)
puts "#{host.hostname} reports: #{uptime}"
end
end
从语义上看它完成了以下工作
- 定义角色列表名为
demo
,列表中包含example.com
,example.org
,example.net
这几台主机。 - 定义名为
uptime
的任务,通过方法on
来定义任务流程以及任务所针对的角色。方法on
的第一个参数是角色列表roles(:demo)
,这个方法还接收代码块,并把主机对象暴露给代码块,借以运行对应的代码逻辑。 - 任务代码块所完成的功能主要是通过
capture
方法在远程主机上运行uptime
命令,并把结果存储到变量中。然后把运行结果还有主机信息打印出来。
这是一个很简单的DSL,工作内容一目了然。但是如果我们不是采用DSL而是用正常的Ruby代码来实现,代码可能会写成下面这样
demo = %w{example.com example.org example.net} # roles list
# uptime task
def uptime(host)
uptime = capture(:uptime)
puts "#{host.hostname} reports: #{uptime}"
end
demo.each do |hostname|
host = Host.find_by(name: hostname)
uptime(host)
end
可见对比起最初的DSL版本,这种实现方式的代码片段相对没那么紧凑,而且有些逻辑会含混不清,只能通过注释来阐明。况且,Capistrano主要用于自动化一些远程作业,其中的角色列表,任务数量一般不会少。当角色较多时我们不得不声明多个数组变量。当任务较多的时候,则需要定义多个方法,然后在不同的角色中去调用,代码将越发难以维护。这或许就是DSL的价值所在吧,把一些常规的操作定义成更清晰的特殊语法,接着我们便可以利用这些特殊语法来组织我们的代码,不仅提高了代码的可读性,还让后续编程工作变得更加简单。
构建一只青蛙
今天不去分析Capistrano的源码,其实我也从来没有读过它的源代码,想要在一篇短短的博客里面完整分析Capistrano的源码未免有点狂妄。记得之前有位大神说过
如果你想要了解一只青蛙,应该去构建它,而不是解剖它。
那么接下来我就尝试按照自己的理解去构建Capistrano的DSL,让我们自己的脚本也可以像Capistrano那样组织代码。
a. 主机类
从DSL中host
变量的行为来看,我们需要把远程主机的关键信息封装到一个对象中去。那么我姑且将这个对象简化成只包含ip
, 主机名
, CPU核数
, 内存大小
这些字段吧。另外我的脚本不打算采用任何持久化机制,于是我会在设计的主机类内部维护一个主机列表,任何通过该类所定义的主机信息都会被追加到列表中,以便日后查找
class Host
attr_accessor :hostname, :ip, :cpu, :memory
@host_list = [] # 所有被定义的主机都会被临时追加到这个列表中
class << self
def define(&block)
host = new
block.call(host)
@host_list << host
host
end
def find_by_name(hostname) # 通过主机名在列表中查找相关主机
@host_list.find { |host| host.hostname == hostname }
end
end
end
以代码块的方式来定义相关的主机信息,然后通过Host#find_by_name
方法来查找相关的主机
Host.define do |host|
host.hostname = happy.com'
host.ip = '192.168.1.200'
host.cpu = '2 core'
host.memory = '8 GB'
end
p Host.find_by_name('happy.com') # => #<Host:0x00007f943b064bc8 @hostname="happy.com", @ip="192.168.1.200", @cpu="1 core", @memory="8 GB">
限于篇幅,这里只做了个粗略的实现,能够存储并查找主机信息即可,接下来继续设计其他的部件。
b. 捕获方法
capture
方法从功能上来看应该是往远程主机发送指令,并获取运行的结果。与远程主机进行通信一般都会采用SSH协议,比如我们想要往远程主机发送系统命令(假设是uptime)的话可以
ssh user@xxx.xxx.xxx.xxx uptime
而在Ruby中要运行命令行指令可以通过特殊语法来包裹对应的系统命令。那么capture
方法可以粗略实现成
def capture(command)
`ssh #{@user}@#{@current_host} #{command}`
end
不过这里为了简化流程,我就不向远端主机发送命令了。而只是打印相关的信息,并始终返回success
状态
def capture(command)
# 不向远端主机发送系统命令,而是打印相关的信息,并返回:success
puts "running command '#{command}' on #{@current_host.ip} by #{@user}"
# `ssh #{@user}@#{@current_host.ip} #{command}`
:success
end
该方法可以接收字符串或者符号类型。假设我们已经设置好变量@user
的值为lan
,而@current_host
的值是192.168.1.218
,那么运行结果如下
capture(:uptime) # => running command 'uptime' on 192.168.1.218 by lan
capture('uptime') # => running command 'uptime' on 192.168.1.218 by lan
c. 角色注册
从代码上来看,角色相关的DSL应该包含以下功能
- 通过
role
配合角色名,主机列表来注册相关的角色。 - 通过
roles
配合角色名来获取角色所对应的主机列表。
这两个功能其实可以简化成哈希表的取值,赋值操作。不过我不想另外维护一个哈希表,我打算直接在当前环境中以可共享变量的方式来存储角色信息。要知道我们平日所称的环境其实就是哈希表,而我们可以通过实例变量
来达到共享的目的
def role(name, list)
instance_variable_set("@role_#{name}", list)
end
def roles(name)
instance_variable_get("@role_#{name}")
end
这样就能够简单地实现角色注册,并在需要的时候再取出来
role :name, %w{ hello.com hello.net }
p roles(:name) # => ["hello.com", "hello.net"]
此外,这个简单的实现有个比较明显的问题,就是有可能会污染当前环境中已有的实例变量。不过一般而言这种几率并不是很大,注意命名就好。
d. 定义任务
在原始代码中我们通过关键字task
,配合任务名还有代码块来划分任务区间。在任务区间中通过关键字on
来定义需要在特定的主机列表上执行的任务。从这个阵仗上来在task
所划分的任务区间中或许可以利用多个on
语句来指定需要运行在不同角色上的任务。我们可以考虑把这些任务都塞入一个队列中,等到task
的任务区间结束之后再依次调用。按照这种思路task
方法的功能反而简单了,只要能够接收代码块并打印一些基础的日志信息即可,当然还需要维护一个任务队列
def task(name)
puts "task #{name} begin"
@current_task = [] # 任务队列
yield if block_given?
@current_task.each(&:call)
puts "task #{name} end"
end
然后是on
方法,它应该能定义需要在特定角色上运行的任务,并且把对应的任务追加到队列中,延迟执行。我姑且把它定义成下面这样
def on(list, &block)
raise "You must provide the block of the task." unless block_given?
@current_task << Proc.new do
host_list = list.map { |name| Host.find_by_name(name) }
host_list.each do |host|
@current_host = host
block.call(host)
end
end
end
e. 测试DSL
相关的DSL已经定义好了,下面来测试一下,从设计上来看需要我们预先设置主机信息,注册角色列表以及具有远程主机权限的用户
# 设定有远程主机权限的用户
@user = 'lan'
# 预设主机信息,一共三台主机
Host.define do |host|
host.hostname = 'example.com'
host.ip = '192.168.1.218'
host.cpu = '2 core'
host.memory = '8 GB'
end
Host.define do |host|
host.hostname = 'example.org'
host.ip = '192.168.1.110'
host.cpu = '1 core'
host.memory = '4 GB'
end
Host.define do |host|
host.hostname = 'example.net'
host.ip = '192.168.1.200'
host.cpu = '1 core'
host.memory = '8 GB'
end
## 注册角色列表
role :app, %w{example.com example.net}
role :db, %w{example.org}
接下来我们通过task
和on
配合上面所设置的基础信息来定义相关的任务
task :demo do
on roles(:app) do |host|
uptime = capture(:uptime)
puts "#{host.hostname} reports: #{uptime}"
puts "------------------------------"
end
on roles(:db) do |host|
uname = capture(:uname)
puts "#{host.hostname} reports: #{uname}"
puts "------------------------------"
end
end
运行结果如下
task demo begin
running command 'uptime' on 192.168.1.218 by lan
example.com reports: success
------------------------------
running command 'uptime' on 192.168.1.200 by lan
example.net reports: success
------------------------------
running command 'uname' on 192.168.1.110 by lan
example.org reports: success
------------------------------
task demo end
这个就是我们所设计的DSL,与Capistrano所提供的基本一致,最大的区别在于我们不会往远程服务器发送系统命令,而是以日志的方式把相关的信息打印出来。从功能上看确实有点粗糙,不过语法上已经达到预期了。
尾声
这篇文章主要简要地介绍了一下DSL,如果细心观察会发现DSL在我们的编码生涯中几乎无处不在。Ruby的许多开源项目会利用语言自身的特征来设计相关的DSL,我用Capistrano举了个例子,对比起常规的编码方式,设计DSL能够让我们的代码更加清晰。最后我尝试按自己的理解去模拟Capistrano的部分DSL,其实只要懂得一点元编程的概念,这个过程还是比较容易的。