Whosbug开发经历背景需求实现逻辑需要分离的部分数据解析的具体方案获取仓库的Git信息获取文件变动信息1-2.获得变动的文件的完整文本和变动的行号3.找到变动的函数/方法名4.将变动和提交者绑定起来上传Object到WebService并发的设计调整未完待续
在多人协同开发的项目中,我们常常会使用到Git作为版本管理和协作编码的工具。
程序的运行过程中,Bug的产生是不可避免的。而通过Bug的错误堆栈,我们需要有效的定位责任人,从而能针对性的修复Bug。
由此衍生出了我们对Whosbug功能的需要。
我们的粗略实现逻辑如下:
有了粗略的功能运作逻辑,我们就可以对每个部分进行合理的拆分。
在实际使用中,我们很容易想到,获得仓库信息和得到责任人,应当发生在两个部分。发送错误堆栈的接收者应当是一个公共的整体,所有相关的项目都可以对这个公共整体发送错误堆栈来获取相关的责任人。而获取仓库信息和获取文件变动的部分,需要每个项目的每次分析独立。
我们可以得到拆分的结果:置信算法的部分可以放置在一个服务器上部署,文件分析部分需要在每次项目分析时启动
所以我们把项目抽象成两个层级:数据解析
和算法分析
我们抽象出文件分析部分的逻辑:
如何获取仓库信息?
因为我们一般意义上的仓库都是基于Git的代码托管,所以我们很容易想到使用Git来帮助我们完成数据的获取:
1// 该命令获取当前仓库的所有diff信息(文件变动信息)
2git log --full-diff -p -U10000 --pretty=raw
这里我们将内容重定向到Diffs.out
文件中,获取到的内容范例:
xxxxxxxxxx
21// 该命令获取当前仓库的所有commit信息
2git log --pretty=format:%H,%ce,%cn,%cd
依据这个结构,我们很容易就可以获得所有的文件变动信息和所有的commit信息。
那么我们实现获取信息这一部分的方法就明了了:
这部分的代码很容易实现,这里我特意编写了一个工具方法来重复使用:
x1// ExecRedirectToFile
2// @Description: 执行命令并将输出流重定向到目标文件中
3// @param fileName 目标文件目录
4// @param command 执行的指令头
5// @param args 执行指令的参数
6// @author KevinMatt 2021-07-29 17:31:00
7// @function_mark PASS
8func ExecRedirectToFile(fileName string, command string, args ...string) error {
9 cmd := exec.Command(command, args...)
10 log.SetOutput(LogFile)
11 log.Println("Cmd", cmd.Args)
12 fd, _ := os.OpenFile(fileName, os.O_WRONLY|os.O_CREATE|os.O_SYNC, os.ModePerm)
13 cmd.Stdout = fd
14 cmd.Stderr = fd
15 err := cmd.Start()
16 if err != nil {
17 return errors.Wrap(err, "Start cmd Fails.")
18 }
19 err = cmd.Wait()
20 if err != nil {
21 return errors.Wrap(err, "cmd Wait Fails.")
22 }
23 err = fd.Close()
24 if err != nil {
25 return errors.Wrap(err, "FD close Fails.")
26 }
27 return err
28}
29
30// GetLogInfo
31// @Description: 获取所有的git commit记录和所有的commit+diff,并返回存储的文件目录
32// @return string 所有diff信息的目录
33// @return string 所有commit信息的目录
34// @author KevinMatt 2021-07-29 17:25:39
35// @function_mark PASS
36func GetLogInfo() (string, string) {
37 err := ExecRedirectToFile("commitInfo.out", "git", "log", "--pretty=format:%H,%ce,%cn,%cd")
38 if err != nil {
39 fmt.Println(utility.ErrorStack(err))
40 }
41 err = ExecRedirectToFile("allDiffs.out", "git", "log", "--full-diff", "-p", "-U10000", "--pretty=raw")
42 if err != nil {
43 fmt.Println(utility.ErrorStack(err))
44 }
45
46 return "allDiffs.out", "commitInfo.out"
47}
这里为什么要重定向到本地文件而不是直接装入内存呢?
因为在面对大型仓库的首次接入时,如果贸然将所有的内容都装入内存,可能会面临内存溢出的问题。
我们比较容易的实现了获取我们想要的文件变动和commit信息之后,接下来要面对的问题就是如何有效的提取变动信息。
如我们一开始所设想的,我们希望获得每一次commit中的文件变动涉及到的函数/方法,为了达到这个目的,我们主要需要做这样几个事情:
其中,第1、2部分,都是可以在我们目前设想中的部分实现的。
为了获得变动的文件的完整文本,我们可以观察diff文件的格式:
一个完整的Diffs.out文件包含有一个或多个commit,它们都由一个头部开始:
xxxxxxxxxx
51commit commit的Hash
2tree commitTree的Hash
3parent 上一次commit的Hash
4author 作者名 <邮箱> 时间戳
5committer GitHub <noreply@github.com> 时间戳
每一次commit可能包含多个文件变动(即多个diff),它们也都由一个五行的头部开始:
xxxxxxxxxx
51diff --git a/变动前文件目录 b/变动后文件目录
2index diff索引
3--- a/有删改的文件目录
4+++ b/有添加的文件目录
5
diff中的行首位的加减号即标识了变动的行:
我们不难发现,每一个diff中即包含一个完整的文件文本。
所以要获得每一个diff的文本,我们只需要匹配到每一次commit的每一次diff,并把内容读出即可。
那么获取变动的文本就可以利用正则匹配:
xxxxxxxxxx
1091// patCommit, patTree 预编译正则匹配
2var patCommit, _ = regexp.Compile(parCommitPattern)
3var patTree, _ = regexp.Compile(parTreePattern)
4
5// MatchCommit
6// @Description: 获得完整文本
7// @param diffPath diff-commit文件目录
8// @param commitPath commit-info文件目录
9// @author KevinMatt 2021-07-29 17:37:10
10// @function_mark PASS
11func MatchCommit(diffPath, commitPath string) {
12
13 commitFd, _ := os.Open(commitPath)
14 diffFd, _ := os.Open(diffPath)
15
16 lineReaderCommit := bufio.NewReader(commitFd)
17 lineReaderDiff := bufio.NewReader(diffFd)
18 for {
19 line, _, err := lineReaderDiff.ReadLine()
20 if err == io.EOF {
21 break
22 }
23
24 res := patTree.FindString(string(line))
25 if res != "" {
26 // 匹配到一个commit的tree行,从commit info读一行
27 commitLine, _, err := lineReaderCommit.ReadLine()
28 if err == io.EOF {
29 break
30 }
31
32 var commitInfo global_type.CommitInfoType
33
34 commitInfo = utility.GetCommitInfo(string(commitLine))
35 // 获取一次完整的commit,使用循环交错读取的方法避免跳过commit
36 fullCommit, err := getFullCommit(patCommit, lineReaderDiff)
37 if err != nil {
38 fmt.Println(utility.ErrorStack(err))
39 }
40
41 // 获取单次commit中的每一次diff,并处理diff,送进协程
42 ParseDiff(fullCommit, commitInfo)
43 }
44 }
45 err = commitFd.Close()
46 if err != nil {
47 log.Println(errors.WithStack(err))
48 }
49 err = diffFd.Close()
50 if err != nil {
51 log.Println(errors.WithStack(err))
52 }
53}
54
55// ParseDiff
56// @Description: 将commit内的diff解析后存入SourceCode中
57// @param data 传入的fullCommit字符串
58// @param CommitHash 本次commit的Hash
59// @author KevinMatt 2021-07-29 22:54:33
60// @function_mark PASS
61func ParseDiff(data string, commitInfo global_type.CommitInfoType) {
62 // 匹配所有diffs及子匹配->匹配去除a/ or b/的纯目录
63 rawDiffs := patDiff.FindAllStringSubmatch(data, -1)
64
65 // 匹配diff行的index列表
66 indexList := patDiff.FindAllStringIndex(data, -1)
67
68 // 遍历所有diff
69 for index, rawDiff := range rawDiffs {
70
71 // 如果非匹配的语言文件,直接跳过
72 if !lanFilter(path.Base(rawDiff[2])) {
73 continue
74 } else {
75 // 获得左索引
76 leftDiffIndex := indexList[index][0]
77
78 var diffPartsContent string
79 var rightDiffIndex int
80 // 判断是否为最后一项diff,随后获取代码段
81 if index == len(rawDiffs)-1 {
82 diffPartsContent = data[leftDiffIndex:]
83 } else {
84 rightDiffIndex = (indexList[index+1])[0]
85 diffPartsContent = data[leftDiffIndex:rightDiffIndex]
86 }
87
88 // 匹配@@行
89 rightDiffHeadIndex := patDiffPart.FindStringSubmatchIndex(diffPartsContent)
90
91 // 无有效匹配直接跳过
92 if rightDiffHeadIndex == nil {
93 continue
94 }
95 temp := strings.Split(diffPartsContent[rightDiffHeadIndex[4]:rightDiffHeadIndex[5]], " ")
96 OldLineCount := QuatToNum(temp[0][1:])
97 NewlineCount := QuatToNum(temp[1][1:])
98
99 // 获取所有行,并按"\n"切分,略去第一行(@@行)
100 lines := (strings.Split(diffPartsContent[rightDiffHeadIndex[1]:][0:], "\n"))[1:]
101
102 // 传入行的切片,寻找所有变动行行号
103 changeLineNumbers := findAllChangedLineNumbers(lines)
104
105 // 替换 +/-行,删除-行内容,切片传递,无需返回值
106 replaceLines(lines)
107 }
108 }
109}
为了最大限度减少IO的次数,我们采用了双循环交错读的方法来按顺序读取每一次commit,并在每读取到一个完整的commit之后送进ParseDiff
进行解析。
这里我们还对文本进行了处理,删去了-
行内容,保留了+
行的符号后的内容。
要实现第3部分,我们就需要引入AST解析来获得变动代码的位置所处的函数名等可能会用到的信息。
这里我们选择了相对灵活一些的Antlr4 AST解析框架。
ANTLR (ANother Tool for Language Recognition) 是一个强大的解析器生成器,用于读取、处理、执行或翻译结构化文本或二进制文件。它被广泛用于构建语言、工具和框架。在一个语法中,ANTLR生成一个可以建立解析树的解析器,还可以生成一个监听接口(或visitor访问接口),使其容易对特定的短语的识别做出反应。
针对送入的每一个diff文本,我们都对其进行完整分析:
这里以对Java的分析为例:
xxxxxxxxxx
261func ExecuteJava(diffText string) AnalysisInfoType {
2 // 截取目标文本的输入流
3 input := antlr.NewInputStream(diffText)
4 // 初始化lexer
5 lexer := javaLexerPool.Get().(*javaparser.JavaLexer)
6 defer javaLexerPool.Put(lexer)
7 lexer.SetInputStream(input)
8 // 初始化Token流
9 stream := antlr.NewCommonTokenStream(lexer, 0)
10 // 初始化Parser
11 p := javaParserPool.Get().(*javaparser.JavaParser)
12 defer javaParserPool.Put(p)
13 p.SetTokenStream(stream)
14 // 构建语法解析树
15 p.BuildParseTrees = true
16 // 启用SLL两阶段加速解析模式
17 p.GetInterpreter().SetPredictionMode(antlr.PredictionModeSLL)
18 // 解析模式->每个编译单位
19 tree := p.CompilationUnit()
20 // 创建listener
21 listener := newJavaTreeShapeListenerPool.Get().(*JavaTreeShapeListener)
22 defer newJavaTreeShapeListenerPool.Put(listener)
23 // 执行分析
24 antlr.ParseTreeWalkerDefault.Walk(listener, tree)
25 return listener.Infos
26}
Antlr将会调用实现了JavaParserListener
接口的结构体方法,逐个按规则解析文本,我们在Listener中对特定的规则中添加逻辑代码,就可以收集到我们需要的信息。
例如在退出方法定义时,我们通过以下代码即可得到我们需要的信息:
xxxxxxxxxx
231// ExitMethodDeclaration
2// @Description: 匹配到方法结束时被调用
3// @receiver s
4// @param ctx
5// @author KevinMatt 2021-07-23 23:14:09
6// @function_mark PASS
7func (s *JavaTreeShapeListener) ExitMethodDeclaration(ctx *javaparser.MethodDeclarationContext) {
8 var methodInfo MethodInfoType
9 if ctx.GetChildCount() >= 2 {
10 MethodName := ctx.GetChild(1).(antlr.ParseTree).GetText()
11 methodInfo = MethodInfoType{
12 StartLine: ctx.GetStart().GetLine(),
13 EndLine: ctx.GetStop().GetLine(),
14 MethodName: MethodName,
15 MasterObject: findJavaMasterObjectClass(ctx),
16 }
17 resIndex := s.FindMethodCallIndex(methodInfo.StartLine, methodInfo.EndLine)
18 if resIndex != nil {
19 methodInfo.CallMethods = RemoveRep(resIndex)
20 }
21 s.Infos.AstInfoList.Methods = append(s.Infos.AstInfoList.Methods, methodInfo)
22 }
23}-
为什么要使用sync.pool
来分配listener
?
此处的
sync.pool
并不是为了减小GC的压力,而是为了实现Antlr的解析并发的线程安全的必要工作。
这将会调用我们的Listener对传入的文本进行解析。
AST解析的代码这里不作展示,亦不深入讲解。我们在此过程中最终需要获得的有效信息包含(不同语言的情形并不完全相同):
方法
类
解析完成后,返回包含我们需要的内容的结构体。
获得了我们需要的内容之后,我们将这些每一个变动中的各种变动信息称为一组Object
,然后将它们与Commit信息关联起来,并选择性的保留变动行涉及的Object
,同时由于变动行可能存在多个,还需要对其去重。
xxxxxxxxxx
281// addObjectFromChangeLineNumber
2// @Description: 传入的参数较多,大致功能是构建object
3// @param commitDiff
4// @param changeLineNumber 行号变动
5// @param antlrAnalyzeRes antlr分析结果
6// @return objectInfoType
7// @author KevinMatt 2021-08-03 19:26:12
8// @function_mark PASS
9func addObjectFromChangeLineNumber(commitDiff global_type.DiffParsedType, changeLineNumber global_type.ChangeLineType, antlrAnalyzeRes AnalysisInfoType) global_type.ObjectInfoType {
10 // 寻找变动方法
11 changeMethod := findChangedMethod(changeLineNumber, antlrAnalyzeRes)
12 if changeMethod.MethodName == "" {
13 // 为空直接跳过
14 return global_type.ObjectInfoType{}
15 }
16
17 var newObject global_type.ObjectInfoType
18 newObject = global_type.ObjectInfoType{
19 CommitHash: commitDiff.CommitHash,
20 Id: changeMethod.MasterObject.ObjectName + "." + changeMethod.MethodName,
21 OldId: "",
22 FilePath: commitDiff.CommitHash,
23 OldLineCount: changeMethod.EndLine - changeMethod.StartLine,
24 NewLineCount: commitDiff.NewLineCount,
25 Calling: changeMethod.CallMethods,
26 }
27 return newObject
28}
当前面的数据解析处理任务已经完成之后,我们需要将解析得到的结果发送给WebService进行分析。
如果按照前面的思路,继续线性的发送下去,可能会产生这样的局面:
数据解析每完成一次diff的解析就向服务器发送一次object,如果diff都非常的小,那么解析的速度将会非常的快,也就造成不停的向服务器发送信息,服务器耗尽连接的资源,拒绝超出的所有发送请求,内容发生丢失或返回错误。
为了避免上述情形,我们需要维护一个缓冲机制。
通过示意图可以看到,我们使用了一个Channel
来连接和协调数据解析和上传两个部分的工作,二者不存在线性依赖关系。
我们还可以对缓冲区的管理协程的数量做出控制,使得上传部分的过程可以实现并发。
依照我们已经设计好的架构,我们很容易可以发现,在diff的处理方面我们有意对其做了拆分,使其与其他模块没有依赖关系,这正是我们并发解析数据的出发点。
为什么不以Commit为单位并发,而要以Diff为单位并发?
原因很简单,单个Commit可能会包含多个Diff,这意味着Commit的大小比Diff更大,如果我们的并发数量设计不合理,我们将有更高的风险在并发数目过大导致性能衰减或者程序崩溃,所以我们选择了最小的粒度Diff来作为并发的单位。
我们引入Ants库作为我们并发的协程池,使用阻塞式的协程队列,可以有效的限制内存的开销。
每一个Diff处理协程同时包含着一个Antlr解析的过程。
Antlr本身的线程并不安全,原因在于其Go-Runtime版本的lib没有对语法解析监听树进行及时的回收,为了性能而使用了指针也是线程不安全的罪魁祸首之一。所以我们使用sync.pool
来分配lexer
和parser
变量,隔离了协程之间的共享,并在接口实现的结构体内添加了协程内的“全局变量”,最终实现了有效的线程安全,维持了Diff解析的并发可靠性。