閱讀本文不需要預先掌握 Ruby 與 DSL 相關的知識
何為 DSL
DSL(Domain Specific Language) 翻譯成中文就是:“領域特定語言”。首先,從定義就可以看出,DSL 也是一種程式語言,只不過它主要是用來處理某個特定領域的問題。
廣為人知的程式語言有 C、Java、PHP 等,他們被稱為 GPL(General Purpose Language),即通用目的語言。與這些語言相比,DSL 相對顯得比較神祕,他們中的大多數甚至連一個名字都沒有。這主要是因為 DSL 通常用來處理某個特定的、很小的領域中的問題,因此起名字這事沒有太大的必要和意義。
說了這麼多廢話, 一定有讀者在想:“能不能舉個例子講解一下,什麼是 DSL”。實際上,DSL 只是對一類語言的描述,它可以非常簡單:
UIView (0, 0, 100, 100) black
UILabel (50, 50, 200, 200) yellow
……複製程式碼
比如這就是我自己隨便編的一個語言。它的語法看上去很奇怪,不過這不是重點。語言的根本目的是傳遞資訊。
為什麼要用 DSL
其實從上面的程式碼中已經可以比較出 DSL 和 GPL 的特點了。DSL 語法更加簡潔,比如可以沒有括號(這取決於你如何設計),因此開發、閱讀的效率更高。但作為代價,DSL 除錯很麻煩,很難做型別檢查,因此幾乎難以想象可以用 DSL 開發一個大型的程式。
如果同時接觸過編譯型語言和指令碼語言,你可以把 DSL 理解為一種比指令碼語言更加輕量、靈活的語言。
DSL 的執行過程
瞭解過 C 語言的開發者應該知道,從 C 語言原始碼到最後的可執行檔案,需要經過預編譯、編譯(詞法分析、語法分析、語義分析)、彙編、連結等步驟,最終生成 CPU 相關的機器碼,也就是一堆 0 和 1。
指令碼語言不需要編譯(有些也可以編譯),他們在執行時被解釋,當然也需要做詞法分析和語法分析,最終生成機器碼。
於是問題來了,自定義的 DSL 如何被執行呢?
對於詞法分析和語法分析,由於語言簡單,通常只是少數關鍵字,即使使用最簡單的字串解析,工作量和複雜度也在可接受的範圍內。然而最後生成彙編程式碼就顯得不是很有必要了,DSL 的特點不是追求執行效率,而是高效,對開發者友好。
因此一種常見的做法是,用別的語言(可以理解為宿主語言)來解析 DSL,並執行宿主語言。繼續以上面的 DSL 為例,我們可以用 OC 讀取這個文字檔案,瞭解到我們要建立一個 UIView
物件,因此會執行以下程式碼:
UIView *view = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)];
view.backgroundColor = [UIColor blackColor];複製程式碼
如何實現 DSL
可以看到,DSL 的定義與實現毫無技術難度可言,與其說是一門語言,不如說是一種資訊的標記格式,一種儲存資訊的協議。從這個角度來說,JSON、XML 等資料格式也可以被稱為 DSL。
然而,隨著關鍵字數量的增多,對 DSL 的解析難度迅速提高。舉個簡單的例子,Cocoa 框架下的控制元件型別有很多,因此在解析上述 DSL 時就需要考慮很多情況。這顯然與 DSL 的初衷不符。
有沒有一種快速實現 DSL 的方法呢?選擇 ruby 一定程度上可以解決上述問題。在解釋為什麼偏偏選擇 ruby 之前,首先介紹一些基礎知識。
Ruby
這篇文章不是用來介紹 Ruby 語法的,感興趣的讀者可以閱讀 《七週七語言》 或者 《松本行弘的程式世界》這兩本書的前面幾個章節來入門 Ruby,進階教程推薦 《Ruby 超程式設計》。
本文主要介紹為何 Ruby 經常作為宿主語言,被用來實現 DSL,用一句話概括就是:
DSL 其實就是 Ruby 程式碼
上文說過,實現 DSL 的主要難度在於利用宿主語言解析 DSL 的語法,而藉助 Ruby 實現的 DSL,其本身就是 Ruby 程式碼,只是看起來比較像 DSL。這樣在執行的時候,我們完全藉助了 Ruby 直譯器的力量,而不需要手動分析其中的語法結構。
借用 Creating a Ruby DSL 這篇文章中的例子,假設我們想寫一段 HTML 程式碼:
<html>
<body>
<div id="container">
<ul class="pretty">
<li class="active">Item 1</li>
<li>Item 2</li>
</ul>
</div>
</body>
</html>複製程式碼
但又感覺手寫程式碼太麻煩,希望簡化它,所以使用一個自創的 DSL:
html = HTMLMaker.new.document do
body do
div id: "container" do
ul class: "pretty" do
li "Item 1", class: :active
li "Item 2"
end
end
end
end
# 這個 html 變數是一個字串,值就是上面的 HTML 文件複製程式碼
不熟悉 Ruby 語法的讀者可能無法一眼看出這段看上去像是文字的內容,其實是 Ruby 程式碼。為什麼偏偏是 Ruby,而不是 Objective-C 或者 C++ 這些語言呢?我總結為以下兩點:
- Ruby 自身的語法特性
- Ruby 具備超程式設計的能力
語法簡介
首先,Ruby 呼叫函式可以不用把引數放在括號中:
def say(word)
puts word
end
say "Hello"
# 呼叫 say 函式會輸出 "Hello"複製程式碼
這就保證了語法的簡潔,看上去像是一門 DSL。
另外要提到的一點是 Ruby 中的閉包。與 Objective-C 和 Swift 不同的是,Ruby 的閉包可以寫在 do … end
程式碼塊,而不是必須放在大括號中:
(1..10).each do |i|
puts "Number = #{i}"
end
# 輸出十行,每行的格式都是 "Number = i"複製程式碼
大括號看上去就像是一門比較複雜的語言,而 do … end
會更容易閱讀一些。
Ruby 超程式設計
超程式設計是 Ruby 的精髓之一。我們見過很多以“元”開頭的單詞,比如 “後設資料”、“元類”、“元資訊”。這些詞彙看上去很難理解,其實只要把 “元xx” 當做 “關於xx的xx”,就很容易理解了。
以後設資料為例,它表示“關於資料的資料”。比如我的 ID 是 bestswifter,它是一個資料。我還可以說這個單詞中有兩個字母 s,一共有 11 個字母等等。這些也是資料,並且是關於資料(bestswifter)的資料,因此可以被稱為後設資料。
在 runtime 中經常提到的元類,也就是關於類的類。所以儲存了類的方法和屬性。
而所謂的超程式設計,自然指的就是“關於程式設計的程式設計”。程式設計是指用一段程式碼輸出某個結果,而關於程式設計的程式設計則可以理解為通過程式設計的方式來產生這段程式碼。
在實際開發時,超程式設計通常以兩種形式體現出他的威力:
- 提供反射的功能,通過 API 來提供對執行時環境的訪問和修改能力
- 提供執行字串格式程式碼的能力
在 Ruby 中,我們可以隨意為任何一個類新增、修改甚至刪除方法。呼叫不存在方法時,可以統一進行轉發:
class TestMissing
def method_missing(m, *args, &block)
puts "方法名:#{m},引數:#{args},閉包:#{block}"
end
end
TestMissing.new.say "Hello", "World" do
puts "Hello, world"
end
# 方法名:say,引數:["Hello", "World"],閉包:#<Proc:0x007feeea03cb00@t.ruby:7>複製程式碼
可見,當呼叫不存在的方法 say
時,會被轉發到類的 method_missing
方法中,並且可以很容易的獲取到方法名稱和引數。
有一定 iOS 開發經驗的讀者會立刻想到,這哪是超程式設計,明明就是 runtime。確實,相比於靜態語言比如 Java、Swift 的反射機制而言,Objective-C 的 runtime 提供了更強大的功能,它不僅可以自省,還能動態的進行修改。當然這也是由語言特性決定的,對於靜態語言來說,早在編譯時期就生成了機器碼,並且隨後進行連結,能提供一個反射機制就很不錯了,至於修改還是不要奢望。
實際上,如果我們廣義的把超程式設計理解為:“關於程式設計的程式設計”,那麼 runtime 可以理解為一種超程式設計的實現方式。如果狹義的把超程式設計理解為用程式碼生成程式碼,並且動態執行,那 runtime 就不算了。
利用 Ruby 實現 DSL
分別介紹了 DSL 和 Ruby 的基礎概念後,我們就可以著手利用 Ruby 來實現自己的 DSL 了。
以上文生成 HTML 的 DSL 為例進行分析,為了說明問題,我把程式碼再次簡化一下:
html = HTMLMaker.new.document do
body do
div id: "container"
end
end複製程式碼
首先我們要定義一個 HTMLMaker
類,並且把 document
方法作為入口。這個方法接收一個閉包,閉包中呼叫 body
函式,這個函式也提供了閉包,閉包中呼叫了 div
方法,並且有一個引數 id: "container"
……
可見這其實是一個遞迴呼叫,無論是 body
還是 div
,他們對應著 HTML 標籤,其實都是一些並列的方法,方法可以接受若干個鍵值對,也就是 HTML 中標籤的屬性,最後再跟上一個閉包用來建立隸屬於自己的子標籤。
如果不用 Ruby,我們需要事先知道所有的 HTML 標籤名,然後進行匹配,可想工作量有多大。而在 Ruby 中,他們都是並列關係,可以統一轉發到 method_missing
方法中,獲取方法名、引數和閉包。
我們首先解析引數,配合方法名拼湊出當前標籤的字串,然後遞迴呼叫閉包即可,核心程式碼如下:
def method_missing(m, *args, &block)
tag(m, args, &block)
end
def tag(html_tag, args, &block)
# indent 用來記錄行首的空格縮排
# options 表示解析後的 HTML 屬性,比如 id="container", content 則是標籤中的內容
html << "
#{indent}<#{html_tag}#{options}>#{content}"
if block_given? # 如果傳遞了閉包,遞迴執行
instance_eval(&block) # 遞迴執行閉包
html << "
#{indent}"
end
html << "</#{html_tag}>"
end複製程式碼
這裡的 instance_eval
也是一種超程式設計,表示以當前例項為上下文,執行閉包,具體用途可以參考這篇文章: Eval, module_eval, and instance_eval。
完整的程式碼意義不大,主要是細節的處理,如果感興趣,或者沒有完全理解上面這段程式碼的意思,可以去原文中檢視程式碼,並且自行除錯。
Ruby 在 iOS 開發中的運用
Ruby 主要用來實現一些自動化指令碼,而且由於 iOS 系統上沒有 Ruby 直譯器,所以它通常是在 Mac 系統上使用,在編譯前(絕非 app 的執行時)進行一些自動化工作。
大家最熟悉的 Cocoapods 的 podfile 其實就是一份 Ruby 程式碼:
target `target_name` do
pod `pod_name`, `~> version`
end複製程式碼
熟悉的 do end
程式碼塊告訴我們,這段宣告式的 pod 依賴關係,其實就是可以執行的 ruby 程式碼。Cocoapods 的具體實現原理可以參考 @draveness 的這篇文章: CocoaPods 都做了什麼?
在 Ruby On Rails 中有一個著名的模組: ActiveRecord,它提供了物件關係對映的功能(ORM)。
在物件導向的語言中,我們用物件來儲存資料,物件是類的例項,而在關係型資料庫中,資料的抽象模型叫實體(Entity)。類和實體在一定程度上有相似性,比如都可以擁有多個屬性,類對屬性的增刪改查操作由類對外暴露的方法實現,在關係型資料庫中則是由 SQL 語句實現。
ORM 提供了物件和實體之間的對應關係,我們不再需要手寫 SQL 語句,而是直接呼叫物件的相關方法, 這些方法的內部會生成相應的 SQL 語句並執行。可以說 ORM 框架遮蔽了資料庫的具體細節, 允許我們以物件導向的方式對資料進行持久化操作。
MetaModel
MetaModel 這個框架借鑑了 ActiveRecord 的功能,致力於打造一個 iOS 開發中的 ORM 框架。
在沒有 ORM 時,假設有一個 Person
類,它有若干個屬性。即使我們利用繼承等物件導向的特性封裝好了大量模板方法,每當增加或刪除屬性時,程式碼改動量依然不算小。考慮到實體之間還有一對一、一對多、多對多等關係,一旦關係發生變化,相關程式碼的變化會更大。
MetaModel 的原理就是利用 ruby 實現了一個 DSL,在 DSL 中規定了每個實體的屬性和關係,這也是開發者唯一需要關心的內容。接下來的任務將完全由 MetaModel 負責,首先它會解析每個實體有哪些屬性,和別的實體有哪些關係,然後生成對應的 Swift/Objective-C 程式碼,打包成靜態庫,最終以物件導向的方式向開發者暴露增刪改查的 API。
在實際使用時,我們首先要寫一個 Metafile 檔案,它類似於 Podfile,用於規定實體的屬性和關係:
define :Article do
attr :title
attr :content
has_many :comments
end
define :Comment do
attr :content
belongs_to :article
end複製程式碼
執行完 MetaModel 的指令碼後,就會生成相關程式碼,並封裝在靜態庫中,然後可以這樣呼叫:
let article = Article.create(title: "title1", content: "content1")
article.save // 執行 INSERT 語句
article.update(title: "newTitle")
let anotherArticle = Article.find(content:"content1")
print(Article.all)複製程式碼
MetaModel 的實現原理並不複雜,但真的做起來,還是要考慮很多細節,本文不對它的內部實現做過多分析。MetaModel 已經開源,正在不斷的完善中,想了解具體使用步驟或參與到 MetaModel 完善工作中的朋友,可以開啟這個頁面。