1. 浅谈代码覆盖率
作为 SET 和 SWE, 我们经常需要编写单元测试或集成测试用例来验证系统/应用的正确性, 但同时我们也常会质疑我们的测试是否充分了. 这时测试覆盖率是可以辅助用来衡量我们测试充分程度的一种手段, 增强发布成功率与信心, 同时给了我们更多可思考的视角. 值的注意的是代码覆盖率高不能说明代码质量高, 但是反过来看, 代码覆盖率低, 代码质量不会高到哪里去.
大部分的编程语言都自带了单元测试覆盖率的收集能力, Elixir 也同样如此, 官方提供的 mix 构建工具自带了覆盖率的收集能力, 但目前只适用于离线(offline)系统, 对于运行时系统, 并不适用. 本文将会基于 Erlang 的 cover 模块, 给出一个 Elixir 运行时系统的解决方案. 既然 cover 是 Erlang 的内置模块, 但为什么它也同样适用于 Elixir, 我们将会在后续的环节中揭开它神秘的面纱. 在开始之前, 让我们先看下开源社区进行运行时系统代码覆盖率采集的两种主流方式(这里我们看下语言社区生态庞大的 Java 的字节码插桩方式):
接下来让我们关注一下本文的 Elixir 运行时覆盖率收集的核心 - cover 模块.
2. 深度解析 Erlang Cover 覆盖率收集实现机制
2.1 Erlang Cover 简介
cover 是 Erlang 内置工具集(tools set)的一部分, 提供了代码覆盖率收集的能力.
2.2 Erlang 代码覆盖率收集实现分析
从 Erlang 关于 cover 模块官方手册可以知道, cover 统计了 Erlang 程序中每一可执行(executable line)被执行的次数.
从官方文档的介绍来看, cover 可以用于运行时系统的代码覆盖率收集, cover 进行代码插桩时, 并不会对任何模块的代码源文件或编译后生成的 beam 文件进行修改(即业界所说的 On-The-Fly 模式). 运行时系统每次可执行行被调用一次, 都会更新调用次数到 cover 用于存储数据的内存数据库中, 用于后续的覆盖率分析.
接下来, 我们将会去探索下 cover 进行 On-The-Fly 插桩的细节.
2.3 了解 BEAM File Format
在进一步了解 cover 实现细节之前, 我们有必要先了解下 Elixir 源码编译后的产物 BEAM 文件的格式. Elixir (.ex 文件)编译后的产物与 Erlang (.erl 文件)一样, 都是一个二进制分块文件(binary chunked file), 它被划分为了多个 section, 用于存储程序运行时用到的信息(如虚拟机操作指令). Erlang/Elixir 中, 每一个模块都会有一个对应的 BEAM 文件. BEAM 文件大致的结构如下图:
让我们来通过一个 Elixir mini demo 项目查看下 beam 文件大概内容:
- Step 1、clone 项目 yeshan333/explore_ast_app 到本地:
1 | git clone https://github.com/yeshan333/explore_ast_app.git |
- Step 2、构建此项目为 OTP release 格式, 本地需要安装 Elixir 和 Erlang:
1 | MIX_ENV=prod mix distillery.release |
可以关注到, 每一个 Elixir 模块, 都被编译成了一个 BEAM 文件(于目录_build/prod/rel/explore_ast_app/lib/explore_ast_app-0.1.0/ebin
中可以看到).
- Step 3、接下来让我们通过 Erlang 的标准库 beam_lib 文件查看 Beam 文件中的 chunk:
1 | 打开 iex console |
查看编译后 BEAM 文件 (Elixir.ExploreAstApp.beam
) 的所有 chunks:
1 | $ iex -S mix |
可以看到, 获取到的 chunks 是和之前的图对应的. 我们还可以通过 beam_lib 标准库获取到模块(ExploreAstApp)对应的 Erlang AST(抽象语法树):
1 | iex(3)> result = :beam_lib.chunks(String.to_charlist(beam_file_path), [:abstract_code]) |
可以看到 AST 以 Erlang Terms 的形式表示(称之为 Abstract Code), 方便阅读. 该 Abstract Code, 在 cover 进行 on-the-fly 插桩过程中大有妙用.
上述 AST 结构简单易读, 我们可以很简单的将其与模块编译前的源代码(lib/explore_ast_app.ex
)对应起来, 虽然该 AST 结构是最终的 Erlang AST, 被 Erlang 编译器添加了部分额外的信息, 但不影响阅读:
元组(tuple)中的第二个元素一般表示所处的源码行数. 你可以通过官方文档详细了解下 Erlang 的 Abstract Format, 动手多观察几个 BEAM 文件的 Erlang AST 的结构, 便可了熟于心. 值得注意的是 Abstract Code 在 OTP 20 之前是存放在 BEAM 文件的 Abst Chunk 中的.
如果你想了解更多关于 BEAM 文件的细节, 可以查看以下两篇文档:
- http://beam-wisdoms.clau.se/en/latest/indepth-beam-file.html#beam-term-format
- https://blog.stenmans.org/theBeamBook/#BEAM_files
2.4 Elixir 源码编译过程
了解了 BEAM File Format(BEAM 文件格式)之后, 我们还有必要了解下 Elixir 代码的编译过程, 有助于我们更好的理解 cover. Elixir 源码的编译为 BEAM 文件的过程可能和你想象的不太一样, 不直接从 Elixir 的 AST, 经过编译器后端的处理后成为可执行的 BEAM Code, 中间还有一个过程, 如下图所示:
上图的过程可以描述为:
- Step 1、Elixir 源代码会被自定义的词法分析器(elixir_tokenizer)和 yacc 进行语法分析生成初始版的 Elixir AST, AST 以 Elixir Terms 的形式表示;如果你对 Elixir 的 AST 感兴趣, 可以关注下这个项目 arjan/ast_ninja.
- Step 2、在 Elixir AST 阶段, 一些自定义的和内置的宏(Macros)还没有被展开, 这些宏在 Expanded Elixir AST 展开为最终的 Elixir AST(final Elixir AST);
- Step 3、final Elixir AST 经过 Elixir Compiler 处理会被转换为 Erlang 标准的 AST 形式(Erlang Abstract Format);
- Step 4、最后, Elixir 会使用 Erlang 的 Compiler 处理 Erlang AST, 将其转换为可被 BEAM 虚拟机(VM)执行的 BEAM 字节码. 关于 compiler 的细节, 可以查看: elixir_compiler.erl 和 elixir_erl.erl 源码, 如果你也想了解 Erlang Compiler 的细节, 可以查看 theBeamBook/#CH-Compiler.
2.5 Cover On-The-Fly 插桩实现
现在该来到正餐环节了, 让我们来看看 cover 是如何进行插桩和覆盖率收集的, 使用 cover 完成代码覆盖率收集, 必须要知道三把屠龙利剑:
cover:start
: 用于创建 cover 覆盖率收集进程, 它会完成存储覆盖率数据的相关 ets 表的创建, cover.erl#L159 & cover.erl#L632, 还可以启动远程(remote) Erlang 节点的 cover 进程.cover:compile_beam
: 进行插桩, cover 会读取 BEAM 文件的 abstract_code 的内容, 即 Erlang AST, 关键代码在 cover.erl#L1541, 然后对 Erlang AST From 进行 transform 和 munge, 它会调用 bump_call, 在每一个可执行行后插入如下 abstract_code:
1 | {call,A,{remote,A,{atom,A,ets},{atom,A,update_counter}}, |
通过前文对 Erlang AST 的了解, 我们知道这相当于插入了如下一行代码:
1 | ets:update_counter(?COVER_TABLE, #bump{module=Module, function=Function, arity=Arity, clause=Clause, line=Line}, 1). |
然后对于被 munge 后的 Erlang AST Form, cover 使用了 Erlang Compiler 从被 munge 后的 AST 表达形式中获取 Erlang Beam Code(又称 object code, 即字节码, VM 执行指令)cover.erl#L1580, 然后利用 Erlang code server 将获取到的新 object code 替换旧的 object code, load_binary
cover.erl#L1581 到了 ERTS(Erlang Run Time System)中 . cover 完成了 Erlang AST 插桩流程, 这样, 每当可执行行被执行, 对应的 ets 存储表都会更新该行被 call 的次数.
cover:analyze
: 分析 ets 表中存储的数据, 可获取可执行被执行(called)的次数, 可用于统计覆盖率数据.
munge: 用于对数据或文件进行一系列可能具有破坏性或不可撤销的更改.
3. Elixir Application 运行时覆盖率采集示例
通过前文, 在了解了 Erlang Cover 模块的实现细节之后, 让我们以一个部署运行的 Elixir Application(我们会使用之前的 yeshan333/explore_ast_app ) 为例, 进行Elixir 应用运行时的大型测试(系统 & 集成测试)代码行级覆盖率采集.
这里我们会使用到一个工具库: ex_integration_coveralls 进行覆盖率的分析, 它是 Erlang 模块 cover 的一个 Elixir Wrapper. 让我们开始:
- Step 1、添加
ex_integration_coveralls
依赖到mix.exs
文件中:
1 | defp deps do |
拉取依赖, 重新构建项目:
1 | mix deps.get |
- Step 2、启动项目:
1 | _build/prod/rel/explore_ast_app/bin/explore_ast_app foreground |
- Step 3、连接运行时应用节点的 remote_console:
1 | _build/prod/rel/explore_ast_app/bin/explore_ast_app remote_console |
- Step 4、利用 ex_integration_coveralls (ExIntegrationCoveralls.execute) 启动 cover, 执行代码覆盖率收集:
1 | iex(explore_ast_app@127.0.0.1)1> compiled_beam_dir_path = "/Users/yeshan/oss_github/explore_ast_app/_build/prod/rel/explore_ast_app/lib/explore_ast_app-0.1.0/ebin" |
可以看到, 初始的覆盖率是 0, 还没有代码被调用.
- Step 5、让我们执行以下 cURL :
1 | curl --location --request GET 'http://localhost:8080/hello' |
再次查看代码覆盖率数据:
1 | iex(explore_ast_app@127.0.0.1)6> ExIntegrationCoveralls.get_total_coverage(compile_time_source_lib_abs_path, source_code_abs_path) |
可以看到, cURL(测试)对该项目的覆盖率是 17.1%.
我们还可以使用如下方式查看更为详尽的代码覆盖情况, 比如查看 lib/explore_ast_app/router.ex
的代码覆盖情况(nil 表示该行不是 executable line):
1 | iex(explore_ast_app@127.0.0.1)7> result = ExIntegrationCoveralls.get_coverage_report(compile_time_source_lib_abs_path, source_code_abs_path) |
基于 post_cov_stats_to_ud_ci 接口,可以进一步对接内部或外部的类似于 Codecov 的覆盖率系统.
基于此, 我们可以实现在 Elixir Application 不停止运行的情况下, 配合大型(集成 & 系统)测试能力, 完成代码覆盖率的收集.
4. 大规模 Elixir/Erlang 微服务集群连续运行时覆盖率收集方案
随着 Elixir 微服务系统规模的不断扩大, 前一节所展现的覆盖率收集手段需要进一步的演进. 参考 Prometheus Pull-Base 的设计, 总体设计(Pull & Push 模式结合)如下:
我们基于 ex_integration_coveralls 做拓展, 在 Elixir Application 启动后, 拉起一个 http worker 将代码覆盖率数据实时暴露出去, 方便与异构系统的通信. 由 Coverage Push Gateway 负责定时拉取覆盖率数据(Gateway 可以是一个 OTP Application, 这让可以直接让ex_integration_coveralls
拉起 GenServer Worker 在分布式 OTP 系统进行交互集成), 在集成/系统测试系统告知测试结束后, Gateway 将覆盖率 push 给 Cover Center(覆盖率中心)进行代码覆盖率展示.
End(long way to go).