Drafter: 一個在iOS項目中分析代碼結構的工具

在之前的一篇博客中,曾經用clang提供的庫LibTooling編寫了一個簡單的導出iOS代碼中函數調用關系圖的工具,然而這種實現方式存在一些很明顯的缺點:

  1. 在分析一個工程中的單個代碼文件時,無法得知定義在其他文件中的類或方法,導致生成的語法樹節點缺失,對最終的結果造成不小的影響。
  2. 在解析時clang會進行預處理,導致最終生成的結果可能包括一些外部系統庫的函數,這對于我們來說是無用的信息(當然這個應該是我的使用姿勢問題)。
  3. 無法支持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,在經過詞法分析之后被處理成了一系列的詞法單元:inti=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添加更多的功能、增強解析能力等,希望這個小工具能稍微減輕你在閱讀代碼時的負擔??。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,818評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,185評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,656評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,647評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,446評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,951評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,041評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,189評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,718評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,602評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,800評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,316評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,045評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,419評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,671評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,420評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,755評論 2 371

推薦閱讀更多精彩內容