DSL-让你的 Ruby 代码更加优雅

DSL是Ruby这门语言较为普遍的用途之一,不过若是不熟悉Ruby的元编程的话,不免会被这类语法弄得一脸蒙蔽。今天主要就来看看DSL它是个什么东西,它在Ruby社区中地位怎么样,以及如何实现一门简单的DSL。html

DSL与GPL

DSL的全称是domain specific language-领域特定语言。顾名思义,它是一种用于特殊领域的语言。咱们最熟悉的HTML其实就是专门用于组织页面结构的“语言”,CSS其实就是专门用于调整页面样式的“语言”。SQL语句就是专用于数据库操做的“语句”。不过它们通常也就只能完成本身领域内的事情,别的几乎啥都作不了。就如同你不会想利用一支钢笔去弹奏乐曲或者利用一台钢琴来做画同样。此外,前端领域的最后一位“三剑客”JavaScript曾经也勉强可以算做一门专一于页面交互的DSL,不过随着标准化的推动,浏览器的进化还有进军服务端的宏图大志,它所能作的事情也就渐渐多起来,发展成了一门通用目的的编程语言。前端

与DSL相对的是GPL(这个简写跟某个开源证书相同),它的全称是general-purpose language-通用目的语言,指被设计来为各类应用领域服务的编程语言。通常而言通用目的编程语言不含有为特定应用领域设计的结构。咱们经常使用的Ruby,Python,C语言都属于这类范畴。它们有本身的专门语法,可是并不限于特定领域。以Python为例子,现在它普遍用于人工智能领域,数据分析领域,Web开发领域,爬虫领域等等。遗憾的是这让许多人产生了一种只有Python才能作这些领域的幻觉。为了在指定的领域可以更加高效的完成工做,一些语言会研发出相应的框架,相关的框架越出色,对语言的推广做用就越好。Rails就是一个很好的例子,Matz也曾经说过git

若是没有Ruby On Rails,Ruby绝对不会有现在的流行度。github

语言之争也渐渐地演化成框架之争,若是哪天Ruby也开发出一个被普遍接受的人工智能框架,在效率与创新上可以吊打现在的龙头老大,说不定Ruby还能再度火起来吧(我还没睡醒)。不过今天的重点并不是语言之争,让我们再次回到DSL的怀抱中。数据库

简要的DSL

咱们遇到很多的Ruby开源库都会有其对应DSL,其中就包括RspecRablCapistrano等。今天就以自动化部署工具Capistrano来作个例子。Capistrano的简介以下编程

A remote server automation and deployment tool written in Ruby.
复制代码

它的做用能够简单归纳为**经过定义相关的任务来声明一些须要在服务端完成的工做,并经过限定角色,让咱们能够针对特定的主机完成特定的任务。**Capistrano的配置文件大概像下面这样api

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
复制代码

从语义上看它完成了如下工做数组

  1. 定义角色列表名为demo,列表中包含example.comexample.orgexample.net这几台主机。
  2. 定义名为uptime的任务,经过方法on来定义任务流程以及任务所针对的角色。方法on的第一个参数是角色列表roles(:demo),这个方法还接收代码块,并把主机对象暴露给代码块,借以运行对应的代码逻辑。
  3. 任务代码块所完成的功能主要是经过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的价值所在吧,把一些常规的操做定义成更清晰的特殊语法,接着咱们即可以利用这些特殊语法来组织咱们的代码,不只提升了代码的可读性,还让后续编程工做变得更加简单。ruby

构建一只青蛙

今天不去分析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应该包含如下功能

  1. 经过role配合角色名,主机列表来注册相关的角色。
  2. 经过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}
复制代码

接下来咱们经过taskon配合上面所设置的基础信息来定义相关的任务

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,其实只要懂得一点元编程的概念,这个过程仍是比较容易的。

相关文章
相关标签/搜索