3.4.1 什么是Code Call Graph(CCG)
Code Call Graph(CCG)即业务代码中的调用关系图,是通过静态分析手段分析并构建出的一种描述代码间关系的图。根据精度不同,一般分为类级别、方法级别、控制流级别,本文重点在方法级别上。
我们以一段代码进行举例:
class A {public void funA1() {funA2();C c = new C();c.funC1();}public void funA2() {B b = new B();b.funB1();}
}class B {public void funB1() {funB2();}public void funB2() {if (randN(10) < 5) {Logger.log("Hello B2");} else {funB2()}}
}class C {public void funC1() {B b = new B();b.funB2();}
}
如上代码所构建出的方法级别的CCG是这样的:

1,CCG的作用主要有两个:
假设当我们出现一个需求改动到
1,funB1该方法时,我们可以从该图上进行逆向查找,找到所有直接调用或者间接调用该方法的所有方法A2,A1,这个代表对B2的改动,会影响到A2,A1,{B1, A2, A1}即方法B2的代码影响域。
2,在单元测试场景下,如果某个测试用例
testX是针对funC1的测试,那么我们可以从该方法上进行正向查找,找到所有它直接调用或者间接调用的方法B2,这个代表,我的测试用例testX的执行后可以测试到方法C1, B2,{B2, C1}即用例testX的关联代码。
2,CCG的应用场景
除了可以应用在精准测试场景下之外,还能在如下场景应用:
1,app启动或页面启动场景下的性能分析与性能优化:当我们要进行某个场景下的耗时优化时,我们可以从几个核心入口函数如android的下的application.onCreate(),application.onBaseContextAttached()的方法作为起点,查找后续调用方法,获知在整个启动流程里,哪些方法通过什么方式被执行了,帮助判断这种执行是否是启动场景下必须执行的任务。
2,组件化解耦:当我们需要判断两个组件间的耦合关系时,我们可以以其中一个组件中的方法作为起点,查找调用链上是否有另一个组件的方法,来寻找两个组件间的详细耦合关系,帮助后续进行解耦。相比传统静态分析方案,CCG可以更准确高效的查找出非直接依赖的隐性耦合。
3.4.2. CCG构建业界方案一览
目前业界有一部分相对完成度比较高的开源callgraph或者AST生成方案:
Android/Java
1,soot/wala等静态代码分析框架:GitHub - soot-oss/soot: Soot - A Java optimization framework,soot是比较完善的静态代码分析框架,从能力设计上都符合我们的需求,但是soot本身是一个通用性框架,没有专门为call graph场景去设计,比如匿名内部类,Runnable/Callable/Thread等线程类,lambda表达式,Stream调用,泛型处理等等,这些都需要我们去对soot做定制才能达到我们的需求。此外仅针对callgraph生成场景,soot设计是过复杂的,导致对于百万级方法节点的处理性能并不足够好。
2,java-all-call-graph: GitHub - Adrninistrator/java-all-call-graph: Generate all call graph for Java Code.,这个项目是一个比较简单的基于class字节码分析生成callgraph的方案,解决了soot的各种缺失能力,同时在处理性能上要优于soot。但是仍然存在一定缺陷,比如无法支持使用Redux框架进行开发的代码,反射,广播等场景。
iOS/Objc-c, swift
1,Drafter: GitHub - L-Zephyr/Drafter: 在iOS项目中自动生成类图和方法调用图 - Generate call graph in iOS project,Drafter是一个简单的语法+词法分析器,由于不带语义信息,只能支持单个类下的call graph生成,不符合我们的需求。
2,libTooling:官方工具,独立AST生成工具,libTooling可以生成一个完整的带语义分析的AST,我们可以基于该AST来生成所需的call graph,但是libTooling的性能非常差(需要为每个文件或者模块生成编译参数,并且无法应用各种编译优化),在全量情况下快手app的call graph生成耗时达到数小时,增量情况下一个500行文件的生成耗时达到几十秒,对于大型mr无法承受。
3,Clang Plugin:官方工具,集成进编译流程中的AST生成插件,clang plugin方案通过集成在编译流程里,目标产物为语义AST,由于可集成在编译流程中,我们可以复用包括gundum在内的各种编译优化手段,在增量情况下每个文件的生成耗时可以降到秒级,全量情况下为十几分钟。但是clang plugin只能支持oc代码,并且我们无法直接将打包集群的编译环境替换掉,因此我们需要在clang plugin基础上搞定swift/c++/c的支持,以及跨语言构建问题。同样的,我们还需要支持泛型、代理、redux、广播、KVO、oc runtime等特殊场景。
3.4.3 现在的CCG整体架构

CCG服务提供了Android,IOS调用钏的生成,序列化保存,查询等相关功能,以及对git diff获取diff函数的相关功能和接口。
3.4.4 . 流水线上CCG服务构建与更新流程
随着代码的改动,CCG需要同步更新,因此CCG服务需要与流水线深度关联。CCG服务主要分为3个阶段:
1,全量构建阶段
CCG全量构建基于定时触发,每隔固定时间(目前为24小时),CCG服务平台会触发一次双端全源码包构建请求,完成一次全量CCG构建,流程如下

如前文提到,CCG构建时需要使用特定jenkins脚本构建相关产物,获得产物后通过相关分析脚本得到完整版CCG:

生成出的完整版本CCG大概长上面这个样子,每个节点代表一个方法,我们需要存储该方法本身信息,其所属函数、参数列表,指向的前序与后序方法节点。对于一个超大型应用而言,我们可能有几百万个方法节点,这种存储方式最后得到的CCG产物十分庞大,内存占用达到几GB,显然这对内存、磁盘甚至CPU都是很大的负担(一个CCG服务器上需要同时维护多份CCG)。
因此我们在构建出全量CCG后,引入了CCG压缩流程,压缩后的CCG会被分成两部分:CCG-Node Map,CCG-Meta DB。压缩后的每个节点上只存储了该Node的hash值,并以此hash值作为key,构建meta DB,存储详细信息。在后续查询时,我们从Node Map中拿到对应的hash list后再从数据库中做一次sql查询,即可得到完整信息。这种方案也有利于获取扩展更多的节点信息,比如我们要增加线上用户热度图(见5.5)信息时,只需要在meta DB中插入即可。
全量构建完成的CCG我们称之为Base CCG,会以commitID作为版本号进行持久化。
2. 更新阶段
由于每个mr提交后都会改变局部CCG,因此我们需要引入实时CCG更新方案。我们选择引入git webhook监听所有mr merge操作,当一个mr合并入主分支后(dev分支),会触发CCG更新机制。整个更新机制分成两种平台,四种场景:
Android(复用产物分析)
场景1. 如果mr有新增/更新/删除单元测试case,一定会触发单元测试节点,此时我们根据mr diff与已经打好的单元测试包做一次增量分析,得到增量CCG
场景2. 如果mr没有相关单元测试case改动,我们根据mr diff与已经打好的编译检查包做一次增量分析,得到增量CCG
场景3. 如果因为各种原因没有匹配的编译检查包,我们需要触发一次jenkins debug包打包,再结合mr diff进行增量分析,得到增量CCG
iOS(无法使用产物结果,需要重新进行语义AST分析)
场景1. iOS场景下,在mr merge触发后,会直接触发jenkins打包服务,构建语义AST,与全量构建场景不同的是,这种场景下只会触发增量编译,因此语义AST构建只针对mr diff中的增量文件进行触发(目前主站增量编译是pod级缓存,AST构建也是pod级,当文件级缓存上线后,AST构建也将变成文件级)。
增量更新的CCG我们称之为Diff CCG,以mrId + 最新commitId作为索引值持久化,该CCG唯一绑定某个版本的Base CCG(取决于基于哪个base版本进行的diff),并存储指向对应版本的Base CCG的文件指针。
Diff CCG寻找绑定Base CCG的算法可以简化描述为:从提交mr对应的开发分支上向前寻找到最近的与dev分支的共同祖先节点,以这个节点commitId作为基准值,再向前寻找最邻近的关联有Base CCG的commitId,该Base CCG即目标CCG。
Question1. 为什么可以使用开发分支上的编译产物获取增量CCG并合入dev分支后的CCG?
事实上CCG的merge操作和git代码的merge操作是类似的,由于开发分支合并入dev分支时,代码层面一定不存在冲突,因此我们可以保证CCG merge时也不存在冲突。
另一方面对于代码层面的merge,最终可以归类为三种情况:add method,change method,delete method。这几种情况,反应到CCG上对应于添加一个方法节点,修改某个方法节点的出边,删除一个方法节点,可以实现一一对应。
因此我们在开发分支上获得增量CCG可以与当前mr的diff代码保证一一对应,merge进主干分支的CCG上时也等价于mr merge进主干分支。
Question2. 如果CCG更新太慢,后续mr所基于的dev分支代码已经领先于最新CCG会出现什么后果?
由于指向dev分支的merge操作是保证原子时序性的(不会出现两个merge操作并发执行),因此我们对于git merge的webhook也是时序性,在CCG更新操作上我们采用了同步非阻塞设计,即当出现一次merge操作后,我们触发更新操作,该更新操作会被push到执行队列中并立刻返回,执行队列是一个顺序的任务队列,保证前一个更新任务完全完成后,后一个才会执行。
当出现一个查询任务时,如果该查询任务所基于dev分支节点的CCG还未更新完成,为了避免阻塞等待,我们会使用最邻近CCG进行查询,这会带来一定程度的误差(事实上这种误差可以忽略,大多数情况下不会存在两个mr在很短的时间内去更新同一个功能模块)。
3,查询阶段
用户提交mr后,会通过流水线触发代码影响域查询服务,输入为mr diff文件,输出为受影响的方法list,以及相关权重信息。
- 查询可以分为两个阶段:
- mr diff分析找到所有改动方法
根据改动方法,在CCG上找到所有受影响的方法
MR Diff分析
MR分析阶段,我们会根据mr diff信息使用前置分析器找到变动方法,为了确保分析性能(MR分析阶段需要足够快,否则会影响整个流水线的执行速度),我们引入/自研了高性能的词法语法分析器作为我们的前置分析器,可以非常快的构建出一棵不带语义信息的AST。关于前置分析器的具体实现细节可见3.4、3.5节。

CCG查询阶段
拿到变动方法列表后,我们还无法直接进行查询,因为在CCG平台上存储的是一系列Base CCG和Diff CCG,我们需要找到并构建出我们的mr所匹配的CCG。
考虑如下的一个Git分支模型:

我们从Dev分支拉出Task分支后,当我们第一次提交mr到流水线上时(图中create MR),我们的CCG服务会基于该mr的最新commit(红色分支第二个节点)向前寻找最近一次checkout/merge/rebase Dev分支的节点(绿色第二个节点),找到该节点后,向CCG持久化服务中搜索对应的CCG图,此时我们找到了MR1-1 CCG,这是一个Diff CCG,该Diff CCG中存储了它依赖的Base CCG指针,然后对这两个CCG进行一次merge操作,即得到了我们想要的CCG(即CCG v1),在该图上即可进行后续的查询服务。当我们后续有新的commit提交到MR上时,重复上述操作即可获得新的CCG版本。
在具体查询上,我们根据变动类型将查询分为三类:
- 新增方法:新增方法不会影响其它方法,并且在没有匹配的新增case时,该新增方法也不会存在关联存量用例,而对于新增用例的场景,这些用例无论怎么样都会直接推荐,因此在CCG阶段直接忽略新增方法
- 删除方法:删除方法不会影响其它方法,也不会对测试产生影响,直接忽略
- 变更方法:变更方法是我们唯一需要进行查询的场景,我们以变更方法作为起始节点,向前追溯该方法的所有前序节点,即可获得对应变更方法的代码影响域,为了提供更多信息,我们会引入更多的权重信息来辅助后续的推荐策略,在第五章中会作详述。
至此整个CCG服务完成,作为一个单独的服务,为整个精准测试平台提供调用链路查询和分析相关功能。