只有通過別人的眼睛,才能真正地瞭解自己 ——《雲圖》
背景
作爲全球最大的互聯網 + 生活服務平臺,美團點評近年來在業務上取得了飛速的發展。爲支持業務的快速發展,移動研發團隊規模也逐漸從零星的小作坊式運營,演變爲千人級研發軍團協同作戰。
在公司蓬勃發展的大背景下,移動項目架構也有了全新的演進方向:需要支持高效的集成策略,支持研發流程自動化等等,最終提升研發效能,加速產品迭代和交付能力。
雖然高效的研發交付體系幫助 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]。