词语表明
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;
- }
- }
复制代码 全局探针方案的优劣
这类方案的优势是,实现简朴,并且性能影响极小(特殊在客户端大规模代码插桩时)。但最显着的缺点是只能收集全局粒度的数据,无法细分单个调用的覆盖和链路数据。
要细分单个调用数据,折中的方案是通过求快照差来近似获取覆盖数据,比如在做流量回放时,要收集单个回放流量的覆盖数据:在回放之前,先清空全部的覆盖数据;在回放完成后,记录一次最新的覆盖率数据,以这份数据作为流量的覆盖数据。这么做会有两个很显着的问题
- 回放流量\用例执行等只能串行执行,否则因为并发影响,无法通过快照差来求覆盖率数据,回放效率低下。
- 单个流量\用例的覆盖数据依然可能存在噪音,比如一些旁路的异步逻辑(定时器、MQ消费等)造成的覆盖数据,也会统计到当前流量\用例上。
别的基于全局探针采集的方案,还有两个显着的缺点,即使通过快照差也无法办理:
- 虽然采集了覆盖率大概已覆盖的方法,但无法还原调用链/控制流图。
- 数据无法全链路串联起来,流量很可能经过了多个后端服务,每个服务收集的覆盖数据是孤立且不绑定哀求信息的,因此无法串联起来
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插桩是对代码有侵入的,因此需要充实考虑稳定性和性能方面的影响,并且由于采集的是调用级的数据,数据量巨大,数据存储自己也是一大挑战。
稳定性
- 避免插桩之后目标项目无法运行,插桩工具可以支持回滚。同时插桩工具的准出应该配套丰富的自动化和灰度流程。
- 避免自身的异常抛出给业务,导致业务代码异常。对Go语言即应该recover自身所有可能的panic(当然fatal error是无法recover的,此类问题可以依靠自动化、灰度等在准出阶段发现)。
- 可以进一步支持动态关闭、自动降级等能力
- 可以监控CPU、内存使用情况,自动暂停恢复采集能力
- 可以增加监控埋点,结合监控系统进行告警
- 插桩采集逻辑可以增加限流,低落在大流量场景下对目标项目标影响。
性能影响
插桩代码要避免对业务代码造成显着的性能影响。
技术上
- 避免加重量级锁,确保每个协程只操作当前协程的数据
- 需要高频创建使用的对象,考虑池化,减少GC的压力。
- 插桩到业务代码的方法要确保每一个方法都是O(1)时间复杂度的操作。
- 高频访问的字段进行缓存行填充,避免伪共享。
- 整数索引的场景,只管考虑用bitmap(bitset)大概数组,而非map。
能力上
- 支持多种采样计谋
- 参数采集涉及序列化,对性能影响较大,支持不同的参数采集计谋,须要时可以关闭参数采集
当然,即使经过各种优化,由于插桩语句做了更多的操作,即使是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
复制代码 数据存储
如果采集每个调用的全量链路的所有数据,存储开销必然是非常巨大的,可以考虑采样,并且只采集关键的出入参数据,大概仅采集方法链路和代码块覆盖数据。同时可以考虑冷热分离,全量数据采用廉价的离线存储,而及时数据则限定有效时间,定期清算和压缩归档。也可以考虑只存储聚合计算之后的数据,而不存储每一个调用的明细数据,比如汇总和去重存储调用链。具体计谋可以根据应用场景调解。
主要应用场景
覆盖率采集
除了能支持基本的增量、全量、分支覆盖率之外,还可以实现更为精细化的覆盖数据采集。比如
- 链路覆盖率
- 与传统的分支覆盖率不同的是,分支覆盖率分母是所有条件的笛卡尔积,实际此中很多分支链路是不可达的,因此无法准确给分支覆盖率一个合理的目标。而链路覆盖率,分母可以是线上环境和测试环境累积的所有可达链路,分子是本次回归覆盖的所有链路,因此可以以100%为近似的覆盖目标(有可能无法达到100%是因为分母中的部分链路可能已经失效,这里就需要考虑数据的保鲜计谋了)。
- 分场景覆盖率
- 比如区分自动化还是手工测试的覆盖,甚至可以二次开发,将覆盖数据与测试账号绑定,明确具体是哪个场景、哪个用户造成的覆盖。
影响面评估
影响面评估的焦点底子是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灵活度更高。
风险分析
可以基于插桩采集的链路数据,分析程序中埋伏的风险,包括但不限于稳定性、资损防控、隐私合规等。
比如稳定性方面
- Ellyn采集的链路数据包罗链路是否有异步调用,以及各异步链路的出入参,通过分析异步链路的出参结果是否影响主链路的出参结果,可以识别该异步链路是否为弱依靠。
- 可以基于动态采集的链路数据结合静态分析数据得到一份非常完备的流量(链路)大图,可以应用在容量治理、红蓝攻防的爆炸半径分析等。
再比如,可以收集运行时的panic信息,包括panic发生时的堆栈信息,调用链信息、出入参数等等,帮助研发定位panic根因,低落线上panic风险。
项目地址
https://github.com/lvyahui8/ellyn
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。 |