Drafter: 一個在iOS專案中分析程式碼結構的工具

L-Zephyr發表於2019-03-04

在之前的一篇部落格中,曾經用clang提供的庫LibTooling編寫了一個簡單的匯出iOS程式碼中函式呼叫關係圖的工具,然而這種實現方式存在一些很明顯的缺點:

  1. 在分析一個工程中的單個程式碼檔案時,無法得知定義在其他檔案中的類或方法,導致生成的語法樹節點缺失,對最終的結果造成不小的影響。
  2. 在解析時clang會進行預處理,導致最終生成的結果可能包括一些外部系統庫的函式,這對於我們來說是無用的資訊(當然這個應該是我的使用姿勢問題)。
  3. 無法支援swift。swift編譯器的前端並不是clang,而這個工具是基於clang的庫來開發的,所以也就沒有支援swift的可能。

由於這幾個缺點(主要是第三點,因為在日常工作中還是以swift為主),後來也沒有再繼續使用和完善。直到最近因為工作上的安排,需要維護一份較為陳舊的程式碼,面對動輒數千行的程式碼檔案,覺得還是需要一個比較趁手的工具來輔助閱讀。前段時間正好恰逢國慶長假,抽空用swift重新寫了一個工具:drafter,如名字所示,它的目的在於生成描述程式碼的草圖。

Drafter是什麼

  • Drafter是一個命令列工具,用於分析iOS工程的程式碼,支援Objective-C和Swift。
  • 自動解析程式碼並生成方法呼叫關係圖。
  • 自動解析程式碼並生成類繼承關係圖。

安裝和使用

完整的程式碼在這裡:github.com/L-Zephyr/Dr…

這裡提供了一個快速安裝的指令碼,在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: 一個在iOS專案中分析程式碼結構的工具

以及動輒數百行的方法時不再那麼頭疼,匯出指定方法的呼叫流可以更迅速的理清程式碼邏輯上的關係:

Drafter: 一個在iOS專案中分析程式碼結構的工具

之後如果有需要的話會為Drafter新增更多的功能、增強解析能力等,希望這個小工具能稍微減輕你在閱讀程式碼時的負擔?。

相關文章