DSL-讓你的 Ruby 程式碼更加優雅

lanzhiheng發表於2019-04-20

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 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,其實只要懂得一點超程式設計的概念,這個過程還是比較容易的。

相關文章