只有通过别人的眼睛,才能真正地了解自己 ——《云图》

背景

作为全球最大的互联网 + 生活服务平台,美团点评近年来在业务上取得了飞速的发展。为支持业务的快速发展,移动研发团队规模也逐渐从零星的小作坊式运营,演变为千人级研发军团协同作战。

在公司蓬勃发展的大背景下,移动项目架构也有了全新的演进方向:需要支持高效的集成策略,支持研发流程自动化等等,最终提升研发效能,加速产品迭代和交付能力。

虽然高效的研发交付体系帮助 App 项目缩短了迭代周期,但井喷式的模块发版和频繁的项目集成,使得纯人工的项目维护和质量保证变得“独木难支”。

静态分析需求

上图漫画中,列举了大型项目在持续优化和维护过程中较为常见的几类需求。这些需求主要包括以下几个方面:

  1. 在 CI 流程中加入静态准入检查,避免繁琐的人工 Review 以及减少人工 Review 可能带来的失误。
  2. 为了推进项目的优化过程,需要方法数监控、宏定义分析等代码分析报表和监控。
  3. 零 PV 报表、依赖分析和头文件引用规范、无用代码分析等项目优化方案。

不难发现,这些需求的本质是:借助代码静态分析能力,提升项目可持续发展所需要的自动化水平。针对 C/Objective-C 主流的静态分析开源项目包括:Static Analyzer、Infer、OCLint 等。但是,这些分析工具对我们而言存在一些问题:

  • 开发成本高,收益有限,研发参与积极性不够。
  • 针对局部代码分析,跨编译单元以及全局性分析较难。
  • 增量分析困难,CI 静态检查效率低下。
  • 工具性较强,大部分只作代码规范检查,应用范畴局限。
  • 接入和维护成本高,难以平台化。

针对以上背景和现有方案的不足,我们决定自研基于语义的静态分析框架。

Hades 项目简介

大众点评静态分析框架 Hades,取名源于古希腊神话中的冥王。冥王 Hades 公正无私,能够审视灵魂的是非善恶。

Hades 框架支持语义分析能力,我们希望这种能力不仅仅能够去实现一个传统的 Lint 工具,而且能成为创造更多能力的基础,可以帮助我们更轻松地审视代码,理解把控大型项目。

Hades 方案选型

文本处理方式

首先,最简单的静态分析是字符匹配和文本处理。这种方式虽然实现简单,但是存在能力上限,也不可能在语义理解上有足够的把控力。另外,以正则匹配为核心建立的工具栈难以得到持续优化。为了分析项目的依赖关系,我们需要判断代码中的符号含义以及符号间关系(如包含哪些类,类中有哪些方法等),分析过程的正则表达式如下图所示。

正则匹配模式

由此可见,繁琐的文本匹配不仅可读性差,也存在容易分析出错的问题。

基于编译器的静态分析方案

我们需求的本质是对代码进行分析,而在源代码编译过程中,语法分析器会创建出抽象语法树(Abstract Syntax Tree 缩写为 AST)。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 的整体技术框架,可以用下图所示的四层架构表示:

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 以后的静态分析中,我们的着重点变化如下图所示:

Hades分析模式

下面我们可以简单描述需要设计的 HadesModel 的基本特点:

  • HadesModel 可以结构化表达源码的语义。它能够表达一个编译单元定义了哪些接口声明、实现了哪些类/类别的方法、定义和展开了哪些宏定义、对象的方法调用和函数使用情况等等。
  • HadesModel 使我们不需要了解 Clang 编译器以及 AST 如何表达源码。
  • HadesModel 以一个完整的编译单元为单位,支持 JSON 格式表达。
  • 对于 Objective-C ,分析过程不必强依赖于 xcodebuild 编译构建过程。

通过以上几点特征描述,我们得到了 HadesModel 更清晰的表述:

HadesModel 是基于 AST 的更高层级语义表达,它能够序列化为 JSON 格式并描述完整的编译单元,这种结构化信息使得静态分析能更接近于开发者阅读理解源码的思维习惯。

在介绍完 HadesModel 的基本目标后,我们用下面一段简单的 Objective-C 代码为例来明确 HadesModel 的具体表达形式:

Hades测试代码

在示例代码中,我们简单了解下包含的语义逻辑:

  • 这是一段 Objective-C 代码,实现文件名为 HadesViewController.m
  • 在实现文件中,定义了一个名为 HadesMacro 的宏定义。
  • 实现文件中包含了 HadesViewController 类的实现部分,HadesViewControllerUIViewController 的子类。
  • HadesViewController 类中包含了两个方法实现。其中第一个方法名为 sayHello ,里面包含了局部对象 testView 的初始化以及对象的方法调用,另外还包含了宏定义的使用。

可以发现,HadesModel 能够表达开发者对语义信息的直观理解即可。

如何生成语义模型:HadesModel ?

接下来介绍 Hades 基本架构图中 HadesCore 的核心实现,重点在如何生成前文所述的 HadesModel。

这里 HadesCore 借助 Clang LibTooling 分析源码的 AST,然后将我们所需的语义信息抽象成 HadesModel。将数据抽象和转换过程用以下简要流程表示:

Hades 模型生成主要过程

下面将从一个流程图来看看 HadesCore 是如何生成 HadesModel 的实现细节:

Hades 模型生成流程图

流程图中主要包括以下几点内容。

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 实现架构

在接入 HadesLint 的项目后,我们将项目以 Pod 为单位,从 Maven 中读取缓存模型 Zip 包。如果不存在缓存,那么将利用前文所述封装好的 HadesGem 通过编译数据库实时生成每个编译单元的 HadesModel。

由于我们的项目较大,模型数据量也非常庞大,为了防止分析过程内存泄露的危险,提升分析性能,可以通过Lazy.js进行惰性求值,渐进加载有效解决了模型数据庞大的问题。

被 Lazy.js 加载的 JSON 对象,需要通过 TypeScript 声明来保证 HadesModel 具备类型。这样,我们就可以在 VSCode 中编写代码时,享受自动补全、类型推断,从而保证编写过程更加安全、高效。借助 VSCode 对 TypeScript 的良好支持,在编写分析过程中方便地 Debug。

最后 HadesLint Driver 会加载每个规则对象,在规则中分析 HadesModel 然后确定检查项是否合法。

当然,如果希望程序执行效率更高些,也可以尝试 OCaml+ATD 来构建 Lint 项目。

HadesLint 应用案例:打印项目中的类名

需求描述:我们需要找到项目中定义的所有类名。

我们只需要通过脚手架创建新的规则,然后编写以下代码(HadesLint规则代码):

this.hadesModels.each((hadesModel: HadesModel.HModel) => {
  hadesModel.class_list.forEach((occlass: HadesNode.Class) => {
    console.log(occlass.name);
  })
});

编写代码以后,可以在 VSCode 的 Debug 面板中开启调试:

HadesLint 开发调试界面

当然,除了以上简单的查询功能以外,我们也可以定制相对复杂的检查规则,比如继承链管控、方法复写检查、非空检查等。

在引出方法复写管控之前,开发者往往会通过随意继承的方式复写代码,或者通过不合理扩展方式来满足当前需求。但是,人工 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 是否开源?不久将会开源,敬请期待。如果对我们平台感兴趣,欢迎小伙伴们加入大众点评的大家庭。

参考资料

作者简介

  • 吴达,大众点评 iOS 技术专家,Hades 项目开发者。目前专注于移动 CI 研发,静态分析和点评 App 业务研发。
  • 智聪,移动信息组件负责人,大众点评 iOS 高级专家。专注于移动工具链开发,对移动持续集成、静态分析平台建设有深刻理解和丰富的实践经验。

招聘信息

大众点评移动研发中心,Base 上海,为美团提供移动端底层基础设施服务,包含网络通信、移动监控、推送触达、动态化引擎、移动研发工具等。同时团队还承载流量分发、UGC、内容生态、个人中心等业务研发工作,长年虚位以待专注于移动端研发的各路英雄豪杰。欢迎投递简历:dawei.xing@dianping.com。