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,其實只要懂得一點元編程的概念,這個過程仍是比較容易的。

相關文章
相關標籤/搜索