Hades:移動端靜態分析框架
只有通過別人的眼睛,才能真正地瞭解自己 ——《雲圖》
背景
作為全球最大的網際網路 + 生活服務平臺,美團點評近年來在業務上取得了飛速的發展。為支援業務的快速發展,移動研發團隊規模也逐漸從零星的小作坊式運營,演變為千人級研發軍團協同作戰。
在公司蓬勃發展的大背景下,移動專案架構也有了全新的演進方向:需要支援高效的整合策略,支援研發流程自動化等等,最終提升研發效能,加速產品迭代和交付能力。
雖然高效的研發交付體系幫助 App 專案縮短了迭代週期,但井噴式的模組發版和頻繁的專案整合,使得純人工的專案維護和質量保證變得“獨木難支”。
上圖漫畫中,列舉了大型專案在持續優化和維護過程中較為常見的幾類需求。這些需求主要包括以下幾個方面:
- 在 CI 流程中加入靜態准入檢查,避免繁瑣的人工 Review 以及減少人工 Review 可能帶來的失誤。
- 為了推進專案的優化過程,需要方法數監控、巨集定義分析等程式碼分析報表和監控。
- 零 PV 報表、依賴分析和標頭檔案引用規範、無用程式碼分析等專案優化方案。
不難發現,這些需求的本質是:藉助程式碼靜態分析能力,提升專案可持續發展所需要的自動化水平。針對 C/Objective-C 主流的靜態分析開源專案包括:Static Analyzer、Infer、OCLint 等。但是,這些分析工具對我們而言存在一些問題:
- 開發成本高,收益有限,研發參與積極性不夠。
- 針對區域性程式碼分析,跨編譯單元以及全域性性分析較難。
- 增量分析困難,CI 靜態檢查效率低下。
- 工具性較強,大部分只作程式碼規範檢查,應用範疇侷限。
- 接入和維護成本高,難以平臺化。
針對以上背景和現有方案的不足,我們決定自研基於語義的靜態分析框架。
Hades 專案簡介
大眾點評靜態分析框架 Hades,取名源於古希臘神話中的冥王
。冥王 Hades 公正無私,能夠審視靈魂的是非善惡。
Hades 框架支援語義分析能力,我們希望這種能力不僅僅能夠去實現一個傳統的 Lint 工具,而且能成為創造更多能力的基礎,可以幫助我們更輕鬆地審視程式碼,理解把控大型專案。
Hades 方案選型
文字處理方式
首先,最簡單的靜態分析是字元匹配和文字處理。這種方式雖然實現簡單,但是存在能力上限,也不可能在語義理解上有足夠的把控力。另外,以正則匹配為核心建立的工具棧難以得到持續優化。為了分析專案的依賴關係,我們需要判斷程式碼中的符號含義以及符號間關係(如包含哪些類,類中有哪些方法等),分析過程的正則表示式如下圖所示。
由此可見,繁瑣的文字匹配不僅可讀性差,也存在容易分析出錯的問題。
基於編譯器的靜態分析方案
我們需求的本質是對程式碼進行分析,而在原始碼編譯過程中,語法分析器會創建出抽象語法樹(Abstract Syntax Tree 縮寫為 AST)。AST 是原始碼的抽象語法結構的樹狀表現形式,樹上的每個節點都表示原始碼的一種結構。
以上圖為例,程式碼塊區域是用 Objective-C 和 TypeScript 編寫的一個簡單條件語句原始碼,下面是其對應的抽象語法結構表達。這種樹狀的結構表達,省略了一些細節(比如:沒有生成括號節點),從圖中的這種對映關係中我們也可以發現:
- 原始碼的語法結構是可以通過明確的資料結構表示的。
- 大多數程式語言都可以用相似的 AST 表達的。
對於 C/Objective-C 而言,主流編譯器是 Clang/LLVM(Low Level Virtual Machine)的,它是一個開源的編譯器架構,並被成功應用到多個應用領域。Clang(發音為/klæŋ/,不是C浪)是 LLVM的一個編譯器前端,它目前支援 C, C++, Objective-C 等程式語言。Clang 會對源程式進行詞法分析和語義分析,將分析結果轉換為 AST。現有方案中不少 Lint 工具便是基於 Clang 的,Clang 包含了以下特點:
- 編譯速度快:Clang 的編譯速度遠快於 GCC。
- 佔用記憶體小:Clang 生成的 AST 所佔用的記憶體是 GCC 的五分之一左右。
- 模組化設計:Clang 採用基於庫的模組化設計,易於 IDE 整合及其他用途的重用。
因此,藉助 Clang 的模組化設計和高效編譯等諸多優點,Hades 也將更容易開發和升級維護。Clang 對原始碼強有力的分析能力也是主流靜態分析工具的不二之選。
Clang AST 初識
Clang 專案非常龐大。僅僅是 Clang AST 相關程式碼就超過 10W+ 行程式碼。如何利用 Clang 實現 AST 分析工作,這裡可以參考官網提供的文件 Choosing the Right Interface for Your Application ,以下是三種方式:
-
LibClang
提供 C 語言的穩定介面,支援Python Binding。AST 並不完整,不能完全掌控 Clang AST。
-
Clang Plugins
提供 C++ 介面,更新快,不能保留上下文資訊。外掛的存在形式是一個動態連結庫,不能在構建環境外獨立存在。
-
LibTooling
提供 C++ 介面,更新快,可以通過標準的 main() 函式作為入口,可獨立執行,能夠完全掌控 AST,相比 Plugin 更容易設定。
這裡我們選擇可獨立執行並且能完全掌控 AST 的 LibTooling 作為 Hades 的基礎。
在使用 Clang 的學習過程中,基本的概念便是表示 AST 的節點型別,這裡重要的幾點是:
-
ASTContext。
ASTContext 是編譯例項用來儲存 AST 相關資訊的一種結構,也包含了編譯期間的符號表。我們可以通過
TranslationUnitDecl * getTranslationUnitDecl():
方法得到整個翻譯單元的 AST 的入口節點。 -
節點型別。
AST 通過三組核心類構建:Decl (declarations)、Stmt (statements)、Type (types)。其它節點型別並不會從公共基類繼承,因此,沒有用於訪問樹中所有節點的通用介面。
-
遍歷方式。
為了分析 AST,我們需要遍歷語法樹。Clang 提供了兩種方式:RecursiveASTVisitor 和 ASTMatcher。RecursiveASTVisitor 能夠讓我們以深度優先的方式遍歷 Clang AST 節點。我們可以通過擴充套件類並實現所需的 VisitXXX 方法來訪問特定節點。
ASTMatcher API 提供了一種域特定語言(DSL)來構建基於 Clang AST 的謂詞,它能高效地匹配到我們感興趣的節點。
除了這兩種方式外,LibClang 也提供了 Cursors 來遍歷 AST。更多細節內容可以前往 :clang.llvm.org 。
常用開源工具的不足
通過上一章節的介紹,我們大致瞭解了 Clang 的基本特點。 但是在實踐開發過程中發現:通過 Clang API 去遍歷和分析 AST 的原始碼樹形結構較為複雜。現有靜態分析方案(如:OCLint),大多是直接給出封裝好的 Lint 工具,擴充套件方面也是提供腳手架生成 Rule 檔案,然後在 Rule 中編寫訪問特定 AST 節點的方法(例如:VisitObjCMethodDecl 方法用來訪問 Objective-C 的方法定義)。
因此,現有方案大多數只提供了直接訪問 AST 的方式,而且這種方式較為“區域性”。每實現一個實際需求需要耗費大量精力去理解如何從 AST 分析對映到原始碼的語義邏輯。
但是,Code Review 時我們並不會將目的碼轉換為 AST 然後再去分析程式碼的語義如何,更多的是直接理解程式碼的具體邏輯和呼叫關係。AST 樹狀結構分析的複雜性容易帶來理解上的差異鴻溝。因此,這也不利於調動業務研發團隊的積極性,很多基於原始碼分析工作也難以落地。
Hades 核心實現
為了讓分析過程更清晰,我們需要在 AST 的基礎之上再進行一次抽象。本章節主要內容包含:Hades 的整體架構、為什麼要定義語義模型、定義什麼樣的語義模型、如何輸出語義模型以及模型的序列化和持久化。
Hades 總體架構
按照 Hades 的架構目標進行基礎方案選型以後,我們來看下 Hades 的整體技術框架,可以用下圖所示的四層架構表示:
下面簡述下這幾層的不同職責。
編譯器架構層。Clang 的諸多優勢前文已經提到,這也是 Hades 的基礎依賴。
Hades 核心層。在編譯器架構層,我們藉助 Clang 得到了程式碼的抽象語法結構表示 AST。而 Hades 核心層的職責便是將 AST 解析成人們更容易理解的,更高層級的語義模型。
Hades 介面封裝層。抽象出的模型,能夠像 Clang 提供豐富 AST 訪問介面那樣,為開發者提供豐富的模型訪問介面。
靜態分析應用。通過 Hades 介面封裝,我們無需清楚底層模型是如何生成的,在這一層我們可以製作 Lint 或者其它監控、分析工具。
為什麼 Hades 的架構設計是這樣的呢?下面我們將一一道來。
為何要定義語義模型 ?
首先,正如「常用開源工具的不足」章節所述,大多現有方案是直接通過編譯器前端提供的介面實現對 AST 的操作,從而達到靜態分析的目的。
當然,除了現有方案的不足以外,在業務研發過程中出現的 Case ,其原因大多數並不是違反了現有的 Lint 工具中所定義的基本語法規範,這些規則分析的往往是“常識”類問題。在靜態分析中,更多的是物件的錯誤方法呼叫和非法的繼承/複寫關係等問題,即便具備良好的編碼規範也會疏忽。這裡乍一看沒太大區別,但是從著重點來說,Hades 的設計理念上會存在本質區別。
如上圖所示,現有方案如 OCLint 或者 Clang Static Analyser 等,其核心原理是在編譯器將原始碼生成 AST 時,通過分析節點和節點間的關係,從而達到靜態分析的目的。這種方式不利於跨編譯單元分析,自然對專案級別的理解分析存在侷限性。
所以,這裡可以藉助 AST 針對每個編譯單元建立更直觀的、更容易理解的結構化表達。我們將這個更高層級的語義表達稱為 HadesModel。
定義什麼樣的語義模型 ?
建立 HadesModel 以後的靜態分析中,我們的著重點變化如下圖所示:
下面我們可以簡單描述需要設計的 HadesModel 的基本特點:
- HadesModel 可以結構化表達原始碼的語義。它能夠表達一個編譯單元定義了哪些介面宣告、實現了哪些類/類別的方法、定義和展開了哪些巨集定義、物件的方法呼叫和函式使用情況等等。
- HadesModel 使我們不需要了解 Clang 編譯器以及 AST 如何表達原始碼。
- HadesModel 以一個完整的編譯單元為單位,支援 JSON 格式表達。
- 對於 Objective-C ,分析過程不必強依賴於 xcodebuild 編譯構建過程。
通過以上幾點特徵描述,我們得到了 HadesModel 更清晰的表述:
HadesModel 是基於 AST 的更高層級語義表達,它能夠序列化為 JSON 格式並描述完整的編譯單元,這種結構化資訊使得靜態分析能更接近於開發者閱讀理解原始碼的思維習慣。
在介紹完 HadesModel 的基本目標後,我們用下面一段簡單的 Objective-C 程式碼為例來明確 HadesModel 的具體表達形式:
在示例程式碼中,我們簡單瞭解下包含的語義邏輯:
- 這是一段 Objective-C 程式碼,實現檔名為
HadesViewController.m
。 - 在實現檔案中,定義了一個名為
HadesMacro
的巨集定義。 - 實現檔案中包含了
HadesViewController
類的實現部分,HadesViewController
是UIViewController
的子類。 HadesViewController
類中包含了兩個方法實現。其中第一個方法名為sayHello
,裡面包含了區域性物件testView
的初始化以及物件的方法呼叫,另外還包含了巨集定義的使用。
可以發現,HadesModel 能夠表達開發者對語義資訊的直觀理解即可。
如何生成語義模型:HadesModel ?
接下來介紹 Hades 基本架構圖中 HadesCore 的核心實現,重點在如何生成前文所述的 HadesModel。
這裡 HadesCore 藉助 Clang LibTooling 分析原始碼的 AST,然後將我們所需的語義資訊抽象成 HadesModel。將資料抽象和轉換過程用以下簡要流程表示:
下面將從一個流程圖來看看 HadesCore 是如何生成 HadesModel 的實現細節:
流程圖中主要包括以下幾點內容:
1. 構建編譯資料庫
首先,Hades 是基於 Clang 的模組化設計開發,所以它可以獨立執行,因此,可以利用 RubyGem 的方式將模型生成過程封裝並提供命令列工具。對於需要得到 HadesModel 的編譯單元.m
,首先需要作為原始檔整合到 workspace (iOS 可以用 CocoaPods),然後利用 Xcode 提供的 xcodebuild 結合 xcpretty 編譯得到專案的編譯資料庫 compile_commands.json
。編譯資料庫用來指定每個編譯單元的命令列引數。
2. 建立 HadesDriver
在建立驅動器之前,可以使用 Clang 提供的 CommonOptionsParser
類,它將負責解析與編譯資料庫和輸入相關的命令列引數,然後將其作為驅動器的輸入。驅動器控制整個模型生成周期,它的輸出結果便是 HadesModel。
3. 構建 HadesModel
在 HadesDriver 的驅動下,首先需要建立編譯器例項,執行編譯前可以分析巨集定義和標頭檔案展開等預處理資訊,並將這些內容初始化到 HadesModel 物件。接著,在編譯器例項中將 FrontendAction
介面作為擴充套件編譯過程的執行入口,利用 Clang LibTooling 提供的 ASTVistor 訪問 AST 節點(更多 Clang 技術細節見:Clang 8 documentation),最終將所有翻譯單元的“元資料”填充到 HadesModel。
以前文的 HadesViewController.m
為例,我們得到 HadesModel 並序列化為 JSON 資料以後,如下圖所示:
顯然,示例 HadesModel 已經能夠表達開發者 Code Review 時,絕大多數“直白”的語義資訊了。
HadesModel 的序列化/持久化
由於 HadesModel 最終需要以 JSON 格式作為提供靜態分析的原始資料型別,所以需要保證 HadesModel 具備序列化的能力。
JSON 格式使 Hades 具備了全域性分析能力,也符合設計之初的分析和平臺、語言無關的要求。再者,JSON 型別也方便利用具備較好型別系統的語言作為分析介面層。
實踐中,以 iOS 常用的 CocoaPods 的 Pod 為單位,在私有 Pod 發版時生成模型資料然後打包儲存在 Maven 中,以便於增量分析。
在 CI 系統中,特別是大型專案持久化的模型儲存非常重要。CI 中為了加快整合速度,不得不使用部分二進位制的整合方式,但是這樣將無法對靜態庫進行原始碼分析。利用 Hades 的模型快取,我們可以解決二進位制整合的侷限性。快取資料也不需要再次編譯、模型生成等耗時操作,所以接入 Hades 後基本不影響整合專案的整合速度。
Hades 應用案例(1):製作 Lint 工具
在這一章,我們將介紹 Hades 架構中的介面層,以及在 Lint 工具上的應用。
HadesLint 架構描述
HadesLint 是基於 Hades 框架製作的靜態分析工具。作為平臺標準的 Lint 工具,目前在持續整合有了廣泛應用(詳情見此篇文章:MCI:大眾點評千人移動研發團隊怎樣做持續整合?)。
HadesLint 開發語言是 TypeScript。它具備完善的型別系統,結合 VSCode 的智慧補全和完善的 Debug 能力,使得 HadesLint 具備良好的開發體驗。
HadesLint 的實現細節如下圖所示:
在接入 HadesLint 的專案後,我們將專案以 Pod 為單位,從 Maven 中讀取快取模型 Zip 包。如果不存在快取,那麼將利用前文所述封裝好的 HadesGem 通過編譯資料庫實時生成每個編譯單元的 HadesModel。
由於我們的專案較大,模型資料量也非常龐大,為了防止分析過程記憶體洩露的危險,提升分析效能,可以通過Lazy.js
進行惰性求值,漸進載入有效解決了模型資料龐大的問題。
被 Lazy.js 載入的 JSON 物件,需要通過 TypeScript 宣告來保證 HadesModel 具備型別。這樣,我們就可以在 VSCode 中編寫程式碼時,享受自動補全、型別推斷,從而保證編寫過程更加安全、高效。藉助 VSCode 對 TypeScript 的良好支援,在編寫分析過程中方便地 Debug。
最後 HadesLint Driver 會載入每個規則物件,在規則中分析 HadesModel 然後確定檢查項是否合法。
當然,如果希望程式執行效率更高些,也可以嘗試 OCaml+ATD 來構建 Lint 專案。
HadesLint 應用案例:列印專案中的類名
需求描述:我們需要找到專案中定義的所有類名。
我們只需要通過腳手架建立新的規則,然後編寫以下程式碼:
this.hadesModels.each((hadesModel: HadesModel.HModel) => {
hadesModel.class_list.forEach((occlass: HadesNode.Class) => {
console.log(occlass.name);
})
});
複製程式碼
編寫程式碼以後,可以在 VSCode 的 Debug 面板中開啟除錯:
當然,除了以上簡單的查詢功能以外,我們也可以定製相對複雜的檢查規則,比如繼承鏈管控、方法複寫檢查、非空檢查等。
在引出方法複寫管控之前,開發者往往會通過隨意繼承的方式複寫程式碼,或者通過不合理擴充套件方式來滿足當前需求。但是,人工 Review 程式碼很難保證整合專案中,這些擴充套件或者子類在執行時的行為。因此,對繼承鏈管控的需求非常有必要。我們的 App 之前就出現了擴充套件同名方法,意外導致方法複寫,從而在程式執行時出現問題,甚至導致 Crash。
為此,我們在整合准入檢查中加入了方法覆蓋檢查。當然,如果父類設計之初本身是希望子類複寫,我們在 Lint 過程中通常會忽略這些合法的複寫情況。
對於這類跨編譯單元的分析需求,如果我們按照 Clang Static Analyser 是較難分析的,但是 Hades 就可以非常輕鬆地做到,因為 Hades 可以輕鬆獲取整個繼承鏈以及每個類的實現定義。
Hades 應用案例(2):構建 HadesDB
HadesModel 是結構化資料,因此,我們也可以將這些模型資料以 Document 的形式儲存到文件型資料庫中,例如:CouchDB。
在 CouchDB 的基礎上建立模型資料庫,這樣便能夠方便地通過 Map-Reduce 建立檢視文件(Design Documents),然後,我們可以獲取專案中包含的類及其方法列表、分析每個 Document 的欄位按需輸出結果。
例如,儲存建立完整的專案 HadesModel 資料後,在 CouchDB 中建立 Design Document,然後在 Map Function 中編寫以下程式碼:
function (doc) {
if (doc.extracontext.macro_list !== null) {
emit(doc._id, doc.extracontext.macro_list);
}
}
複製程式碼
CouchDB 支援 JS 程式碼編寫 map-reduce,以上程式碼表示在當前的資料庫中,對於每個 HadesModel Document 判斷是否存在巨集定義,如果存在,那麼輸出巨集定義作為 Design Document 的結果。
最後,通過 CouchDB 介面返回可以獲取如下結果:
App 專案中原始碼中使用的所有巨集定義資訊:
{
"total_rows": xxx,
"offset": 0,
"rows": [
{
"id": "NVShopInfoBlackPearlMultiDealCell",
"key": "NVShopInfoBlackPearlMultiDealCell",
"value": [
{
"name": "NVActionSheet",
"expanded": true,
"expandstr": "UIResponder<NVActionSheetDelegate> *",
"location": ${path_location},
...
}
]
},
...
]
}
複製程式碼
有了 HadesDB 以後,我們能賦予程式碼語義分析更大的想象空間。比如,可以利用 HadesDB 製作 Web 專案,通過 Web 頁面搜尋、查詢我們所需要知道的語義資訊和分析資料。
總結
本文介紹了在美團點評業務快速發展背景下,針對大型移動專案的靜態分析需求,結合開源專案利弊,最終設計實現的靜態分析框架 Hades。
Hades 作為大眾點評移動研發的基礎設施之一,在實踐中得到了廣泛的應用,為大型 App 專案的日常維護、程式碼分析提供支援。基於 HadesModel 的靜態分析易上手,開發接入成本低,能夠理解程式碼語義,具備全域性分析能力等諸多優點。
最後,我們也希望 Hades 的設計是賦予創造能力的能力,而不僅僅是作為傳統意義上的 Lint 輔助工具,這也是我們為什麼不取名為“工具”,而是稱之為“框架”的原因。當然,基於 Hades 我們也是能夠很方便地製作出 Lint 工具的。
Hades 是否開源?不久將會開源,敬請期待。如果對我們平臺感興趣,歡迎小夥伴們加入大眾點評的大家庭。
參考資料
[1] Clang 8 documentation [2] Infer static analyzer [3] Clang Tidy [4] OCLint static analyzer [5] Apache CouchDB [6] TypeScript [7] ATD [8] Lazy.js [9] xcpretty [10] Visual Studio Code
作者簡介
吳達,大眾點評 iOS 技術專家,Hades 專案開發者。目前專注於移動 CI 研發,靜態分析和點評 App 業務研發。
智聰,移動資訊元件負責人,大眾點評 iOS 高階專家。專注於移動工具鏈開發,對移動持續整合、靜態分析平臺建設有深刻理解和豐富的實踐經驗。
招聘資訊
大眾點評移動研發中心,Base 上海,為美團提供移動端底層基礎設施服務,包含網路通訊、移動監控、推送觸達、動態化引擎、移動研發工具等。同時團隊還承載流量分發、UGC、內容生態、個人中心等業務研發工作,長年虛位以待專注於移動端研發的各路英雄豪傑。歡迎投遞簡歷:[email protected]。