大家对于Go语言可能不陌生,但在日常工作当中,对Go语言本身提供的单元测试、覆盖率等工具可能并不熟悉。本文将简单介绍一下Go语言提供的各种方便提升代码质量的工具,供大家参考,并在工作中灵活使用,以提升代码的质量。主要介绍内容包括,Go语言及其周边工具提供的单元测试能力、Benchmark功能、代码覆盖率、Fuzz测试能力以及数据竞争检查。案例编写正式介绍之前,我们需要编写一个案例,是一个本地记账软件,会将收支记录下来,并提供平均值等简单统计指标的计算功能。首先,我们定义好账户中每条记录的类型枚举:然后,我们定义账户中每条记录的结构以及账户的结构:对于账户,我们首先要支持的就是记账,我们的记账类型分为收入和支出,因此我们也定义两个方法,分别用来完成收入和支出操作。接下来,是账户支持的平均数统计功能,其可以返回账户内简单的统计结果。最后,除了记账外,我们还支持对记录的随机访问和删除。可以看到,账户中记录的删除操作支持批量操作,为了性能考虑,我们使用了Go语言提供的goroutine来并发完成,充分利用了Go语言的并发能力。到此,我们初步完成了我们程序的功能。但是,它是否存在问题呢?我们接下来通过Go语言提供的各种测试能力,来发现潜在的问题。单元测试单元测试是指对代码中最小可测试单元进行检查的测试和验证方法。我们庞大的代码仓库也是由一个一个小的逻辑和单元组成起来的,就像摩天大楼是由一块一块砖堆砌起来的一样。而单元测试就是对这些“砖”进行测试,只要这些小的单元正常执行,我们的“大楼”才有可能拔地而起。Go语言本身的工具链支持完备的单元测试能力,同时提供了testing包来控制测试的流程,与此同时,也可以使用各种开源库来书写更加紧凑的断言等,如本文使用的github.com/go-playground/assert/v2。在Go中,所有以_test.go结尾的文件,均会被认为是单元测试文件,会被go命令识别,并可通过go test来执行,并输出报告。文件中Test开头的函数为我们的测试函数。为了用来检查我们上文中实现的功能是否存在问题,我们接下来为上文中的代码实现单元测试,首先创建main_test.go,并添加如下代码:在测试函数中,我们首先创建一个账户,然后增加收入记录1条,增加支出记录1条。并通过账户提供的OperationCount函数计算出每种操作类型的数量。然后通过assert包完成断言。在我们执行go test -v(-v表示输出更多信息)后,我们得到了如下输出:可以看到,我们的测试并未通过,其中15行income是2,而不是我们期待的1。通过代码Review我们发现,在TakeIn函数和PayOut函数中,我们忘记给Type进行赋值,从而导致记录的类型出错,因此断言才没有通过。在知道原因后,我们将代码更改为如下样子:此时再执行单元测试,我们发现代码可以成功的通过测试。Benchmark性能通常来说至关重要,在完成功能和保证正确性的同时,性能当然是越快越好。有时候由于算法、编码方式等不同,性能也有很大的差别。为了能够量化这些差异,为我们选择更快的算法提供理论依据,Go语言也提供了Benchmark功能。和单元测试一样,Benchmark也存在于以_test.go结尾的文件中。但和单元测试不同的是,Benchmark函数均以Benchmark开头,并通过go test -bench=.来执行。在本文的示例中我们发现OperationCount执行较慢,因此提供了一个优化的版本OperationCountFast函数。为了能够更准确的知道优化后版本究竟快了多少,我们提供了对应的Benchmark函数:可以看到,两个Benchmark函数几乎一样,只是调用的方法不同。在执行go test -v -bench=. -run=^#(^#表示跳过单元测试,仅执行Benchmark)后,我们得到了以下输出:可以看到,新的AccountOperationFast执行速度是AccountOperation的三倍,通过量化后的数据,我们可以放心的选择AccountOperationFast来获得更好的性能。代码覆盖率代码覆盖率可以配合单元测试,来查看我们单元测试的覆盖度,覆盖度越高,代表更多的代码被测试过,质量相应的也就越高。在Go语言中,可以方便的通过go命令来执行单元测试,生成覆盖率文件。我们通过go test --coverprofile=coverage.out命令执行单元测试,并生成覆盖率文件coverage.out。在执行后,我们得到了如下输出:可以看到,我们代码中有26.2%被测试文件覆盖到,而生成的coverage.out文件中包含了详细的覆盖信息:但是由于文本文件看起来并不方便,Go语言也提供了工具可以方便的将覆盖率信息转化为HTML文件方便观察,只需要执行go tool cover -html=coverage.out。Fuzz测试Fuzz测试也叫“模糊测试”,用来通过大量随机输入来挖掘软件安全漏洞、检测软件健壮性的黑盒测试。它通过向软件输入大量随机的字段,观测被测试软件是否完备地处理了各种输入情况。Fuzz测试面世以来,发现了大量的安全漏洞,其通过各种类型的输入,不间断的对程序进行冲撞,极大的拓展了测试的边界。Go语言从1.18版本开始,也支持了Fuzz测试。Go语言的Fuzz测试同C++的一样,系统输入大量随机的字节序列,并通过随机的字节序列组成入参,然后进行测试,直到发现空指针等难以发现的问题。目前Go语言的Fuzz测试入参支持的类型有:布尔型、整型、浮点型、字符串以及字节序列。用户可以根据需要调整入参类型,方便使用。于是,我们对上文中GetRecord方法进行了Fuzz测试。通过执行go test -fuzz=Fuzz命令来进行我们的Fuzz测试。在经过短时间的运行后,获得如下输出:通过错误我们可以清楚地看到,在Fuzz测试期间,爆出了下标越界的错误,同时导致越界的输入值为-79,通过检查GetRecord方法不难发现,接口虽然进行了入参最大范围的校验,但是却忽略了入参为负数的情况,从而使得程序并不健壮,当用户输入错误的入参的时候,会触发越界,导致panic这样严重的错误,给了黑客可乘之机。由此,我们很方便的完善了GetRecord方法,也使得这个方法变得健壮,可以防止各种非法入参攻击,大幅度提高程序的质量。数据竞争检测Go语言的一大特色是方便强大的并发能力,通过go关键字,轻松的启动goroutine。由于Go语言的并发模型充分地利用了多核能力,同时采用了抢占式的调度方式,因此带来方便和高效的同时,也引入了数据竞争的风险。数据竞争问题一直是多线程编程中难以避免却又十分重要的一个问题,其难以发现,随机触发,通常在测试环节比较难发现,甚至线上运行很久问题也始终没有浮现。但是一旦发生,往往造成的破坏又十分巨大。在其他语言当中,大家主要通过三种方法来规避数据竞争:①. 通过各种设计模式和严格遵守的设计规范来规避数据竞争。但是效果往往差强人意,各种测试代码也难以准确的测试出数据竞争的发生。如Java、C++等②. 不支持并发,通过一个全局锁来保证指令的顺序执行。但是性能的损失又是一个难以接受的理由。如Python等。③. 十分严格的静态检查来避免数据竞争。效果斐然,但是也极大的失去了程序的灵活性。如Rust。以上解决思路均有一定效果,但是又存在许多问题。有没有既能发现问题,又不用损失灵活性的方案呢?答案是肯定的。谷歌公司通过努力,针对C++语言推出了一套Sanitizer工具,包括Address Sanitizer、Leak Sanitizer、Undefined Sanitizer以及Thread Sanitizer等,其中Thread Sanitizer通过代码插桩和内存改造,在保证灵活性的同时,方便快速地检查出数据竞争的问题。而Go语言中的数据竞争检测,也是利用这套Thread Sanitizer工具。Go语言在编译过程中,如果加入了-race参数,就会使用Thread Sanitizer来进行代码插桩并链接相应的运行时,从而可以在代码测试过程中方便的检测出数据竞争所在。在此,我们利用Go语言的数据竞争检测功能,来完成对Remove方法数据竞争问题的检测。我们添加如下测试,创建一个账户,进行三次操作后,移除第一次和第二次的操作记录。由于Remove方法使用了并发来完成移除操作,因此可能会出现数据竞争的情况。正如上文中说到的那样,数据竞争问题难以发现。如果读者直接运行测试代码TestRemove100次,极大概率会发现100次运行的结果均正确。但是,Remove方法依然存在发生问题的可能性,a.Records的长度为3,在通过Remove方法删去2个记录后,正常情况下,剩余的记录数量为1,当数据竞争发生时,剩余的记录数量就会为2。通过代码Review,可以发现当Remove中两个goroutine发生数据竞争时,可能会导致某个删除操作被覆盖掉,从而产出错误的结果。通过go test -race -run=TestRemove,我们可以清楚地看到可能发生的数据竞争的各种情况。通过上述输出,我们可以看到Remove的当前实现,既有并发写Records的潜在风险,也有并发读写Records的潜在风险,这两种情况均会导致读取到的结果存在问题,从而导致隐秘又危险的问题发生。我们可以通过对Remove内部代码进行加锁改造,使得对Records的读和写都有锁保护,即可顺利通过检测,代码再无数据竞争的风险。总结Go语言吸取了大量的经验,为我们提供了方便的测试工具和测试能力。在以后的工作中,大家可以根据代码情况将这些工具利用起来,从而轻松完成稳定运行且没有漏洞的代码。分享给第一个想到的人