Sourcery - Swift超程式設計實踐,告別樣板程式碼

L-Zephyr發表於2018-04-23

前段時間發現了一個十分強大的工具:Sourcery,它很好的解決了我在Swift開發中遇到的一些問題,在中文社群中sourcery似乎並不是很有名,所以這裡特地寫一篇文章來作介紹。本文大致分為三個部分:

  • 超程式設計的概念和作用
  • Sourcery的原理和基本使用
  • Sourcery和Codable的實踐

什麼是超程式設計

很多人可能對超程式設計(meta-programming)這個概念比較陌生,當然有一部分是因為翻譯的問題,這個“元”字看起來實在是雲裡霧裡。如果用一句話來解釋,所謂超程式設計就是用程式碼來生成程式碼

這句話可以從兩個層面上來理解:

  • 在執行時通過反射之類的技術來動態修改程式自身的結構。比如說我們都非常熟悉的Objective-C Runtime。
  • 通過DSL來生成特定的程式碼,這通常發生在編譯期預處理階段。

OC有著十分強大的Runtime特性,在執行時可以檢視和修改一個物件的所有成員,所以有了Mantle之類JSON轉Model的庫;甚至可以在執行時新增、刪除、替換一個型別中的方法,當然也可以動態的新增型別,所以有了AspectsAOP。這些應用都可以歸納為超程式設計的範疇,因為它們的功能都是通過在執行時修改程式本身來實現的,這一特性為我們節省了很多重複的樣板程式碼。

而Swift是一門靜態強型別語言,沒有OC這樣強大的執行時特性,雖然Swift也可以接入OC Runtime,但是那很容易讓你的程式碼變成“用Swift寫的OC”,而且對執行時的修改容易讓程式變得難以理解。既然這樣,再來看看Swift自身的反射機制,Swift提供了一個名為Mirror的型別用來在執行時檢查物件的屬性,但是一方面Mirror只能檢視不能修改,另一方面它的效能很差,文件中也建議僅在Debug的時候使用。

所以說第一條路子在Swift中是走不通了,只能從另一個方面來尋找答案,所幸的是已經有了一套成熟的解決方案,那就是下面要介紹的Sourcery。

Sourcery

簡單來說Sourcery是一個Swift程式碼的生成器,它能夠根據我們預先定義好的模板來自動生成Swift程式碼。

基本使用

定義模板

以官方的Demo為例,比如說你有一個自定義的型別:

struct Person {
	var name: String
    var age: Int
}
複製程式碼

想要為這個型別實現Equatable協議,必須在==方法中依次比較每一個屬性的相等性:

extension Person {
	static func ==(lhs: Person, rhs: Person) -> Bool {
        guard lhs.name == rhs.name else { return false }
        guard lhs.age == rhs.age else { return false }
        return true
    }
}
複製程式碼

通常我們的專案中都會有大量的Model型別,如果要為它們都實現Equatable,會帶來大量重複的工作。而且如果你在一個型別中新增了新的屬性的話,必須同步修改它的Equatable實現,否則可能會出現難以預料的Bug。

Sourcery可以將我們從這些繁瑣的樣板程式碼中解放出來,首先我們需要為所有的Equatable實現定義一個統一的模板,這部分是通過一門名為Stencil的語言來編寫的。Stencil是一門專門為Swift設計的模板語言,語法十分簡單,對於上面程式碼可以定義這樣的模板(模板的編寫推薦使用vscode加上stencil外掛):

{% for type in types.implementing.AutoEquatable %}
extension {{type.name}}: Equatable {
    static func ==(lhs: {{type.name}}, rhs: {{type.name}}) -> Bool {
        {% for variable in type.storedVariables %}
        guard lhs.{{variable.name}} == rhs.{{variable.name}} else { return false }
        {% endfor %}
        return true
    }
}
{% endfor %}
複製程式碼

程式碼中出現的AutoEquatable是預先定義在我們自己程式碼中的一個協議,只是一個作為標記用的空協議:

protocol AutoEquatable { }
複製程式碼

它的作用是讓我們能夠在模板中找到需要的型別,只需將自定義的Person型別宣告為實現AutoEquatable,之後在模板中就可以通過types.implementing.AutoEquatable找到目標型別,然後通過type.storedVariables來遍歷型別中的所有儲存屬性生成對應的比較程式碼。

程式碼生成

定義了模板之後就可以通過這個模板來生成程式碼了,首先在系統中安裝Sourcery:brew install sourcery。之後執行下面的指令:

sourcery \
   --sources ./YourProject \
   --templates ./YourTemplates \
   --output ./YourProject/AutoGenerated.swift
複製程式碼

其中--source指定了工程的根目錄,--templates指定存放模板檔案的目錄,--output將生成的程式碼輸出到指定路徑,除了命令列也可以通過一個.sourcery.yml檔案來定製引數,這裡就不再展開介紹了。

之後就能在工程的路徑下看到一個名為AutoGenerated.swift的程式碼檔案,它包含了這樣的內容:

// Generated using Sourcery 0.12.0 — https://github.com/krzysztofzablocki/Sourcery
// DO NOT EDIT
extension Person {
	static func ==(lhs: Person, rhs: Person) -> Bool {
        guard lhs.name == rhs.name else { return false }
        guard lhs.age == rhs.age else { return false }
        return true
    }
}
複製程式碼

生成的程式碼檔案是需要參與編譯的,記得將它新增到工程中。

接著,我們可以將程式碼生成這一步整合到Xcode的編譯流程中,在Build Phases新增這樣一個指令碼(這裡我把sourcery二進位制檔案也加到了工程目錄中):

Run Script

需要注意的是這個指令碼一定要新增在Compile Sources之前,否則新生成的程式碼無法參與編譯。在這之後只要我們的型別實現了AutoEquatable,無論是新增還是刪除屬性,每次Build程式碼就會自動更新,免去了手動修改的困擾。

以上的Equatable只是作為示例,完整的版本請看官方提供的這個模板AutoEquatable.stencil

原理

從上面的例子中可以看出來,Sourcery之所以如此強大,關鍵在於模板解析時能夠獲取我們程式碼中的所有型別資訊,這使我們在編寫模板的時候獲得了極大的自由度。Sourcery使用了兩個關鍵的技術來實現這一切:Stencil和SourceKitten。

Stencil

在之前的介紹中也提到了,Stencil是一門用Swift實現的專門為Swift設計的模板語言,它的語法十分簡單,只解析下面這三種語法模式:

  • {{ ... }}:變數語法,將中間的部分作為變數(或變數的表示式)來解析,解析後的值會作為結果插入到模板中的相應位置上。
  • {% ... %}:標籤語法(Tag),標籤用來表示一些具有特殊功能的語法,比如用來實現判斷的if和迴圈的for
  • {# ... #}:註釋語法,不會出現在解析後的結果中。

除此之外還有一個名為Filter的概念,它的語法是這樣的:{{ "stencil"|uppercase }}。符號|左邊是輸入的變數,右邊就是一個Filter,這裡輸出了字串的大寫形式。Filter本質上是一個輸入和輸出都是Any的方法,比如說上面的uppercase在原始碼中對應是這樣的:

registerFilter("uppercase", filter: uppercase) // 注入一個Filter

func uppercase(_ value: Any?) -> Any? {
    return stringify(value).uppercased()
}
複製程式碼

同樣模板解析時可以訪問的變數也是在執行時注入到Stencil環境中的。Stencil有著十分強大的擴充套件性,github上有一個這樣的庫StencilSwiftKit,為Stencil擴充套件了許多更加便捷的語法。

SourceKit

Xcode對Swift和OC的處理有一點不同的地方,OC的編譯器是在Xcode程式中執行的,而Swift的編譯器是在一個獨立的程式中進行的,所涉及到的一系列編譯工具的集合稱為SourceKit,編譯的結果通過XPC與Xcode進行通訊。

這樣一來就有機會對編譯中間的結果做一些分析,SourceKitten就是這樣一個開源庫,它與SourceKit進行互動並將程式碼的語法結構轉換成JSON的形式返回。利用SourceKitten,Sourcery可以獲取程式碼中所有型別的相關資訊,並將它們作為變數注入到了Stencil的上下文環境中,所以我們才能在模板中用{{ types }}這樣的方式遍歷程式碼中的所有型別。

在Codable中的實踐

下面所介紹的Demo已上傳至我的Github:AutoCodableDemo

Codable是Swift4引入的對JSON解析的原生支援,與ObjectMapper之類的第三方庫相比,它可以自動地解析Model中的屬性,如果你的資料模型和JSON結構完全一致的話,使用起來將會非常簡單。

然而現實往往並不是這麼美好,很多時候需要對解析做一些自定義,這樣一來操作將會變得十分繁瑣,要自定義KeyPath首先得為型別定義一個實現了CodingKey的列舉,這個列舉中要包含所有的屬性欄位,即使這個屬性不需要自定義;而如果要做更加複雜的自定義的話還得自己實現init(from decoder: Decoder)encode(to encoder: Encoder)方法,併為所有的屬性實現decode和encode操作。

顯然這些程式碼具有很高的重複性,非常適合使用Sourcery來自動生成:

AutoCodable

首先在專案中定義一個AutoCodable型別:

protocol AutoCodable: Codable { }
複製程式碼

在模板中找到所有實現了AutoCodable的型別,並在擴充套件中為它們自動加上一個包含了所有屬性名的列舉:

enum CodingKeys: String, CodingKey {
    {% for var in type.storedVariables %}
        case {{var.name}} {% if var|annotated:"key" %}= "{{var.annotations.key}}"{% endif %}
    {% endfor %}
}
複製程式碼

Sourcery提供了一個名為annotation的機制,可以在程式碼中以註釋的形式向模板提供一些必要的資料,只需要在某個變數或是型別的定義前加上一行這樣的註釋:

// sourcery: key = "value"
var something: Int
複製程式碼

Sourcery會將這種格式的註釋解析出來,以key-value的方式新增到模板中該變數所對應的annotations屬性上,通過這種方式可以在程式碼中為模板解析提供一些自定義的資料。

使用

讓你的自定義型別實現AutoCodable

struct Person: AutoCodable {
    var myName: String
}
複製程式碼

AutoCodable實現了以下功能:

  • 自定義欄位名稱: 在需要自定義欄位名稱的屬性前加上這樣一個annotation

    // sourcery: key = "my_name"
    var myName: String
    複製程式碼
  • 設定屬性預設值: AutoCodable允許你為屬性提供預設值,當JSON中的該欄位解析失敗時該屬性會被設定為預設值,而不是丟擲錯誤,有了預設值之後該屬性不再需要定義成可選型別:

    // sourcery: default = true
    var something: Bool
    複製程式碼
  • 忽略某個欄位: 被忽略的屬性不會參與JSON的Encode和Decode,另外被忽略的屬性必須帶有一個預設值:

    // sourcery: skip
    var something: Int = 0
    複製程式碼
  • 支援將Int解析成Bool型別:

    Codable在解析JSON的時候對於型別是有嚴格要求的,如果一個屬性的型別是Bool,在JSON中對應的欄位值是Int型別的話會丟擲一個型別錯誤(不像OC中的Mantle會自動轉換)。 雖然Codable的這個做法無可厚非,然而在我們的實際專案中已經有大量的後臺介面資料使用1和0來表示true和false了。所以在這裡AutoCodable針對Bool型別做了處理,支援將Int型別的值解析成Bool型別。

之後像上面所介紹的那樣將生成的程式碼檔案新增到工程裡即可,可以看到Sourcery為我們免去了自定義解析時大量重複的程式碼,唯一的缺點就是向模板傳值只能通過註釋的形式,在Xcode新增一個Code Snippet:// sourcery: <#key#> = <#value#>能提供一些幫助,至於Key的名稱就只能在編碼的時候注意別寫錯了。除此之外Sourcery已經完美的解決了我在使用Codable時碰到的問題。

總結

Sourcery本質上相當於一個前處理器,它為Swift帶來了靈活的超程式設計特性,你甚至可以將生成的程式碼內嵌到自己的程式碼中,它的應用場景遠遠不只是上面所介紹的這些。程式設計師的時間是寶貴的,我們應該將精力集中在真正關鍵的部分,如果你也在使用Swift,不妨來嘗試一下,和那些瑣碎重複的樣板程式碼揮手作別?。

相關文章