Ellyn-Golang 调用级覆盖率&方法调用链插桩采集方案

词语解释

名词 说明
插桩 在源代码的一些关键节点,插入一些探针之类的代码片段,以此收集代码运行过程中的数据。插桩可以在编译期进行,也可以在运行时。
调用级 业务代码某次触发的一次调用,来源可以是一个网络请求、单元测试、自动化用例等
覆盖率 描述代码执行情况的指标,常见的有增量覆盖率、全量覆盖率、分支覆盖率等等
调用链 方法调用链路,记录调用请求处理过程中,走过的所有方法。本质是一个有向图,节点表示函数,边表示调用关系,可能存在环。

Ellyn 要解决什么问题?

在应用程序并行执行的情况下,精确获取单个用例、流量、单元测试走过的方法链(有向图)、出入参数、行覆盖等运行时数据,经过一定的加工之后,应用在覆盖率、影响面评估、流量观测、精准测试、流量回放、风险分析等研发效能相关场景。

常见的覆盖率工具实现

常见的覆盖率工具,原理都是通过在代码关键节点,插入全局探针数组来实现覆盖状态的采集。伪代码如下

var flags []bool

func method() {
     flags[0] = true
     n := 100
     k := 10 
     sum := 0 
     for i := 0 ; i < n ; i ++ {
         if i % k == 0  {
             flags[1] = true
             sum += i
         } else {
             flags[2] = true
             sum += 1
         }
     }
     flags[3] = true
     println(sum)
}

这里为了可读性调整了格式,实际生成的代码,一般追加在文件或者已有行的尾部,不会影响原代码的行号。

以上面的伪代码为例,我们看看各开源实现是怎么对代码插桩的。

Go test cover 实现方案

Go test cover 插桩逻辑可以在 go 源码中看到

插桩完成后的代码如下

//line api.go:1
package cover_example                              

import "fmt"                                       

func method() {GoCover.Count[0]++;                 
        n := 100                                   
        k := 10                                    
        sum := 0                                   
        for i := 0; i < n; i++ {GoCover.Count[2]++;
                if i%k == 0 {GoCover.Count[3]++;   
                        sum += i                   
                } else{ GoCover.Count[4]++;{       
                        sum += 1                   
                }}                                 
        }                                          
        GoCover.Count[1]++;fmt.Println(sum)        
}                                                  

var GoCover = struct {                             
        Count     [5]uint32                        
        Pos       [3 * 5]uint32                    
        NumStmt   [5]uint16                        
} {                                                
        Pos: [3 * 5]uint32{                        
                5, 9, 0x19000f, // [0]
                16, 16, 0x120002, // [1]
                9, 10, 0xf0019, // [2]
                10, 12, 0x4000f, // [3]
                12, 14, 0x40009, // [4]
        },
        NumStmt: [5]uint16{
                4, // 0
                1, // 1
                1, // 2
                1, // 3
                1, // 4
        },
}

七牛云 GOC

https://github.com/qiniu/goc

核心逻辑跟 Go test cover 类似的,部分代码也是复用的 go test cover 源码:pkg/cover/internal/tool/cover.go。

七牛云 cover 需要有 main package 才能插桩,实现原理与 go test cover 一致。不过七牛云将探针数组生成到了一个单独的文件,而不是像 go test coverI 具追加到源码文件尾部

  goc-build-4fa554c51f8f ls
api.go  api_test.go  go.mod  http_cover_apis_auto_generated.go  src
  goc-build-4fa554c51f8f cat api.go                           
//line /tmp/goc-build-4fa554c51f8f/api.go:1
package main; import . "cover_example/src/gocbuild4fa554c51f8f"

import "fmt"

func method() {GoCover_0_396638376133663931613965.Count[0]++;
        n := 100
        k := 10
        sum := 0
        for i := 0; i < n; i++ {GoCover_0_396638376133663931613965.Count[2]++;
                if i%k == 0 {GoCover_0_396638376133663931613965.Count[3]++;
                        sum += i
                } else{ GoCover_0_396638376133663931613965.Count[4]++;{
                        sum += 1
                }}
        }
        GoCover_0_396638376133663931613965.Count[1]++;fmt.Println(sum)
}

func main() {GoCover_0_396638376133663931613965.Count[5]++;
        method()
}

  goc-build-4fa554c51f8f cat src/gocbuild4fa554c51f8f/cover.go
package gocbuild4fa554c51f8f


var GoCover_0_396638376133663931613965 = struct {
        Count     [6]uint32
        Pos       [3 * 6]uint32
        NumStmt   [6]uint16
} {
        Pos: [3 * 6]uint32{
                5, 9, 0x19000f, // [0]
                16, 16, 0x120002, // [1]
                9, 10, 0xf0019, // [2]
                10, 12, 0x4000f, // [3]
                12, 14, 0x40009, // [4]
                19, 21, 0x2000d, // [5]
        },
        NumStmt: [6]uint16{
                4, // 0
                1, // 1
                1, // 2
                1, // 3
                1, // 4
                1, // 5
        },
}

Jacoco 方案

$jacocoInit 方法将按照 class+method 维度获取相应的全局探针数组,原理其实与 go 的类似。得益于 java 语言的动态能力,jacoco 不仅支持编译期插桩,也支持运行时插桩。另外,Go 是源码插桩,所见即所得,而 Jacoco 是字节码插桩,插入的是字节码指令,下面的代码是插桩完的字节码反编译之后的源码。

package testapp;

public class Application {
    public Application() {
        boolean[] var1 = $jacocoInit();
        super();
        var1[0] = true;
    }

    public static void method() {
        boolean[] var0 = $jacocoInit();
        int n = 100;
        int k = 10;
        int sum = 0;
        int i = 0;

        for(var0[1] = true; i < n; var0[4] = true) {
            if (i % k == 0) {
                sum += i;
                var0[2] = true;
            } else {
                ++sum;
                var0[3] = true;
            }

            ++i;
        }

        System.out.println(sum);
        var0[5] = true;
    }
}

全局探针方案的优劣

这类方案的优势是,实现简单,并且性能影响极小(特别在客户端大规模代码插桩时)。但最明显的缺点是只能收集全局粒度的数据,无法细分单个调用的覆盖和链路数据。

要细分单个调用数据,折中的方案是通过求快照差来近似获取覆盖数据,比如在做流量回放时,要收集单个回放流量的覆盖数据:在回放之前,先清空全部的覆盖数据;在回放完成后,记录一次最新的覆盖率数据,以这份数据作为流量的覆盖数据。这么做会有两个很明显的问题

另外基于全局探针采集的方案,还有两个明显的缺点,即使通过快照差也无法解决:

Ellyn 实现方案

Ellyn 命令行工具,在编译期修改目标业务代码,在函数、代码块入口等关键位置,植入 SDK 调用,并将 SDK 源码拷贝到目标项目,跟随目标项目一起编译。

遍历代码并在关键位置插入代码,则是基于 GO AST API,读取每一个源码文件,解析并遍历 AST(抽象语法树),在函数和代码块的开始位置植入代码,函数植入非常容易,函数有很直接的分隔符,AST 可以直接遍历单个函数,因此很容易在函数开始位置植入代码。比较麻烦的是代码块,这里代码块可以理解为一段在不发生异常的情况下,可以连续执行的一段代码,一直到控制语句或者代码块结束符(go 为}')为止,跟静态分析中的 Basic Block 很相似,程序并没有直接的、固定的块分隔符,AST 也没有抽象的 Block 节点,因此需要自行遍历所有 Statement,寻找控制语句和结束符,自行记录开始结束位置,手动划分 Block。遍历完所有文件后,将方法、Block 等元数据通过 go embed 压缩集成到目标程序中。

插桩完的目标代码编译运行后,SDK 将按照协程粒度收集数据,模拟函数弹栈入栈的操作,当函数弹空时,说明当前协程调用结束了,可以将当前协程数据放入本地的 RingBuffer 队列,等待后续的加工、上报等处理。如果是同一个调用(流量)触发的多个异步覆盖,则将多个协程的数据通过链路 ID 关联起来,这个关联合并的动作可以放在上报后端实现,进一步降低对本地的性能影响。

程序架构

原理图

工具能力

支持调用级链路数据采集,链路数据包括方法链路(堆栈)、方法出入参、方法耗时、异常、error、行覆盖等。并且工具内部默认集成了一个简单的 web 页面,可以在本地可视化查看链路数据

难点和挑战

Ellyn 插桩是对代码有侵入的,因此需要充分考虑稳定性和性能方面的影响,并且由于采集的是调用级的数据,数据量巨大,数据存储本身也是一大挑战。

稳定性

性能影响

插桩代码要避免对业务代码造成明显的性能影响。

技术上

能力上

当然,即使经过各种优化,由于插桩语句做了更多的操作,即使是 0(1)级别的无锁操作,依然比传统方案仅一次数组访问的指令要更多。在一些 CPU 敏感型场景,Ellyn 插桩性能损耗依然比传统方案要高。

基准测试

以下对比了排序、搜索、压缩、加密、文件读写、网络请求等场景下的性能影响,在涉及有 IO 操作的情况下,Elyn 影响可以忽略不计,在纯 CPU 密集型场景,有一定性能损失。实际上,互联网业务大部分场景都是有 IO 操作的,比如读写 DB、RPC 调用,甚至于仅打印日志(非异步写),因此性能影响基本可以忽略。

无插桩(基准)

goos: linux
goarch: arm64
pkg: benchmark
BenchmarkQuickSort-4                        137570             42951 ns/op            4088 B/op               9 allocs/op
BenchmarkBinarySearch-4                   228617994                26.24 ns/op               0 B/op               0 allocs/op
BenchmarkBubbleSort-4                        44918            133785 ns/op            4088 B/op               9 allocs/op
BenchmarkShuffle-4                          330484             18054 ns/op               0 B/op               0 allocs/op
BenchmarkStringCompress-4                     3034           1903760 ns/op          876214 B/op              33 allocs/op
BenchmarkEncryptAndDecrypt-4                590178              9990 ns/op            1312 B/op              10 allocs/op
BenchmarkWrite2DevNull-4                   1428777              4202 ns/op             304 B/op               5 allocs/op
BenchmarkWrite2TmpFile-4                    535009             10967 ns/op             128 B/op               1 allocs/op
BenchmarkLocalPipeReadWrite-4               265272             21792 ns/op            2176 B/op              18 allocs/op
BenchmarkSerialNetRequest-4                    387          15407760 ns/op           40489 B/op             480 allocs/op
BenchmarkConcurrentNetRequest-4               1713           3576828 ns/op          136009 B/op             990 allocs/op
PASS
ok          benchmark        76.928s

0.0001 采样(万分之一)

goos: linux
goarch: arm64
pkg: benchmark
BenchmarkQuickSort-4                        107018             55802 ns/op            4089 B/op               9 allocs/op
BenchmarkBinarySearch-4                   81365398                72.32 ns/op               0 B/op               0 allocs/op
BenchmarkBubbleSort-4                        33294            182848 ns/op            4093 B/op               9 allocs/op
BenchmarkShuffle-4                          320906             18466 ns/op               0 B/op               0 allocs/op
BenchmarkStringCompress-4                     2468           3261636 ns/op          876280 B/op              35 allocs/op
BenchmarkEncryptAndDecrypt-4                563416             10802 ns/op            1344 B/op              12 allocs/op
BenchmarkWrite2DevNull-4                   1368524              4353 ns/op             304 B/op               5 allocs/op
BenchmarkWrite2TmpFile-4                    521224             11328 ns/op             128 B/op               1 allocs/op
BenchmarkLocalPipeReadWrite-4               272166             20679 ns/op            2193 B/op              18 allocs/op
BenchmarkSerialNetRequest-4                    435          13852948 ns/op           40875 B/op             494 allocs/op
BenchmarkConcurrentNetRequest-4               1730           3471552 ns/op          136226 B/op             992 allocs/op
PASS
ok          benchmark        77.277s

数据存储

如果采集每个调用的全量链路的所有数据,存储开销必然是非常巨大的,可以考虑采样,并且只采集关键的出入参数据,或者仅采集方法链路和代码块覆盖数据。同时可以考虑冷热分离,全量数据采用廉价的离线存储,而实时数据则限定有效时间,定期清理和压缩归档。也可以考虑只存储聚合计算之后的数据,而不存储每一个调用的明细数据,比如汇总和去重存储调用链。具体策略可以根据应用场景调整。

主要应用场景

覆盖率采集

除了能支持基本的增量、全量、分支覆盖率之外,还可以实现更为精细化的覆盖数据采集。比如

影响面评估

影响面评估的核心基础是 callgraph 或控制流图。主流的方案是基于静态分析,但静态分析除了算法本身准确性之外,一些运行时决策的调用,比如反射,比如将一组方法放在 slice、map 中,运行时计算 key 进行的调用,静态分析是完全无法分析出来的,此时基于 Ellyn 动态收集的链路数据可以作为有效补充。基于动静结合的方式可以有效提升影响面评估的准确率(查准率)和召回率(查全率)。

链路观测

支持采集单个单元测试\自动化测试\流量的调用链明细数据,包括函数调用链、方法出入参、耗时、error/panic、行覆盖等信息,并将其绑定到一个链路 ID 上(可以是 logid/traceid 等)。进一步可以基于链路 ID 将全链路的数据串联起来。

最直接的应用场景就是基于可视化页面,帮助研发和 QA 同学在测试环境定位联调测试问题。相对于基于日志定位问题更加直观。

单测生成

由于可以全量采集所有方法的出入参和方法调用链,因此可以基于累积的数据,辅助生成单元测试。比如按照单测试 AAA 模式

·Arrange(准备)

。基于采集的入参,构造请求参数

。基于方法调用链以及下游函数的出入参,生成下游函数调用的 mock

·Act(执行):执行单测

·Assert(断言)

。基于采集的出参,对返回结果生成断言语句

精准测试

Ellyn 可以将单个用例的覆盖数据绑定到一个链路 ID 上(logid/traceid 等),因此,只需要进一步建立链路 ID 和用例的关系,就可以间接建立用例与代码方法或代码块的映射关系(知识库)。在用例推荐时,只需要对变更版本和线上版本进行 Function Diff 或者 Block Diff,再基于 Diff 结果反查知识库,即可实现函数级精准(成本更低)或者代码块级(裁剪率更高)精准。

而建立用例和链路 ID 的关系往往很容易做到,如自动化用例、单元测试等,在执行前后我们都可以很容易从上下文拿到链路 ID,而对于手工用例,则可以通过录制工具来绑定这个关系。

与基于传统覆盖率方案实现的精准方案不同的是,Ellyn 实现精准可以更精确,并且很容易可以做到代码块级别,可以获得远高于方法级精准的裁剪率。

Mock 平台

Ellyn 插桩的本质是在所有方法内插入语句,因此可以拦截方法的执行。插桩过程会遍历项目,获取项目中的所有方法标识、参数类型列表等元数据,可以进一步实现一个基于方法标识+实际参数匹配的规则引擎,在任意方法维度配置 mock 规则,插桩代码检查是否命中 mock 规则,命中则直接返回。可以实现方法级 mock,比服务粒度的 mock 灵活度更高。

风险分析

可以基于插桩采集的链路数据,分析程序中潜在的风险,包括但不限于稳定性、资损防控、隐私合规等。

比如稳定性方面

再比如,可以收集运行时的 panic 信息,包括 panic 发生时的堆栈信息,调用链信息、出入参数等等,帮助研发定位 panic 根因,降低线上 panic 风险。

项目地址

https://github.com/lvyahui8/ellyn


↙↙↙阅读原文可查看相关链接,并与作者交流