Whosbug开发经历

背景需求

在多人协同开发的项目中,我们常常会使用到Git作为版本管理和协作编码的工具。

程序的运行过程中,Bug的产生是不可避免的。而通过Bug的错误堆栈,我们需要有效的定位责任人,从而能针对性的修复Bug。

由此衍生出了我们对Whosbug功能的需要。

实现逻辑

我们的粗略实现逻辑如下:

发送错误堆栈
获取仓库信息
获取文件变动
定位发生变动的方法/函数
置信算法分析
Bug-Report
得到责任人

 

有了粗略的功能运作逻辑,我们就可以对每个部分进行合理的拆分。

需要分离的部分

在实际使用中,我们很容易想到,获得仓库信息和得到责任人,应当发生在两个部分。发送错误堆栈的接收者应当是一个公共的整体,所有相关的项目都可以对这个公共整体发送错误堆栈来获取相关的责任人。而获取仓库信息和获取文件变动的部分,需要每个项目的每次分析独立。

我们可以得到拆分的结果:置信算法的部分可以放置在一个服务器上部署,文件分析部分需要在每次项目分析时启动

所以我们把项目抽象成两个层级:数据解析算法分析

数据解析的具体方案

我们抽象出文件分析部分的逻辑:

数据解析
发送解析的结果
置信算法分析
定位发生变动的方法/函数
获取文件变动
获取仓库信息
算法分析

获取仓库的Git信息

如何获取仓库信息?

因为我们一般意义上的仓库都是基于Git的代码托管,所以我们很容易想到使用Git来帮助我们完成数据的获取:

这里我们将内容重定向到Diffs.out文件中,获取到的内容范例:

image-20210821182951317

image-20210821183901008

依据这个结构,我们很容易就可以获得所有的文件变动信息和所有的commit信息。

那么我们实现获取信息这一部分的方法就明了了:

重定向到Diffs.out
重定向到CommitInfo.out
git log --full-diff -p -U10000 --pretty=raw
获取diff信息
解析文件变动
git log --pretty=format:%H,%ce,%cn,%cd
获取commit信息

这部分的代码很容易实现,这里我特意编写了一个工具方法来重复使用:

这里为什么要重定向到本地文件而不是直接装入内存呢?

因为在面对大型仓库的首次接入时,如果贸然将所有的内容都装入内存,可能会面临内存溢出的问题。

获取文件变动信息

我们比较容易的实现了获取我们想要的文件变动和commit信息之后,接下来要面对的问题就是如何有效的提取变动信息。

如我们一开始所设想的,我们希望获得每一次commit中的文件变动涉及到的函数/方法,为了达到这个目的,我们主要需要做这样几个事情:

  1. 获得变动的文件的完整文本
  2. 找到变动的行号
  3. 找到变动的函数/方法名
  4. 将变动和提交者绑定起来

其中,第1、2部分,都是可以在我们目前设想中的部分实现的。

1-2.获得变动的文件的完整文本和变动的行号

为了获得变动的文件的完整文本,我们可以观察diff文件的格式:

image-20210827064343742

一个完整的Diffs.out文件包含有一个或多个commit,它们都由一个头部开始:

每一次commit可能包含多个文件变动(即多个diff),它们也都由一个五行的头部开始:

diff中的行首位的加减号即标识了变动的行:

image-20210827063757325

我们不难发现,每一个diff中即包含一个完整的文件文本。

所以要获得每一个diff的文本,我们只需要匹配到每一次commit的每一次diff,并把内容读出即可。

那么获取变动的文本就可以利用正则匹配:

  1. 匹配一次commit
  2. 匹配commit中的每一个diff

为了最大限度减少IO的次数,我们采用了双循环交错读的方法来按顺序读取每一次commit,并在每读取到一个完整的commit之后送进ParseDiff进行解析。

这里我们还对文本进行了处理,删去了-行内容,保留了+行的符号后的内容。

3.找到变动的函数/方法名

要实现第3部分,我们就需要引入AST解析来获得变动代码的位置所处的函数名等可能会用到的信息。

这里我们选择了相对灵活一些的Antlr4 AST解析框架。

ANTLR (ANother Tool for Language Recognition) 是一个强大的解析器生成器,用于读取、处理、执行或翻译结构化文本或二进制文件。它被广泛用于构建语言、工具和框架。在一个语法中,ANTLR生成一个可以建立解析树的解析器,还可以生成一个监听接口(或visitor访问接口),使其容易对特定的短语的识别做出反应。

针对送入的每一个diff文本,我们都对其进行完整分析:

这里以对Java的分析为例:

Antlr将会调用实现了JavaParserListener接口的结构体方法,逐个按规则解析文本,我们在Listener中对特定的规则中添加逻辑代码,就可以收集到我们需要的信息。

例如在退出方法定义时,我们通过以下代码即可得到我们需要的信息:

为什么要使用sync.pool来分配listener

此处的sync.pool并不是为了减小GC的压力,而是为了实现Antlr的解析并发的线程安全的必要工作。

这将会调用我们的Listener对传入的文本进行解析。

AST解析的代码这里不作展示,亦不深入讲解。我们在此过程中最终需要获得的有效信息包含(不同语言的情形并不完全相同):

解析完成后,返回包含我们需要的内容的结构体。

4.将变动和提交者绑定起来

获得了我们需要的内容之后,我们将这些每一个变动中的各种变动信息称为一组Object,然后将它们与Commit信息关联起来,并选择性的保留变动行涉及的Object,同时由于变动行可能存在多个,还需要对其去重。

上传Object到WebService

当前面的数据解析处理任务已经完成之后,我们需要将解析得到的结果发送给WebService进行分析。

如果按照前面的思路,继续线性的发送下去,可能会产生这样的局面:

数据解析每完成一次diff的解析就向服务器发送一次object,如果diff都非常的小,那么解析的速度将会非常的快,也就造成不停的向服务器发送信息,服务器耗尽连接的资源,拒绝超出的所有发送请求,内容发生丢失或返回错误。

为了避免上述情形,我们需要维护一个缓冲机制。

数据上传
数据解析
数据缓冲
缓冲池已满
缓冲池空闲
缓冲池空闲
达到发送阈值
未满发送阈值
达到发送阈值
解析已完成仍未达到阈值
WebService
触发超时不饱和发送
等待缓冲池空闲
生成Objects
数据解析
缓冲池Channel
等待到达阈值

通过示意图可以看到,我们使用了一个Channel来连接和协调数据解析和上传两个部分的工作,二者不存在线性依赖关系。

我们还可以对缓冲区的管理协程的数量做出控制,使得上传部分的过程可以实现并发。

并发的设计调整

依照我们已经设计好的架构,我们很容易可以发现,在diff的处理方面我们有意对其做了拆分,使其与其他模块没有依赖关系,这正是我们并发解析数据的出发点。

为什么不以Commit为单位并发,而要以Diff为单位并发?

原因很简单,单个Commit可能会包含多个Diff,这意味着Commit的大小比Diff更大,如果我们的并发数量设计不合理,我们将有更高的风险在并发数目过大导致性能衰减或者程序崩溃,所以我们选择了最小的粒度Diff来作为并发的单位。

我们引入Ants库作为我们并发的协程池,使用阻塞式的协程队列,可以有效的限制内存的开销。

每一个Diff处理协程同时包含着一个Antlr解析的过程。

Antlr本身的线程并不安全,原因在于其Go-Runtime版本的lib没有对语法解析监听树进行及时的回收,为了性能而使用了指针也是线程不安全的罪魁祸首之一。所以我们使用sync.pool来分配lexerparser变量,隔离了协程之间的共享,并在接口实现的结构体内添加了协程内的“全局变量”,最终实现了有效的线程安全,维持了Diff解析的并发可靠性。


未完待续