在之前的一篇博客中,曾經用clang提供的庫LibTooling
編寫了一個簡單的導出iOS代碼中函數調用關系圖的工具,然而這種實現方式存在一些很明顯的缺點:
- 在分析一個工程中的單個代碼文件時,無法得知定義在其他文件中的類或方法,導致生成的語法樹節點缺失,對最終的結果造成不小的影響。
- 在解析時clang會進行預處理,導致最終生成的結果可能包括一些外部系統庫的函數,這對于我們來說是無用的信息(當然這個應該是我的使用姿勢問題)。
- 無法支持swift。swift編譯器的前端并不是clang,而這個工具是基于clang的庫來開發的,所以也就沒有支持swift的可能。
由于這幾個缺點(主要是第三點,因為在日常工作中還是以swift為主),后來也沒有再繼續使用和完善。直到最近因為工作上的安排,需要維護一份較為陳舊的代碼,面對動輒數千行的代碼文件,覺得還是需要一個比較趁手的工具來輔助閱讀。前段時間正好恰逢國慶長假,抽空用swift重新寫了一個工具:drafter,如名字所示,它的目的在于生成描述代碼的草圖。
Drafter是什么
- Drafter是一個命令行工具,用于分析iOS工程的代碼,支持Objective-C和Swift。
- 自動解析代碼并生成方法調用關系圖。
- 自動解析代碼并生成類繼承關系圖。
安裝和使用
完整的代碼在這里:https://github.com/L-Zephyr/Drafter
這里提供了一個快速安裝的腳本,在shell中執行指令:
curl "https://raw.githubusercontent.com/L-Zephyr/Drafter/master/install.sh" | /bin/sh
drafter
程序會自動安裝到 /usr/local/bin 目錄中,之后直接在終端使用即可。
具體使用方法請查看使用介紹
實現原理
注:解析器部分后來已用parser combinator重構,文章所講述的代碼對應于0.1.0的tag
在之前的做法中對源碼的解析全交給clang,只對生成的AST做處理,這其實是一種比較偷懶的做法,對最后生成的結果不可控,而且也斷了支持swift的可能。為了獲得更優化的輸出并同時支持Swift和OC,源碼解析這一步還是得自己來做。幸運的是我們只需要解析類、方法定義、方法調用這幾塊,實際工作并不是很復雜。
詞法解析
詞法解析是程序編譯的第一步,所謂詞法解析就是將代碼分割成一系列的詞法單元。詞法單元是一個有特殊意義的標記,也是語法分析程序在處理源代碼時的最小單元。比如說一個簡單的賦值表達式int i = 3
,在經過詞法分析之后被處理成了一系列的詞法單元:int
、i
、=
、3
。
struct Token {
var type: TokenType
var text: String
}
enum TokenType {
case endOfFile // 文件結束
case name // 變量名
case colon // 冒號
case comma // 逗號
...
}
先定義一個名為Token
的結構體,用來表示詞法單元,其中枚舉值type
用來表示詞法單元的類型,text
保存該詞法單元的原始數據,如:對于一個變量n,它在解析成Token之后type為.name
,text為n
。由于我們的目的只是解析類和方法,所以這里只定義了在類和方法的定義中會用到的詞法單元類型,對于那些我們不關心的詞法則一概忽略。
詞法解析器會將任何輸入的源代碼解析成詞法單元流,對于上層使用者來說就像是迭代器一樣遍歷詞法單元直到文件結束,所以這里可以定義一個基本的詞法解析器類型,只有一個計算屬性nextToken
,用來獲取下一個詞法單元:
protocol Lexer {
var nextToken: Token { get }
}
語法解析
在經過第一步的詞法分析將源代碼分割成帶有類型的詞法單元之后,就可以進入語法解析的階段了。要分析一段程序,如表達式1 + 2
,我們是無法直接從字面上來處理的,必須將其轉換成某種可以處理的中間形式,這就是語法解析要做的事情。語法解析器根據語言的文法規則掃描詞法單元流,同時生成中間表示形式(IR),通常來說會生成一棵抽象語法樹(AST),之后的語義分析階段會基于這一步生成的AST進行分析。Drafter只處理到語法解析這一步,僅對代碼中的類、方法定義和方法調用進行解析,解析后生成的數據結構也比較簡單。
語言的文法描述
程序是由多個有效的表達式組成的,我們要做的就是將這些符合特定規則的式子識別出來,語言特定的語法規則稱為這門語言的文法,這種規則可以用一種DSL來描述(BNF范式)。
舉個例子(來源于《編程語言實現模式》一書),對于一個可以包含任意字母的列表聲明如[a, b, c]
,它的文法規則描述如下:
list = '[' elements ']'; // 單引號之間的內容直接匹配
elements = elemenet (',' element)*; // *表示0個或多個
element = NAME | list; // |表示或,元素可能是另一個列表
NAME = ('a'..'z' | 'A'..'Z')+; // +表示一個或多個
上面每一條式子都描述了一條文法規則,這里將詞法規則和文法規則做了區分,文法規則的名稱小寫,詞法規則的名稱大寫。像list
這樣的規則稱為產生式,它可以繼續向下推導,如list
會產生elements
。另外有一些被單引號包圍的符號,這樣的符號是實際要匹配的內容,稱為終結符,因為它無法再繼續往下推導了。
這個文法描述了一個列表聲明的語法,每個規則都包含一個或多個解析選項,多個解析選項通過|
符號分隔。上面聲明了三個文法規則和一個詞法規則:詞法規則NAME
匹配包含至少一個字母的詞法單元;list
規則表示列表必須由中括號包圍,并至少包含一個元素,多個元素之間用逗號分隔,元素可以是一個變量也可以是另一個列表聲明。
有了明確的文法規則定義我們才能夠去編寫語法解析器,對Objective-C的文法我參考了這里。
遞歸下降分析法
定義了語法的結構和相關的詞法單元之后,在解析時只需要識別出相應的式子即可,簡單來說解析器的工作就是:遇到某種結構,就執行某種操作。具體到實現上,我們為每一種文法規則提供一個專用匹配函數,對于詞法規則則統一用match
函數來匹配:
@discardableResult
func match(_ t: TokenType) throws -> Token // 匹配指定類型詞法單元,匹配成功返回該詞法單元
對于上面那個列表的例子,可以編寫如下用于識別的函數:
func list() throws
func elements() throws
func element() throws
每個函數都識別一個特定的子結構,并且可能會調用其他的識別函數或遞歸調用自身。在識別時從起始的詞法單元開始,自上而下進行推導。所以這種分析的方法也被稱為遞歸下降分析法,以這種方法編寫的解析器稱為LL解析器。第一個L表示解析內容的輸入順序是從左到右,第二個L表示解析時也是從左向右進行推導(最左推導)。
對于上面的element
規則,它可能匹配一個變量名或是另一個列表,在進入element
函數時需要先進行判斷,所幸list
規則始終以[
符號開始,變量的規則始終以字母開始,只需要檢查當前的詞法單元類型就可以做出判斷:
func element() throws {
if currentToken.type == .leftSquare {
try list()
} else {
try match(.name)
}
}
在這個列表的文法規則中,從當前的位置開始只需要檢查一個詞法單元的類型就可以做出決斷,像這樣的文法稱為LL(1)
文法,相應的解析器稱為LL(1)解析器,1表示該解析器只能從解析位置向前查看一個詞法單元,通常這個詞法單元被稱為前瞻符號(lookahead)。
LL(k)解析器
LL(1)解析器十分簡單,但是解析能力不足。比如在上面列表語法的例子中,為列表的元素添加一個賦值的操作:[a, b = c, d]
,這樣一來,element
規則就變成了:
element = NAME
| NAME '=' NAME
| list
element
文法中有兩個解析選項都是以詞法單元NAME
開頭的,僅查看一個詞法單元無法確定,在解析時需要向前檢查更多的詞法單元,也就是說這個語法不再是LL(1)的了。
在實際解析時情況比這里要復雜很多,可能需要向前檢查看多個詞法單元才能確定解析策略,所以需要構建一個能夠根據需要查看任意多符號的解析器,也就是LL(k)解析器。目前在應用上有一些能夠根據特定DSL自動生成解析器的工具,如Antlr等,但是考慮通過DSL生成的代碼并不是特別便于調試,而且Drafter只是做了一些非常簡單的解析工作,所以還是自己編寫了一個簡單的LL(k)解析器。在Drafter中提供一個這樣一個基礎的解析器:
class BacktrackParser: Parser {
init(lexer: Lexer) {
self.input = lexer
}
func token(at index: Int = 0) -> Token {
...
}
...
}
以一個詞法解析器(Lexer)作為初始化參數,token()
方法提供從當前位置開始向前查看任意位置詞法單元的能力,而具體的文法規則解析則通過各個子類化的解析器來完成。Objective-C和Swift的代碼通過不同的解析器來進行,解析完成后輸出相同的數據結構,如表示類型的節點:
class ClassNode: Node {
var superCls: ClassNode? = nil // 父類
var className: String = "" // 類名
var protocols: [String] = [] // 實現的協議
}
在將所有關心的語法節點信息解析出來之后,剩下的就是對這些信息進行處理和展示了。Drafter中提供了一些對語法節點進行過濾和搜索的選項,通過提供的參數過濾出感興趣的信息,最后將這些數據傳遞給DotGenerator
類,這個類的作用是根據節點信息生成Dot
語言(一種描述圖形的語言)的代碼,傳遞給Graphviz生成圖片。
方法調用解析
單獨討論一下對于方法調用的解析,首先為方法調用定義一個語法節點類型:
enum MethodInvoker {
case name(String) // 普通變量
case method(MethodInvokeNode) // 另一個方法調用
}
class MethodInvokeNode: Node {
var isSwift: Bool = false
var invoker: MethodInvoker = .name("") // 調用者
var params: [String] = [] // 參數名
var methodName: String = ""
}
一個方法的調用者可能是一個變量,也可能是另一個方法調用的返回值(鏈式調用),所以invoker
被定義為一個枚舉值。
OC方法調用的Parser由類ObjcMessageSendParser
實現,swift方法調用的Parse由類SwiftInvokeParser
實現。以OC為例,對于這樣的簡單調用:
[self.view insertSubview:subview atIndex:0];
匹配的結果為:[self.view insertSubview: atIndex:]
,忽略參數的具體內容。對于鏈式的方法調用:
[[self objectAtIndex: 1] doSomethingWith: param];
解析的結果只保留一個鏈式調用的表示:[[self objectAtIndex:] doSomethingWith:]
,而不是objectAtIndex:
和doSomethingWith:
。
而對于一些更加復雜的形式,如參數為一個Block的定義,Block中還調用了其他方法,如:
[Post globalTimelinePostsWithBlock:^(NSArray *posts, NSError *error) {
if (!error) {
self.posts = posts;
[self.tableView reloadData];
}
}];
先看看對于OC方法調用文法的一個簡單定義:
message_send = '[' receiver param_list ']'
receiver = message_send | NAME
param_list = NAME | (NAME ':' param)+
param = ...
方法調用中具體的參數是通過規則param
來解析的,param
要知道自己當前是否位于另一個閉包或是其他子結構中,這樣才能在正確的時機結束匹配,這一步可以通過計算左右括號的數量來判斷,param
在碰到另一個方法調用語句時進入message_send
規則并將結果添加到最后的匹配結果中,偽代碼如下:
func param() throws {
while 文件未結束 {
if 不在子結構中 && 參數匹配結束 {
return
}
if isMessageSend() {
try messageSend() // 匹配方法調用
保存到最終的匹配結果中
continue
}
consume()
}
}
后記
以上就是Drafter實現的基本思路,開頭提到的三個問題基本上得到了解決。在這段時間的工作中Drafter給了我不少幫助,至少當我在面對一個這樣的代碼文件
以及動輒數百行的方法時不再那么頭疼,導出指定方法的調用流可以更迅速的理清代碼邏輯上的關系:
之后如果有需要的話會為Drafter添加更多的功能、增強解析能力等,希望這個小工具能稍微減輕你在閱讀代碼時的負擔??。