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

lanzhiheng · April 21, 2019 · Last by Repairtech replied at July 25, 2019 · 11340 hits
Topic has been selected as the excellent topic by the admin.

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,其中就包括RspecRablCapistrano等。今天就以自动化部署工具 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

从语义上看它完成了以下工作

  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 的价值所在吧,把一些常规的操作定义成更清晰的特殊语法,接着我们便可以利用这些特殊语法来组织我们的代码,不仅提高了代码的可读性,还让后续编程工作变得更加简单。

构建一只青蛙

今天不去分析 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 [email protected] 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,其实只要懂得一点元编程的概念,这个过程还是比较容易的。

Reply to rcer

感谢分享,我感觉 ying 神这篇文章讲得还是比较周到的。从他的观点上来看,其实 Ruby 里面这些 DSL 也不能算是真正意义上的 DSL,实质上并没有扩展 Ruby 的语义,本质上还是方法定义罢了。

银王说的我也很赞同。额外增加的学习成本是不能忽视的。

4 Floor has deleted

如果世界上没有 DSL,我觉得会更优雅

dsl 一时爽,换人维护火葬场。

jasl mark as excellent topic. 15 May 01:24

dsl 基本都可以使用配置替代。感觉 ruby 的 dsl 并不一定比其他语言强多少。ruby 唯一设计好的地方是很人性。

You need to Sign in before reply, if you don't have an account, please Sign up first.