談談 DSL 以及 DSL 的應用(以 CocoaPods 為例)

因為 DSL 以及 DSL 的界定本身就是一個比較模糊的概念,所以難免有與他人觀點意見相左的地方,如果有不同的意見,我們可以具體討論。

最近在公司做了一次有關 DSL 在 iOS 開發中的應用的分享,這篇文章會簡單介紹這次分享的內容。

這次文章的題目雖然是談談 DSL 以及 DSL 的應用,不過文章中主要側重點仍然是 DSL,會簡單介紹 DSL 在 iOS 開發中(CocoaPods)是如何應用的。

沒有銀彈?

1987 年,IBM 大型電腦之父 Fred Brooks 發表了一篇關於軟體工程中的論文 No Silver Bullet—Essence and Accidents of Software Engineering 文中主要圍繞這麼一個觀點:沒有任何一種技術或者方法能使軟體工程的生產力在十年之內提高十倍。

There is no single development, in either technology or management technique, which by itself promises even one order-of-magnitude improvement within a decade in productivity, in reliability, in simplicity.

時至今日,我們暫且不談銀彈在軟體工程中是否存在(這句話在老闆或者項目經理要求加快項目進度時,還是十分好用的),作為一個開發者也不是很關心這種抽象的理論,我們更關心的是開發效率能否有實質的提升。

而今天要介紹的 DSL 就可以真正的提升生產力,減少不必要的工作,在一些領域幫助我們更快的實現需求。

DSL 是什麼?

筆者是在兩年以前,在大一的一次分享上聽到 DSL 這個詞的,但是當時並沒有對這個名詞有多深的理解與認識,聽過也就忘記了,但是最近做的一些開源項目讓我重新想起了 DSL,也是這次分享題目的由來。

DSL 其實是 Domain Specific Language 的縮寫,中文翻譯為領域特定語言(下簡稱 DSL);而與 DSL 相對的就是 GPL,這裡的 GPL 並不是我們知道的開源許可證,而是 General Purpose Language 的簡稱,即通用編程語言,也就是我們非常熟悉的 Objective-C、Java、Python 以及 C 語言等等。

Wikipedia 對於 DSL 的定義還是比較簡單的:

A specialized computer language designed for a specific task.

為了解決某一類任務而專門設計的計算機語言。

與 GPL 相對,DSL 與傳統意義上的通用編程語言 C、Python 以及 Haskell 完全不同。通用的計算機編程語言是可以用來編寫任意計算機程序的,並且能表達任何的可被計算的邏輯,同時也是 圖靈完備 的。

這一小節中的 DSL 指外部 DSL,下一節中會介紹 內部 DSL/嵌入式 DSL

但是在里所說的 DSL 並不是圖靈完備的,它們的表達能力有限,只是在特定領域解決特定任務的。

A computer programming language of limited expressiveness focused on a particular domain.

另一個世界級軟體開發大師 Martin Fowler 對於領域特定語言的定義在筆者看來就更加具體了,DSL 通過在表達能力上做的妥協換取在某一領域內的高效

而有限的表達能力就成為了 GPL 和 DSL 之間的一條界限。

幾個栗子

最常見的 DSL 包括 Regex 以及 HTML & CSS,在這裡會對這幾個例子進行簡單介紹

  • Regex
    • 正則表達式僅僅指定了字元串的 pattern,其引擎就會根據 pattern 判斷當前字元串跟正則表達式是否匹配。

  • SQL
    • SQL 語句在使用時也並沒有真正的執行,我們輸入的 SQL 語句最終還要交給資料庫來進行處理,資料庫會從 SQL 語句中讀取有用的信息,然後從資料庫中返回使用者期望的結果。
  • HTML & CSS
    • HTML 和 CSS 只是對 Web 界面的結構語義和樣式進行描述,雖然它們在構建網站時非常重要,但是它們並非是一種編程語言,正相反,我們可以認為 HTML 和 CSS 是在 Web 中的領域特定語言。

Features

上面的幾個??明顯的縮小了通用編程語言的概念,但是它們確實在自己領域表現地非常出色,因為這些 DSL 就是根據某一個特定領域的特點塑造的;而通用編程語言相比領域特定語言,在設計時是為了解決更加抽象的問題,而關注點並不只是在某一個領域。

上面的幾個例子有著一些共同的特點:

  • 沒有計算和執行的概念;
  • 其本身並不需要直接表示計算;
  • 使用時只需要聲明規則、事實以及某些元素之間的層級和關係;

雖然了解了 DSL 以及 DSL 的一些特性,但是,到目前為止,我們對於如何構建一個 DSL 仍然不是很清楚。

構建 DSL

DSL 的構建與編程語言其實比較類似,想想我們在重新實現編程語言時,需要做那些事情;實現編程語言的過程可以簡化為定義語法與語義,然後實現編譯器或者解釋器的過程,而 DSL 的實現與它也非常類似,我們也需要對 DSL 進行語法與語義上的設計。

總結下來,實現 DSL 總共有這麼兩個需要完成的工作:

  1. 設計語法和語義,定義 DSL 中的元素是什麼樣的,元素代表什麼意思
  2. 實現 parser,對 DSL 解析,最終通過解釋器來執行

以 HTML 為例,HTML 中所有的元素都是包含在尖括弧 <> 中的,尖括弧中不同的元素代表了不同的標籤,而這些標籤會被瀏覽器解析成 DOM 樹,再經過一系列的過程調用 Native 的圖形 API 進行繪製。

再比如,我們使用下面這種方式對一個模型進行定義,實現一個 ORM 領域的 DSL:

define :article do attr :name attr :content attr :upvotes, :int has_many :commentsend

在上面的 DSL 中,使用 define 來定義一個新的模型,使用 attr 來為模型添加屬性,使用 has_many 建立數據模型中的一對多關係;我們可以使用 DSL 對這段「字元串」進行解析,然後交給代碼生成器來生成代碼。

public struct Article { public var title: String public var content: String public var createdAt: Date public init(title: String, content: String, createdAt: Date) static public func new(title: String, content: String, createdAt: Date) -> Article static public func create(title: String, content: String, createdAt: Date) -> Article? ...}

這裡創建的 DSL 中的元素數量非常少,只有 define attr 以及 has_many 等幾個關鍵字,但是通過這幾個關鍵字就可以完成在模型層需要表達的絕大部分語義。

設計原則和妥協

DSL 最大的設計原則就是簡單,通過簡化語言中的元素,降低使用者的負擔;無論是 Regex、SQL 還是 HTML 以及 CSS,其說明文檔往往只有幾頁,非常易於學習和掌握。但是,由此帶來的問題就是,DSL 中缺乏抽象的概念,比如:模塊化、變數以及方法等。

抽象的概念並不是某個領域所關注的問題,就像 Regex 並不需要有模塊、變數以及方法等概念。

由於抽象能力的缺乏,在我們的項目規模變得越來越大時,DSL 往往滿足不了開發者的需求;我們仍然需要編程語言中的模塊化等概念對 DSL 進行補充,以此解決 DSL 並不是真正編程語言的問題。

在當今的 Web 前端項目中,我們在開發大規模項目時往往不會直接手寫 CSS 文件,而是會使用 Sass 或者 Less 為 CSS 帶來更強大的抽象能力,比如嵌套規則,變數,混合以及繼承等特性。

nav { ul { margin: 0; padding: 0; list-style: none; } li { display: inline-block; } a { display: block; padding: 6px 12px; text-decoration: none; }}

也就是說,在使用 DSL 的項目規模逐漸變大時,開發者會通過增加抽象能力的方式,對已有的 DSL 進行拓展;但是這種擴展往往需要重新實現通用編程語言中的特性,所以一般情況下都是比較複雜的。

Embedded DSL(嵌入式 DSL)

那麼,是否有一種其它的方法為 DSL 快速添加抽象能力呢?而這也就是這一小節的主題,嵌入式 DSL。

在上一節講到的 DSL 其實可以被稱為外部 DSL;而這裡即將談到的嵌入式 DSL 也有一個別名,內部 DSL。

這兩者最大的區別就是,內部 DSL 的實現往往是嵌入一些編程語言的,比如 iOS 的依賴管理組件 CocoaPods 和 Android 的主流編譯工具 Gradle,前者的實現是基於 Ruby 語言的一些特性,而後者基於 Groovy。

CocoaPods 以及其它的嵌入式 DSL 使用了宿主語言(host language)的抽象能力,並且省去了實現複雜語法分析器(Parser)的過程,並不需要重新實現模塊、變數等特性。

嵌入式 DSL 的產生其實模糊了框架和 DSL 的邊界,不過這兩者看起來也沒有什麼比較明顯的區別;不過,DSL 一般會使用宿主語言的特性進行創造,在設計 DSL 時,也不會考慮宿主語言中有哪些 API 以及方法,而框架一般都是對語言中的 API 進行組合和再包裝。

我們沒有必要爭論哪些是框架,哪些是 DSL,因為這些爭論並沒有什麼意義。

Rails 和 Embedded DSL

最出名也最成功的嵌入式 DSL 應該就是 Ruby on Rails 了,雖然對於 Rails 是否是 DSL 有爭議,不過 Rails 為 Web 應用的創建提供大量的內置的支撐,使我們在開發 Web 應用時變得非常容易。

Ruby、 DSL 和 iOS

為了保證這篇文章的完整性,這一小節中有的一些內容都出自上一篇文章 CocoaPods 都做了什麼?。

筆者同時作為 iOS 和 Rails 開發者接觸了非常多的 DSL,而在 iOS 開發中最常見的 DSL 就是 CocoaPods 了,而這裡我們以 CocoaPods 為例,介紹如何使用 Ruby 創造一個嵌入式 DSL。

Why Ruby?

看到這裡有人可能會問了,為什麼使用 Ruby 創造嵌入式 DSL,而不是使用 C、Java、Python 等等語言呢,這裡大概有四個原因:

  • 一切皆對象的特性減少了語言中的元素,不存在基本類型、操作符;
  • 向 Ruby 方法中傳入代碼塊非常方便;
  • 作為解釋執行的語言,eval 模糊了數據和代碼的邊界;
  • 不對代碼的格式進行約束,同時一些約定減少了代碼中的噪音。

一切皆對象

在許多語言,比如 Java 中,數字與其他的基本類型都不是對象,而在 Ruby 中所有的元素,包括基本類型都是對象,同時也不存在運算符的概念,所謂的 1 + 1,其實只是 1.+(1) 的語法糖而已。

得益於一切皆對象的概念,在 Ruby 中,你可以向任意的對象發送 methods 消息,在運行時自省,所以筆者在每次忘記方法時,都會直接用 methods 來「查閱文檔」:

2.3.1 :003 > 1.methods => [:%, :&, :*, :+, :-, :/, :<, :>, :^, :|, :~, :-@, :**, :<=>, :<<, :>>, :<=, :>=, :==, :===, :[], :inspect, :size, :succ, :to_s, :to_f, :div, :divmod, :fdiv, :modulo, ...]

比如在這裡向對象 1 調用 methods 就會返回它能響應的所有方法。

一切皆對象不僅減少了語言中類型的數量,消滅了基本數據類型與對象之間的邊界;這一概念同時也簡化了組成語言的元素,這樣 Ruby 中只有對象和方法,這兩個概念,極大降低了這門語言的複雜度:

  • 使用對象存儲狀態
  • 對象之間通過方法通信

block

Ruby 對函數式編程範式的支持是通過 block,這裡的 block 和 Objective-C 中的 block 有些不同。

首先 Ruby 中的 block 也是一種對象,即 Proc 類的實例,也就是所有的 block 都是 first-class 的,可以作為參數傳遞,返回。

下面的代碼演示了兩種向 Ruby 方法中傳入代碼塊的方式:

def twice(&proc) 2.times { proc.call() } if procenddef twice 2.times { yield } if block_given?end

yield 會調用外部傳入的 block,block_given? 用於判斷當前方法是否傳入了 block。

twice do puts "Hello"endtwice { puts "hello" }

向 twice 方法傳入 block 也非常簡單,使用 do、end 或者 {、} 就可以向任何的 Ruby 方法中傳入代碼塊。

eval

早在幾十年前的 Lisp 語言就有了 eval 這個方法,這個方法會將字元串當做代碼來執行,也就是說 eval 模糊了代碼與數據之間的邊界。

> eval "1 + 2 * 3" => 7

有了 eval 方法,我們就獲得了更加強大的動態能力,在運行時,使用字元串來改變控制流程,執行代碼並可以直接利用當前語言的解釋器;而不需要去手動解析字元串然後執行代碼。

格式和約定

編寫 Ruby 腳本時並不需要像 Python 一樣對代碼的格式有著嚴格的規定,沒有對空行、Tab 的要求,完全可以想怎麼寫就怎麼寫,這樣極大的增加了 DSL 設計的可能性。

同時,在一般情況下,Ruby 在方法調用時並不需要添加括弧:

puts "Wello World!"puts("Hello World!")

這樣減少了 DSL 中的噪音,能夠幫助我們更加關心語法以及語義上的設計,降低了使用者出錯的可能性。

最後,Ruby 中存在一種特殊的數據格式 Symbol:

> :symbol.to_s => "symbol"> "symbol".to_sym => :symbol

Symbol 可以通過 Ruby 中內置的方法與字元串之間無縫轉換。那麼作為一種字元串的替代品,它的使用也能夠降低使用者出錯的成本並提升使用體驗,我們並不需要去寫兩邊加上引號的字元串,只需要以 : 開頭就能創建一個 Symbol 對象。

Podfile 是什麼

對 Ruby 有了一些了解之後,我們就可以再看一下使用 CocoaPods 的工程中的 Podfile 到底是什麼了:

source GitHub - CocoaPods/Specs: The CocoaPods Master Repotarget Demo do pod Mantle, ~> 1.5.1 ...end

如果不了解 iOS 開發後者沒有使用過 CocoaPods,筆者在這裡簡單介紹一下這個文件中的一些信息。

source 可以看作是存儲依賴元信息(包括依賴的對應的 GitHub 地址)的源地址;

target 表示需要添加依賴的工程的名字;

pod 表示依賴,Mantle 為依賴的框架,後面是版本號。

上面是一個使用 Podfile 定義依賴的一個例子,不過 Podfile 對約束的描述其實是這樣的:

source(GitHub - CocoaPods/Specs: The CocoaPods Master Repo)target(Demo) do pod(Mantle, ~> 1.5.1) ...end

Podfile 中對於約束的描述,其實都可以看作是代碼的簡寫,在解析時會當做 Ruby 代碼來執行。

簡單搞個 Embedded DSL

使用 Ruby 實現嵌入式 DSL 一般需要三個步驟,這裡以 CocoaPods 為例進行簡單介紹:

  • 創建一個 Podfile 中「代碼」執行的上下文,也就是一些方法;
  • 讀取 Podfile 中的內容到腳本中;
  • 使用 eval 在上下文中執行 Podfile 中的「代碼」;

原理

CocoaPods 對於 DSL 的實現基本上就是我們創建一個 DSL 的過程,定義一系列必要的方法,比如 source、pod 等等,創造一個執行的上下文;然後去讀存儲 DSL 的文件,並且使用 eval 執行。

信息的傳遞一般都是通過參數來進行的,比如:

source GitHub - CocoaPods/Specs: The CocoaPods Master Repo

source 方法的參數就是依賴元信息 Specs 的 Git 地址,在 eval 執行時就會被讀取到 CocoaPods 中,然後進行分析。

實現

下面是一個非常常見的 Podfile 內容:

source http://source.gitplatform :ios, 8.0target Demo do pod AFNetworking pod SDWebImage pod Masonry pod "Typeset" pod BlocksKit pod Mantle pod IQKeyboardManager pod IQDropDownTextFieldend

因為這裡的 source、platform、target 以及 pod 都是方法,所以在這裡我們需要構建一個包含上述方法的上下文:

# eval_pod.rb$hash_value = {}def source(url)enddef target(target)enddef platform(platform, version)enddef pod(pod)end

使用一個全局變數 hash_value 存儲 Podfile 中指定的依賴,並且構建了一個 Podfile 解析腳本的骨架;我們先不去完善這些方法的實現細節,先嘗試一下讀取 Podfile 中的內容並執行 eval 看看會不會有問題。

在 eval_pod.rb 文件的最下面加入這幾行代碼:

content = File.read ./Podfileeval contentp $hash_value

這裡讀取了 Podfile 文件中的內容,並把其中的內容當做字元串執行,最後列印 hash_value 的值。

$ ruby eval_pod.rb

運行這段 Ruby 代碼雖然並沒有什麼輸出,但是並沒有報出任何的錯誤,接下來我們就可以完善這些方法了:

def source(url) $hash_value[source] = urlenddef target(target) targets = $hash_value[targets] targets = [] if targets == nil targets << target $hash_value[targets] = targets yield if block_given?enddef platform(platform, version)enddef pod(pod) pods = $hash_value[pods] pods = [] if pods == nil pods << pod $hash_value[pods] = podsend

在添加了這些方法的實現之後,再次運行腳本就會得到 Podfile 中的依賴信息了,不過這裡的實現非常簡單的,很多情況都沒有處理:

$ ruby eval_pod.rb{"source"=>"http://source.git", "targets"=>["Demo"], "pods"=>["AFNetworking", "SDWebImage", "Masonry", "Typeset", "BlocksKit", "Mantle", "IQKeyboardManager", "IQDropDownTextField"]}

不過使用 Ruby 構建一個嵌入式 DSL 的過程大概就是這樣,使用語言內建的特性來進行創作,創造出一個在使用時看起來並不像代碼的 DSL。

寫在後面

在最後,筆者想說的是,當我們在某一個領域經常需要解決重複性問題時,可以考慮實現一個 DSL 專門用來解決這些類似的問題。

而使用嵌入式 DSL 來解決這些問題是一個非常好的辦法,我們並不需要重新實現解釋器,也可以利用宿主語言的抽象能力。

同時,在嵌入式 DSL 擴展了 DSL 的範疇之後,不要糾結於某些東西到底是框架還是領域特定語言,這些都不重要,重要的是,在遇到了某些問題時,我們能否跳出來,使用文中介紹的方法減輕我們的工作量。

Reference

  • No Silver Bullet—Essence and Accidents of Software Engineering
  • Domain-specific language
  • DomainSpecificLanguage
  • How browsers work

其它

GitHub Repo:iOS-Source-Code-Analyze

Follow: Draveness · GitHub

Source: 談談 DSL 以及 DSL 的應用(以 CocoaPods 為例)

推薦閱讀:

iPhone 可以識別並記住不同耳機的最後音量是什麼原理?
iOS開發質量的那些事
iOS 的融合與 AR 的崛起
[貝聊科技]AsyncDisplayKit近一年的使用體會及疑難點
破一下 Apple 與 PRC 領土有關的兩條謠言

TAG:iOS | Ruby | DS谛艾仕 |