I. 前言
APM全称是 ApplicationPerformanceManagement
,即应用性能管理平台。对于公司而言,应用发展到现在这个阶段,在人员不断扩展、业务不断复杂、新产品不断孵化,拥有一个统一质量、安全管理平台对于应用健康状况全方位实时的把脉,以及对于业务的可持续化发展的保驾护航是尤为重要的。
在去年其实这样的平台还很少像今年这般被频繁提及。尤记得前几天刚刚参加绿色联盟会议,对于应用的质量、安全无论是阿里、腾讯、新美大还是360都绕不过这个主题,而大家都知道谷歌更是在今年发力推Vitals监控,似乎在今年这个时间点,各大公司都使出浑身解数不约而同的加大了这方面的投入。
无独有偶,2018年流利说Android架构组有一个小目标,就是将原本杂乱的交付、监控等流程系统化、体系化,便于横向项目的发展以及确保各业务线、产品线可持续发展,其中APM就是很重要的一块:
其实在年初我们公司组织架构扩大后,流利说整体已经进入了人员与业务的快速增长的快车道,对于几十个模块、业务线与多条产品线的情况下,原有的那套应对中小应用的体系已经显现了一些问题。因此我们着手从各个阶段入手对其一一击破,今天,我们与大家分享的就是我们在搭建APM过程中,对于大盘搭建与数据整合收集部分,希望大家都能够有所收获。
特别值得一提的是之前在谈到[《客户端持续交付工程实践》这篇文章时,我们谈到了为了确保质量,在最后出了各类的质量报告,并将其通过邮件的方式落地,在最后的时候甚至提出了如何避免线上裸奔,其实这些最后都在APM大盘中得到了进行的落地,这些特性组合让APM大盘展示实现了通过数据与绿线告知测试完备程度以及应用质量程度的任务,后面我们会谈到,大家跟上脚步?,我们往下看:
II. 概况
首先谈到APM大盘,就必须需要确定我们需要监控哪些维度,我们先来看下最终的决定:
之所以选这三个维度,是结合我们目前的发布流程以及开发节奏的特性来确定的:
-
最直接的是监控24小时内的全局的实时数据变化 -->
实时变化大盘
-
而对于每个版本两两比较也是十分重要的,因此便衍生了横跨一年的版本维度的变化大盘 -->
版本变化大盘
-
由于我们每个版本内的开发周期有
dev
、alpha
、beta
,拥有较长的固定的迭代周期,因此我们需要在版本发布之前对当前版本特别的关照,从而衍生了一个仅仅只包含当前版本的数据大盘 -->开发周期大盘
在确定了三大维度之后,我们要做技术选型的确定,其实这块在搭建流利说APM平台之初我们就遇到了一个问题,对于流利说的Android架构团队来说,我们还很难像腾讯、阿里、饿了么、爱奇艺那样投入足够的人力建立非常完善的自有的各类监控平台与体系,因此我们采用了一些现有成熟的体系框架与监控平台来减轻我们的工作量,将有限的精力聚焦在监控整合以及如何展示测试完备程度与应用质量的核心问题上。因此我们采用了我们的云架构团队提供基于开源方案的Promethues数据库与Grafana展示平台,以及在一些监控方面我们采用了第三方数据平台作为数据依据以及问题处理平台(如bugly)再做二次数据采集整合。这也祭奠了流利说APM大盘的另外一个对所有数据的整合与入口的特性,当然这也就影响了我们主要的技术选型:
而对于各类的自动化收集与大盘维度数据控制的自动化,我们需要依赖以下实体媒介来搭载我们的各类服务:
在第一期APM大盘需要监控的数据方面我们选择了 包大小
、 关键页面打开耗时95线
、 内存泄漏
、 遗留BUG数
、 冷/热启动
、 代码质量情况
以及 ANR
与 CRASH
,再加上权衡测试完备程度的 参加测试情况
与 综合覆盖率
,这样的选择的主要原因是,第一期实际上是APM大盘的从无到有,我们核心工作是将整个框架搭建起来,因此在数据选择方面我们主要选择从对用户直观感受与影响最为明显的维度出发进行选择。
其中的 代码质量情况
相信有同学会提出疑惑,这块其实是 FindBugs
、 PMD
、 Lint
这三块扫描出的不需要强制处理 Warning
级别的各类数量情况,而对应的 Error
级别的问题,在代码合并的时候已经被强制处理了,这块如果感兴趣,我们在《客户端持续交付工程实践》中有详细的进行了阐述。
对于刚刚谈到我们有依赖第三方数据平台作为数据依据,主要是将其作为问题处理平台,而涉及到数据处理主要涉及这三块: Crash
、 ANR
、 内存泄漏
。对这三个数据而言我们不得不依赖一个解决问题的平台用于对具体问题的进一步分析、归类,以及对解决进度的标注。其中 Crash
与 ANR
大多数平台都拥有,而内存泄漏我们采取的方案是基于 LeakCanary
进行收集然后作为错误进行上报,具体后文会提到。
后端的APM服务的开发,对于服务而言主要就是提供各类RESTful接口提供后端数据库的数据写入,一些页面的展示(如综合覆盖率详情页)以及文件上传的支持(如 jacoco
的 ec
文件的收集),这边由于我们Android技术栈的原因直接使用了 kotlin
基于 SpringBoot
进行快速开发迭代。
III. 维度分解
1. 开发周期大盘
开发周期是跟着版本走的,因此这块是强绑开发流程的,在开发周期不断的迭代中会进行自动的跟进,并且整个大盘数据维度切换时机是根据以小版本的切换来跟进的,比如 6.8.x
发布后,大盘所展示的所有数据就会自动变迁为 6.9.x
,而最后一位的 patch
版本由于只会在 hotfix
分支上出现,周期很短会有其他流程跟进,不会在该开发周期大盘中体现。
能够做到对单独某个版本维度的展示,得益于 Grafana
中可以使用 MySQL
作为数据源,并支持灵活的使用各类 SQL
语句对数据进行塞选,因此我们只需要在CI上创建周期任务,在当前开发分支定期扫描当前版本,当版本发生变动的时候写入到对应的版本数据库中即可,而所有其他数据获取数据时,只需要进行联表查询取最新版本相关的数据即可。
这里提到的开发分支在不同的阶段会有所不同,还记得在[客户端持续交付工程实践》中我们提到的开发流程时的那张图:
这里可以清晰的看到,在版本提测前当前的开发分支是 develop
,接收各种类型的合入,并且合入审核只 OkCheck
跑过,代码 Owner
与另外一位同学 Approve
即可合入;当提测后,当前的开发分支会变迁为 release
,通常只接收代码 fix
,此时也就是只接收 fix/xxx
的分支合入,并且在 beta
后代码合入还需要发布经理的 Approve
。为了减少大家合并代码可能照成的误操作,这边还开发了 lit
工具,由于篇幅限制,这个话题我们就没有再深入了(我们有计划在明年对这个发布流程进行较大幅度的调整,以后有机会也会与大家进行分享的)。
我们来简单通过一个例子来看下开发周期大盘这块的数据展示的情况,假如我们拥有一个开发版本的表如下:
此时我们便可以通过取降序限制 1
个结果直接拿到最新版本的数据,作为当前版本周期:
如上图,其中 $__timeFilter
函数是 Grafana
提供的,由于 Grafana
支持设置当前面板所展示的数据的时间范围,因此这里的 $__timeFilter(date_created)
等价于 date_created
的时间戳在设定范围内的条件。
紧接着我们尝试拿到当前版本的内存泄漏泄漏个数:
完美,符合预期!其他的数据也是以此类推的获取即可。
对于开发周期大盘,我们希望大家在不同的时间点关注不同的维度,在开发阶段只需要重点关注 包变化
与 代码质量
情况,而当提测后由于代码趋于稳定,我们此时应该需要开始关注各类重要的质量指标。
特别是在提测后(当前版本周期进入 alpha0
后),大家需要通过在大盘中根据 参加测试人数
与 综合覆盖率
作为衡量测试完备程度的一个标准,在测试完备程度尽可能高的情况下,我们跟进其他的数据指标对相关问题进行修复,修复后由于代码的修改 综合覆盖率
会下降,此时再通过灰度等方式提高完备程度,再修复问题打磨应用,以此形成一个良性的闭环。
其中我们通过明确关键发布节点的规则来对这块的落地,在 alpha
阶段测试同学开始介入对测试完备程度的跟进,在 beta
前测试同学需要将完备程度提高到我们在 Grafana
中定义的 SingleStat
图表中的绿线的阈值。而相同的,开发同学也需要在 beta
前将几个基础指标也提高到对应的绿线阈值以内,在达标并提交 beta
包后,这边需要保持无论是完备程度还是各项质量数值都在绿线阈值范围内即可。
2. 版本变化大盘
其实在建立APM时,相信每一个团队都会遇到一个问题,那就是我们如何去权衡里面所罗列出的每一项质量指标数值所代表的实际含义是好,还是坏。其实对于我们而言,这个问题十分简单清晰,只需要与自己的上一个版本进行对比,因此就有了版本变化大盘。
版本变化大盘便是收集各个版本的综合表现,以最直观的形式进行呈现。大家从上图可以看到我们清晰的将版本变化大盘拆解为了5个部分,囊括了 基础性能指标
、 关键页面耗时
、 包情况
、 Qark安全扫描
以及 潜在代码质量问题
。
所有的数据也是同样来自 MariaDB
的数据源,主要原因是版本周期通常来说跨度比较大,间隔也会比较长,并且考虑到我们有很多联表查询的场景,因此这边便没有使用 Prometheus
这种偏向于实时监控数据库作为数据源。哪怕是有些数据是直接打到 Prometheus
的(如关键页面耗时),我们也会在适当的时候周期性的根据版本拉回一个综合数值塞给 MariaDB
。
这边举一个案例,我们需要知道每相邻两个包的大小变化情况:
为了让包大小的变更更加直观,我们实际需要知道的是每个版本相对于上个版本的变化值,这里利用了 SQL
可以灵活的定义变量并进行前后的比较进行实现。
3. 实时变化大盘
实时大盘实际上展示的就是最近24小时内,应用的健康情况,以及线上用户的直观感受,所有数据都是全版本的综合值。
III. 数据收集
在数据收集方面,这块和前面提到的技术选型有着很大的关系。对于数据的获取,主要采用三种方式:
-
应用运行时上报:
关键页面耗时
、运行时Jacocoec
-
CI周期性扫描上报:
包情况
、安全报告
、代码质量
、AndroidTest与UnitTestec
、综合覆盖率详情
、 -
应用API调用:
Phabricator上遗留BUG数
-
服务器爬虫部署:
Crash
、ANR
、内存泄露
、冷/热启动耗时
、参与测试情况
这边使用爬虫而非一些站点的提供的API的原因是因为第三方的平台提供的API基本上是不符合我们要求的,而我们这边无法Push对方新增/修改接口,So...要不自己写平台,要不爬虫,由于人手不足时间有限,我们选择了后者。
其中 Crash
、 ANR
、 冷/热启动耗时
、 关键页面耗时
是集成在应用中带到线上的,而 内存泄露
、 综合覆盖率相关
是只有在测试包才会带有,其他的是基本上解耦应用本身的数据分析。
这边对于数据存储方面也十分明确,对于实时性非常强的 Crash
、 ANR
、 内存泄露
、 冷/热启动耗时
、 关键页面耗时
、 参与测试情况
、 Phabricator上遗留BUG数
这边是直接打到Promethues上,而对于在持续有 ec
刷新的情况下才会15min刷一次的 综合覆盖率
、以及每天刷新一次的 包情况
、 安全报告
、 代码质量报告
这些便直接打到 MariaDB
即可。不过为了版本变化大盘以及当前周期大盘的联表查询,这边还有另外一个周期性半天任务,就是从Prometheus上扫描所有类型的数据,进行综合计算后会刷新到 MariaDB
,也就是说实际上 MariaDB
上拥有所有数据只不过没有Prometheus实时。
P.S. 关于Prometheus的使用以及每个数据Metric类型的选择直接参看Promethues的官方文档即可,Prometheus的各类文档还是非常全的。
IV. 数据整合
1. 综合覆盖率
相信大家在大盘上,能够注意到有一个数据可能很多厂商的APM大盘都没有包含的 --- 综合覆盖率
谈综合覆盖率的数据整合之前,先和大家说说我们统计这块的原因,其实对于应用发布而言,如何权衡测试的完备程度一直是一个十分棘手的问题。而综合覆盖率本身其实并没有神奇的效果,但是他可以十分明确的做到一件事情,那就是如果综合覆盖率是100%,那说明所有代码都有被执行到,那么剩下的事情就是我们如何做到只要存在问题的代码被执行到,我们就能够自动化的将其进行上报。这块其实很好的能够与几个基础指标融合,如 ANR
、 Crash
等,这些本来就是遇到了就会自动上报的。我们通过结合当发生这些问题的时候,当前的覆盖率 ec
文件将不被上报,来让覆盖率这件事情行之有效的在很大意义上体现测试完备程度并且驱动问题修复形成闭环。当然对于一个体系化的集成测试而言,实际上代码的简单的单次执行并无法暴露所有问题,更多的是与该次执行的上下文有很强的联系,这块就需要无论是接口测试、功能测试以及Monkey等自动化测试的完善了,不过在这里对于测试完备程度而言一个完善的综合测试的覆盖率机制依然是一个相对可靠的参考纬度。
对于开发大盘而言,我们需要综合覆盖率与参与测试的人数、启动次数作为测试完备度的一个衡量标准,所有数据都是建立在综合覆盖率达标并且参与人数达标的情况下,才用于其阶段性的意义。在早期我们尝试推过全量的综合覆盖率,但是由于测试同学与开发同学很难就很老的代码花大量的人力去进行覆盖,因此我们结合我们的开发周期对jacoco进行了定制,实现了基于版本的差分的综合覆盖率计算与输出。
这里涉及了较多维度的定义,首先简单来说:
综合覆盖率
= AndroidTest覆盖率
merge UnitTest覆盖率
merge 运行时覆盖率
其实,真正的计算方式是:
综合覆盖率
= 该版本修改代码中被执行过的行数的总和
/ 该版本中所修改代码的总行数
而具体的该版本修改的被执行的行数总和:
该版本修改代码中被执行过的行数的总和
= AndroidTest中执行到被修改的
merge UnitTest中执行到被修改的
+ 运行时中执行到被修改的
虽然看起来公式似乎挺复杂的,但是实际上基于Jacoco Gradle Plugin的 JacocoMerge
我们就可以做合并的操作,因此只需要分别得到三种情况下执行的 ec
文件即可,在具体分析之前对其进行合并,然后在使用 JacocoReport
出报告后针对报告根据 git blame
拿到当前版本修改过的代码进行二次标注即可。
针对 AndroidTest
与 UnitTest
较为简单,我们在 GitLab
上直接创建周期性的任务进行 ec
文件生成即可,而针对 运行时
,我们在每次应用推到后台时主动将当前的 ec
文件上传到后台,唯一需要注意的是,多进程情况,在调用 org.jacoco.core.runtime.RuntimeData#reset
之前,每次通过 org.jacoco.agent.rt.RT.getAgent#getExecutionData()
取到的 ec
文件都是带有所有的 ec
数据,因此这边可以采用同一个进程使用相同的文件名进行覆盖上传即可,最简单的方法就是在当前进程第一次收集的时候生成一个 UUID
,后续不断复用即可,而后台 RESTful
接口的行为需要确保相同名称文件使用覆盖的措施。
这边我们在设计综合覆盖率的时候也并非一帆风顺,遇到不少有意思的问题,我们捡一些与大家分享下:
由于我们需要收集的是 AndroidTest
、 UnitTest
、 Runtime
这几种环境下的 ec
文件,这里其实有一个很有意思的问题,就是最终的综合覆盖率报告,我们需要从报告上获知具体哪些行被覆盖了,这里报告内肯定需要非混淆的源码才可读,而这里所产生的 ec
文件却是来自几个环境, AndroidTest
与 UnitTest
所执行的是未混淆的环境, Runtime
由于需要测试同学的参与、甚至灰度内部大量同学的参与,我们肯定是需要给到混淆包的,这样一来理论上来说,这两种一个来自未混淆包的 ec
,一个来自混淆包的 ec
最终合并然后生成报告肯定是对应不上的,后来在我们来回比对,并解析 ec
文件后发现,通过jacoco注入后的包,jacoco会自动注入一个对当前类的说明:
因此这块问题Jacoco本身就已经给我们解决了。
而在做针对版本的差量的综合覆盖率时,我们遇到了一个相对棘手的问题,如下图:
我们假设当前版本是 6.9.0
,而下一个版本是 6.10.0
,还记得前面提到的我们目前的发布流程,在应用提测之前大家都是在 develop
分支上进行合并代码,而当应用提测后, 6.9.0
就会迁出 release
分支,并且在 release
分支上进行开发,而 develop
此时的任何代码的合并都将不会在这个版本带上,因为此时 develop
分支已经变为 6.10.0
的开发分支, 6.9.0
的开发分支已经变为 release
,当 6.9.0
发布后会合并回 develop
分支,此时 develop
分支依然是 6.10.0
的开发分支只不过合入了 6.9.0
提测后的代码。
这里就遇到了一个问题,我们计算当前版本的综合覆盖率时所选取的变更来源,不能是从 A
到 HEAD
,因为这样一来就包含了 6.9.0
提测后的所有代码,也不能是 B
到 HEAD
,因为这样就丢失了 6.9.0
提测期间 6.10.0
并行开发的那部分提交。实际上我们所需要的是途中绿点的所有变更。这块得益于我们之前在做变更集时的相关积累,通过 git
提供的方法,计算出从 A
到 HEAD
的Diff中每一行的 Commit
,然后将属于红色这条分支的 Commit
的行过滤掉,这样就得到了所有绿色部分修改的行。
Jacoco还有一些版本的兼容问题,比如低版本的Jacoco生成的 ec
文件跨度一大在高版本上就直接解析失败,比如 0.7.4
与 0.8.2
(这是截止本文发布的最新稳定版本)生成的 ec
是相互解不开的,因此我们在接收 ec
文件的时候需要注意做好根据版本区分开。还有另外一点, Jacoco
从 0.8.2
开始对 kotlin
做了一些支持,不过 0.8.2
版本需要依赖 AndroidGradlePlugin
的 3.2.1
或更高版本。
最后,除了外层的覆盖率方面我们添加了基于版本的覆盖率,在详情页中,我们通过左侧的标注来说明该代码是当前版本修改的,该标注是超链接到Gitlab上的相关Commit的,其他部分保留了 Jacoco
原本的面貌。
2. 包信息、代码质量、Qark安全问题
这些内容其实在《流利说客户端持续交付工程实践》中我们就有提到,只不过当时是通过邮件进行通知的,后来我们将其上报到APM的 MariaDB
上,如果感兴趣可以在那边文章进行查看。
3. 内存泄露
前面也提到了,关于内存泄漏,我们是直接通过 LeakCanary
注册 RefWatcher
对所有的泄漏进行监听,其中也包含了 isExcluded=true
的系统级别的泄漏,但是会在上报时进行区分,主要原因是无论是 Framework
层的泄漏还是我们上层代码逻辑泄漏,泄漏的都是我们应用的虚拟机,其对用户的影响也是直接表现在我们自己的应用上的。
其中存在的问题是,我们在流程上需要将应用控制在绿线内,因此我们不能让系统级别Stick级别的泄露阻碍了应用的发布,也不能让Stick级别的泄露被隐藏,因此我们将一些已知的Stick级别的泄露单独提Task跟进,而直接将这块的问题标注为了解决。
内存泄漏这块还有一个小坑,我们刚开始是直接将泄漏的堆调用信息作为 message
以错误的形式上报给Bugly,利用Bugly中根据不同 message
归为不同的错误的形式,将相同堆调用信息的泄漏合并起来,但是后来发现,对于不同的泄漏,在上报的错误中虽然使用了不同的 message
但是由于他们的错误栈可能相同(都是走上报的那个栈),导致不同的泄漏被合并为了同一个,这个问题最后我们将内存泄漏的堆调用信息写入到栈中后得到了解决。
4. 关键页面耗时
对于关键页面耗时这块由于我们需要计算的是95线的数值,简单来说就是95%的用户所感知的耗时都是在该数值之内,由于这块数据方面的特点,我们分享下其在Promethues上的坑点与解决方案。
这边很显然我们需要使用 Histogram
这个类型,由于我们除了95线,在做进一步分析中还需要更多的数据纬度,在各方面的权衡下,我们对每个版本的每个页面监控取20个 Bucket
,那么问题就来了,我们平均发一个版本,整个流程下来 dev
、 alpha0
、 beta0
、 beta1
、 release
,一共5个版本,假定我们需要监控10个页面,组合下来就是发布一个版本总共就需要 20*5*10
= 1000
个新增,并且版本是随着时间的推移不断新增的,这样累计下去将是相当浪费的。
因此这边我们修改策略,因为我们线上在周期中最新的长期只会有一个版本在跑,并且本地的发布流程常年也只会关注正在开发的版本,因此我们这边不再采用特定版本,而是规定版本只为两个: 发布版本
、 开发版本
,这么一来整体的数目就被固定了,只需要在版本发布时,将对应的版本告知数据接收方,刷新 发布版本
与 开发版本
的定义,存储关注的综合数据后,清空数据,并且只接收定义的这两个版本数据即可。
V. 总结
今天我们主要分享了我们在APM大盘这块的工程实践,而对于APM而言,拥有大盘只是其中必不可少的第一步,更重要的是对于大盘的使用,我们通过在几个关键节点的流程上进行落地,在应用提测后,测试同学、架构团队、业务团队分别有轮值的同学进行跟进相关绿线,测试同学主要负责完备的测试的绿线,而业务与架构的同学确保应用质量的绿线,再达成后才可 beta
;另外我们的Android团队有每周的AWTT&Party会议,其中有一趴就是对APM所展现的质量情况的Review。当然结合APM大盘对测试完备的定义,对应用质量的定义无论是用于我们现在这套开发流程,还是未来其他的开发流程对于应用的快速迭代以及可持续发展都是十分重要的,由于篇幅有限,我们就不做细说了,下次有机会再与大家分享,也十分欢迎大家多多拍砖,评论。