跳到主要内容

6 篇博文 含有标签「Go」

查看所有标签

Go1.20 新版覆盖率方案解读

· 阅读需 9 分钟
CarlJi
Coder|Blogger|Engineer|Mentor

玩过 Go 覆盖率的同学当有所了解,Go 的覆盖率方案最初的设计目标仅是针对单测场景,导致其局限性很大。而为了适配更多的场景,行业内各种博客、插件、黑科技介绍也层出不穷。当然,过去我们也开源过 Go 系统测试覆盖率收集利器 - goc,算其中比较完善,比较系统的了。且从用户使用角度来看,goc 也确实解决了行业内很多同学的痛点。

而现在,Go 官方终于开始正式这个问题了。作者Than McIntosh 于今年 3 月份提出了新的覆盖率提案,且看当前实现进度,最快 Go1.20 我们就能体验到这个能力,非常赞。

基于作者的 Proposal,我们先来看看这个提案细节。

新姿势: go build -cover

需要明确的是,本次提案不会改变原来的使用姿势go test -cover,而是新增go build -cover使用入口。从这一变化我们不难看出,新提案主要瞄准的是 "针对程序级的覆盖率收集" ,而旧版的实际是 "仅针对包级别的覆盖率收集" ,二者设计目标有明显的差别。

在新姿势下,使用流程大体是:

$ go build -o myapp.exe -cover ...
$ mkdir /tmp/mycovdata
$ export GOCOVERDIR=/tmp/mycovdata
$ <run test suite, resulting in multiple invocations of myapp.exe>
$ go tool covdata [command]

整体逻辑也比较清晰:

  1. 先编译出一个经过插桩的被测程序
  2. 配置好覆盖率输出的路径,然后执行被测程序。到这一步程序本身就会自动的输出覆盖率结果到上述路径了
  3. 通过 go tool covdata 来处理覆盖率结果

这里的子命令 covdata 是新引入的工具。而之所需要新工具,主要还是在新提案下,输出的覆盖率文件格式与原来的已有较大的差别。

新版覆盖率格式

先来看旧版的覆盖率结果:

  mode: set
cov-example/p/p.go:5.26,8.12 2 1
cov-example/p/p.go:11.2,11.27 1 1
cov-example/p/p.go:8.12,10.3 1 1
cov-example/p/p.go:14.27,20.2 5 1

大家当比较熟悉,其是文本格式,简单易懂。

每一行的基本语义为 "文件:起始行.起始列,结束行.结束列 该基本块中的语句数量 该基本块被执行到的次数"

但缺点也明显,就是 "浪费空间". 比如文件路径 cov-example/p/p.go, 相比后面的 counter 数据,重复了多次,且在通常的 profile 文件,这块占比很大。

新提案在这个方向上做了不少文章,实现细节上稍显复杂,但方向较为清晰。

通过分析旧版的每一行能看出,本质上每一行会记录两类信息,一是定位每个基本块的具体物理位置,二是记录这个基本块的语句数量和被执行的次数。虽然执行的次数会变化,但是其他的信息是不变的,所以全局上其实只要记录一份这样的信息就好,而这就能大大的优化空间,

所以,新版覆盖率它实际会实际输出两份文件,一份就是 meta-data 信息,用于定位这个被测程序所有包、方法等元信息,另一份才是 counters,类似下面:

➜  tmp git:(master) ✗ ls -l
total 1280
-rw-r--r-- 1 jicarl staff 14144 Nov 28 17:02 covcounters.4d1584597702552623f460d5e2fdff27.8120.1669626144328186000
-rw-r--r-- 1 jicarl staff 635326 Nov 28 17:02 covmeta.4d1584597702552623f460d5e2fdff27

这两份文件都是二进制格式,并不能直观的读取。但是借助covdata工具,可以轻松转化为旧版格式,比较优雅。类似:

go tool covdata textfmt -i=tmp -o=covdata.txt

ps: tmp 是覆盖率文件所在目录。

真 • 全量覆盖率

一个标准的 go 程序,基本上由三种类型的代码包组成:

  • 自身代码
  • 第三方包,通过 mod 或者 vendor 机制引用
  • go 标准库

在过去,几乎所有的工具都只关注业务自身代码的插桩,鲜少关注第三方包,更别说 go 官方标准库了。这在大部分场景下是没问题的,但有时有些场景也有例外,比如 SDK 相关的项目。因为这时候 SDK 会作为 Dependency 引入,要想对其插桩就需要额外的开发量。还比如一些 CLI 程序,执行完命令之后,立马就结束了,也是非常不利于覆盖率收集的。

这些问题都是很现实的,且我们在 goc 项目中也收到过真实的用户反馈:

不过,现在好了,新版覆盖率方案也有实际考虑到这些需求,它实际会做到 支持全量插桩+程序退出时主动输出覆盖率结果 的原生方式,非常值得期待。

更多覆盖率使用场景支持: 合并(merge)、删减(subtract)、交集(intersect)

在实际处理覆盖率结果时,有很多实用的场景,在新提案中也有提及,比如支持:

  • 合并多次覆盖率结果 go tool covdata merge -i=<directories> -o=<dir>
  • 删减已经覆盖的部分 go tool covdata subtract -i=dir1,dir2 -o=<dir>
  • 得到两份结果的交集 go tool covdata intersect -i=dir1,dir2 -o=<dir>

在过去,这些场景都需要依赖第三方工具才行,而在新方案中已经无限接近开箱即用了。

不过更复杂的场景,类似远程获得覆盖率结果等(类似 goc 支持的场景),看起来新方案并没有原生支持。这个问题,笔者也在 issue 讨论中提出,看看作者是否后续有解答。

展望与不足

值得注意的是新提案的实现是通过 源码插桩+编译器支持 的方式来混合实现的,与原来go test -cover 纯源码改写的方式有了较大的变化。

另外作者提到的 test "origin" queries 功能还是非常让我兴奋的,因为有了它,若想建立 测试用例到源码的映射 会变得简单很多,甚至更进一步的 精准测试,也变的更有想象空间。不过这个功能不会在 Go1.20 里出现,只能期待以后了。

作者还提到了一些其他的限制和将来可能的改进,比如 Intra-line coverage, Function-level coverage, Taking into account panic paths 等,感兴趣的同学可以自行去 Proposal 文档查看。

聊聊Go代码覆盖率技术与最佳实践

· 阅读需 9 分钟
CarlJi
Coder|Blogger|Engineer|Mentor

"聊点干货"

覆盖率技术基础

截止到 Go1.15.2 以前,关于覆盖率技术底层实现,以下知识点您应该知道:

  • go 语言采用的是插桩源码的形式,而不是待二进制执行时再去设置 breakpoints。这就导致了当前 go 的测试覆盖率收集技术,一定是侵入式的,会修改目标程序源码。曾经有同学会问,插过桩的二进制能不能放到线上,所以建议最好不要。

  • 到底什么是"插桩"?这个问题很关键。大家可以任意找一个 go 文件,试试命令go tool cover -mode=count -var=CoverageVariableName xxxx.go,看看输出的文件是什么?

    • 笔者以这个文件为例https://github.com/qiniu/goc/blob/master/goc.go, 得到以下结果:

      	package main

      import "github.com/qiniu/goc/cmd"

      func main() {CoverageVariableName.Count[0]++;
      cmd.Execute()
      }

      var CoverageVariableName = struct {
      Count [1]uint32
      Pos [3 * 1]uint32
      NumStmt [1]uint16
      } {
      Pos: [3 * 1]uint32{
      21, 23, 0x2000d, // [0]
      },
      NumStmt: [1]uint16{
      1, // 0
      },
      }

      可以看到,执行完之后,源码里多了个CoverageVariableName变量,其有三个比较关键的属性: _ Count uint32 数组,数组中每个元素代表相应基本块(basic block)被执行到的次数 _ Pos 代表的各个基本块在源码文件中的位置,三个为一组。比如这里的21代表该基本块的起始行数,23代表结束行数,0x2000d比较有趣,其前 16 位代表结束列数,后 16 位代表起始列数。通过行和列能唯一确定一个点,而通过起始点和结束点,就能精确表达某基本块在源码文件中的物理范围 * NumStmt 代表相应基本块范围内有多少语句(statement) CoverageVariableName变量会在每个执行逻辑单元设置个计数器,比如CoverageVariableName.Count[0]++, 而这就是所谓插桩了。通过这个计数器能很方便的计算出这块代码是否被执行到,以及执行了多少次。相信大家一定见过表示 go 覆盖率结果的 coverprofile 数据,类似下面: github.com/qiniu/goc/goc.go:21.13,23.2 1 1

      这里的内容就是通过类似上面的变量CoverageVariableName得到。其基本语义为 "文件:起始行.起始列,结束行.结束列 该基本块中的语句数量 该基本块被执行到的次数"

依托于 go 语言官方强大的工具链,大家可以非常方便的做单测覆盖率收集与统计。但是集测/E2E 就不是那么方便了。不过好在我们现在有了https://github.com/qiniu/goc。

集测覆盖率收集利器 - Goc 原理

关于单测这块,深入 go 源码,我们会发现go test -cover命令会自动生成一个_testmain.go 文件。这个文件会 Import 各个插过桩的包,这样就可以直接读取插桩变量,从而计算测试覆盖率。实际上goc也是类似的原理(PS: 关于为何不直接用go test -c -cover 方案,可以参考这里https://mp.weixin.qq.com/s/DzXEXwepaouSuD2dPVloOg)。

不过集测时,被测对象通常是完整产品,涉及到多个 long running 的后端服务。所以 goc 在设计上会自动化会给每个服务注入 HTTP API,同时通过服务注册中心goc server来管理所有被测服务。如此的话,就可以在运行时,通过命令goc profile实时获取整个集群的覆盖率结果,当真非常方便。

整体架构参见:

代码覆盖率的最佳实践

技术需要为企业价值服务,不然就是在耍流氓。可以看到,目前玩覆盖率的,主要有以下几个方向:

  • 度量 - 深度度量,各种包,文件,方法度量,都属于该体系。其背后的价值在于反馈与发现。反馈测试水平如何,发现不足或风险并予以提高。比如常见的作为流水线准入标准,发布门禁等等。度量是基础,但不能止步于数据。覆盖率的终极目标,是提高测试覆盖率,尤其是自动化场景的覆盖率,并一以贯之。所以基于此,业界我们看到,做的比较有价值的落地形态是增量覆盖率的度量。goc diff 结合 Prow 平台也落地了类似的能力,如果您内部也使用 Kubernetes,不妨尝试一下。当然同类型的比较知名的商业化服务,也有 CodeCov/Coveralls 等,不过目前她们多数是局限在单测领域。

  • 精准测试方向 - 这是个很大的方向,其背后的价值逻辑比较清晰,就是建立业务到代码的双向反馈,用于提升测试行为的精准高效。但这里其实含有悖论,懂代码的同学,大概率不需要无脑反馈;不能深入到代码的同学,你给代码级别的反馈,也效果不大。所以这里落地姿势很重要。目前业界没还看到有比较好的实践例子,大部分都是解决特定场景下的问题,有一定的局限。

而相较于落地方向,作为广大研发同学,下面这些最佳实践可能对您更有价值:

  • 高代码覆盖率并不能保证高产品质量,但低代码覆盖率一定说明大部分逻辑没有被自动化测到。后者通常会增加问题遗留到线上的风险,当引起注意。
  • 没有普适的针对所有产品的严格覆盖率标准。实际上这更应该是业务或技术负责人基于自己的领域知识,代码模块的重要程度,修改频率等等因素,自行在团队中确定标准,并推动成为团队共识。
  • 低代码覆盖率并不可怕,能够主动去分析未被覆盖到的部分,并评估风险是否可接受,会更加有意义。实际上笔者认为,只要这一次的提交比上一次要好,都是值得鼓励的。

谷歌有篇博客(参考资料)提到,其经验表明,重视代码覆盖率的团队通常会更加容易培养卓越工程师文化,因为这些团队在设计产品之初就会考虑可测性问题,以便能更轻松的实现测试目标。而这些措施反过来会促使工程师编写更高质量的代码,更注重模块化。

最后,欢迎点击左下角详情按钮,加入七牛云 Goc 交流群,我们一起聊聊 goc,聊聊研发效能那些事。

参考资料

往期推荐

觉得不错,欢迎关注:

我们是如何做go语言系统测试覆盖率收集的

· 阅读需 7 分钟
CarlJi
Coder|Blogger|Engineer|Mentor

工程效能领域,测试覆盖率度量总是绕不开的话题,我们也不例外。在七牛云,我们主要使用 go 语言构建云服务,在考虑系统测试覆盖率时,最早也是通过围绕原生go test -c -cover的能力来构建。这个方案,笔者还曾在 MTSC2018 大会上有过专项分享。其实我们当时已经做了不少自动化工作,能够针对很多类型的代码库,自动插桩服务,自动生成 TestMain()等方法,但随着接入项目越来越多,以及后面使用场景的不断复杂化,我们发现这套还是有其先天局限,会让后面越来越难受:

  • 程序必须关闭才能收集覆盖率。如果将这套系统仅定位在收集覆盖率数据上,这个痛点倒也能忍受。但是如果想进一步做精准测试等方向,就很受局限。
  • 因为不想污染被测代码库,我们采取了自动化的方式,在编译阶段给每个服务生成类似 main_test.go 文件。但这种方式,其最难受的地方在于 flag 的处理,要知道 go test 命令本身会调用flag.Parse 方法,所以这里需要自动化的修改源码,保证被测程序的 flag 定义,要先于 go test 调用 flag.Parse 之前。但是,随着程序自己使用 flag 姿势的复杂化,我们发现越来越难有通用方案来处理这些 flag,有点难受。
  • 受限于go test -c命令的先天缺陷,它会给被测程序注入一些测试专属的 flag,比如-test.coverprofile, -test.timeout 等等。这个是最难受的,因为它会破坏被测程序的启动姿势。我们知道系统测试面对是完整被测集群,如果你需要专门维护一套测试集群来做覆盖率收集时,就会显得非常浪费。好钢就应该用在刀刃上,在七牛云,我们倡导极客文化,追求用工程师思维解决重复问题。而作为业务效率部门,我们自己更应该走在前列。

也是因为以上的种种考量,我们内部一直在优化这一套系统,到今天这一版,我们已从架构和实现原理上完成了颠覆,能够做到无损插桩,运行时分析覆盖率,当属非常优雅。

Goc - A Comprehensive Coverage Testing System for The Go Programming Language

一图胜千言:

使用goc run .的姿势直接运行被测程序,就能在运行时,通过goc profile命令方便的得到覆盖率结果。是不是很神奇?是不是很优雅?

这个系统就是goc, 设计上希望完全兼容 go 命令行工具核心命令(go buld/install/run)。使用体验上,也希望向 go 命令行工具靠拢:

以下是goc 1.0 版本支持的功能:

系统测试覆盖率收集方案

有了 goc,我们再来看如何收集 go 语言系统测试覆盖率。整体比较简单,大体只需要三步:

  • 首先通过goc server命令部署一个服务注册中心,它将会作为枢纽服务跟所有的被测服务通信。

  • 使用goc build --center="<server>" 命令编译被测程序。goc 不会破坏被测程序的启动方式,所以你可以直接将编译出的二进制发布到集成测试环境。

  • 环境部署好之后,就可以做执行任意的系统测试。而在测试期间,可以在任何时间,通过goc profile --center="<server>"拿到当前被测集群的覆盖率结果。 是不是很优雅?

goc 核心原理及未来

goc 在设计上,抛弃老的go test -c -cover模式,而是直接与go tool cover工具交互,避免因go test命令引入的一系列弊端。goc 同样没有选择自己做插桩,也是考虑 go 语言的兼容性,以及性能问题,毕竟go tool cover工具,原生采用结构体来定义 counter 收集器,每个文件都有单独的结构体,性能相对比较可靠。goc 旨在做 go 语言领域综合性的覆盖率工具以及精准测试系统,其还有很长的路要走:

  • 基于 PR 的单测/集测/系统覆盖率增量分析
  • 精准测试方向,有一定的产品化设计体验,方便研发与测试日常使用
  • 拥抱各种 CICD 系统

当前goc 已经开源了,欢迎感兴趣的同学,前往代码仓库查看详情并 Star 支持。当然,我们更欢迎有志之士,能够参与贡献,和我们一起构建这个有意思的系统。

最后,父亲节快乐!

Contact me ?

高效测试框架推荐之Ginkgo

· 阅读需 9 分钟
CarlJi
Coder|Blogger|Engineer|Mentor

自 2015 年开始,七牛工效团队一直使用 Go 语言+Ginkgo的组合来编写自动化测试用例,积累了大约 5000+的数量。在使用和维护过程中,我们觉得 Ginkgo 的很多设计理念和功能非常赞,因此特分享给大家。

本篇不是该框架的入门指导。如果您也编写或维护过大量自动化测试用例,希望能获得一些共鸣.

BDD(Behavior Driven Development)

要说 Ginkgo 最大的特点,笔者认为,那就是对 BDD 风格的支持。比如:

	Describe("delete app api", func() {
It("should delete app permanently", func() {...})
It("should delete app failed if services existed", func() {...})

It's about expressiveness。Ginkgo 定义的 DSL 语法(Describe/Context/It)可以非常方便的帮助大家组织和编排测试用例。在 BDD 模式中,测试用例的标题书写,要非常注意表达,要能清晰的指明用例测试的业务场景。只有这样才能极大的增强用例的可读性,降低使用和维护的心智负担。

可读性这一点,在自动化测试用例设计原则上,非常重要。因为测试用例不同于一般意义上的程序,它在绝大部分场景下,看起来都像是一段段独立的方法,每个方法背后隐藏的业务逻辑也是细小的,不具通识性。这个问题在用例量少的情况下,还不明显。但当用例数量上到一定量级,你会发现,如国能快速理解用例到底是能做什么的,真的非常重要。而这正是 BDD 能补足的地方。

不过还是要强调,Ginkgo 只是提供对 BDD 模式的支持,你的用例最终呈现的效果,还是依赖你自己的书写。

进程级并行,稳定高效

相应的我们知道,BDD 框架,因为其 DSL 的深度嵌套支持,会存在一些共享上下文的资源,如此的话想做线程级的并发会比较困难。而 Ginkgo 巧妙的避开了这个问题,它通过在运行时,运行多个被测服务的进程,来达到真正的并行,稳定性大大提高。其使用姿势也非常简单,ginkgo -p命令就可以。在实践中,我们通常使用 32 核以上的服务器来跑集测,执行效率非常高。

这里有个细节,Ginkgo 虽然并行执行测试用例,但其输出的日志和测试报告格式,仍然是整齐不错乱的,这是如何做到的呢?原来,通过源码会发现,ginkgo CLI 工具在并行跑用例时,其内部会起一个监听随机端口的本地服务器,来做不同进程之间的消息同步,以及日志和报告的聚合工作,是不是很巧妙?

其他的一些 Tips

Ginkgo 框架的功能非常强大,对常见测试场景的都有比较好的支持,即使是一些略显复杂的场景,比如:

  • 在平时的代码中,我们经常会看到需要做异步处理的测试用例。但是这块的逻辑如果处理不好,用例可能会因为死锁或者未设置超时时间而异常卡住,非常的恼人。好在 Ginkgo 专门提供了原生的异步支持,能大大降低此类问题的风险。类似用法:

    It("should post to the channel, eventually", func(done Done) {
    c := make(chan string, 0)
    go DoSomething(c)
    Expect(<-c).To(ContainSubstring("Done!"))
    close(done)
    }, 0.2)
  • 针对分布式系统,我们在验收一些场景时,可能需要等待一段时间,目标结果才生效。而这个时间会因为不同集群负载而有所不同。所以简单的硬编码来 sleep 一个固定时间,很明显不合适。这种场景下若是使用 Ginkgo 对应的 matcher 库GomegaEventually功能就非常的贴切,在大大提升用例稳定性的同时,最大可能的减少无用的等待时间。

  • 笔者一直认为,自动化测试用例不应该仅仅是 QA 手中的工具,而应该尽可能多的作为业务验收服务,输出到 CICD,灰度验证,线上验收等尽可能多的场景,以服务于整个业务线。同样利用 Ginkgo 我们可以很容易做到这一点:

    • CICD: 在定义 suite 时,使用RunSpecWithDefaultReporters方法,可以让测试结果既输出到 stdout,还可以输出一份 Junit 格式的报告。这样就可以通过类似 Jenkins 的工具方便的呈现测试结果,而不用任何其他的额外操作。
    • TaaS(Test as a Service): 通过ginkgo build或者原生的go test -c命令,可以方便的将测试用例,编译成 package.test 的二进制文件。如此的话,我们就可以方便的进行测试服务分发。典型的,如交付给 SRE 同学,辅助其应对线上灰度场景下的测试验收。所以在测试用例的组织上,这里有个小建议,过往我会看到有同学会习惯一个目录就定义一个 suite 文件,这样编译出的二进制文件就非常多,不利于分发。所以建议不要定义太多的 suite,可以一条产品就一个 suite 入口,其他的用例包通过_导入进来。比如:

另外,值得说道的是,Ginkgo 框架在提供强大功能和灵活性的同时,有些地方也需要使用者特别留心:

  • DescribeTable功能是对 TableDriven 模式的友好支持,但它的原理是通过Entry在用例执行之前,通过反射机制来自动生成It方法,所以如果期望类似BeforeEach+It的原生组合来使用BeforeEach+Entry的话,可能在值类型的变量传递上,会不符合预期。其实,相较于DescribeTable+Entry的模式,我个人更倾向于通过方法+多个It的原生组合来写用例,虽然代码量显得有点多,但是用例表达的逻辑主题会更清晰,可读性较高。类似如下:
  • Ginkgo CLI 的 focus 和 skip 命令非常好用,能够灵活的指定想执行或者排除的测试用例。不过要注意的是,focus 和 skip 传入的是正则表达式,而适配这个正则的,是组成用例的所有的 Container 标题的组合(Suite+Describe+Context+It), 这些标题从外到里拼接成的完整字符串,所以使用时当注意。

都有谁在用 Ginkgo?

Ginkgo 的官方文档非常详细,非常利于使用。另外,我们看到著名的容器云项目Kubernetes也是使用 Ginkgo 框架来编写其 e2e 测试用例。

最后,如果您也使用 Go 语言来编写测试用例,不妨尝试下 Ginkgo。

如何保障Go语言基础代码质量

· 阅读需 9 分钟
CarlJi
Coder|Blogger|Engineer|Mentor

为什么要谈这个 topic?

实践中,质量保障体系的建设,主要针对两个目标: 一是不断提高目标业务测试覆盖率,保障面向客户的产品质量;二就是尽可能的提高人效,增强迭代效率。而构建全链路质量卡点就是整个体系建设的核心手段。笔者用下图来描述这整个链路:

可以看到,虽然保障业务迭代的方向性正确排在最前面,但在具体操作上,这一步需要的是强化流程规范和构建企业文化,同时对各负责人技能培训,可以说多数是软技能。而保障基础代码质量环节发力于自动化建设链路之始,是可以通过技术手段来消灭潜在的质量问题,所以构建好的话能极大的降低心智负担,非常值得关注。

我们都知道,代码的好坏会直接影响到业务质量,团队协作,以及后期技术债等。有一个经典的图来描述代码质量的好坏,当能深切表达程序员的内心:

而同时我们相信,绝大部分程序员都有追求卓越的初心,且会尽可能的在自己能力范围内编写高质量的代码。

但是,保障基础代码质量光靠程序员的个人素质一定是不全面,是人就会犯错,可能会疏忽。我们最需要的是一种自动化的机制来持续确保不出问题。这也是自动化的魅力,一次构建,持续收获价值。

此类工具在业界一般叫 linter,不同的语言有不同的实现。本文主要探究 Go 语言相关的。 在介绍相关工具之前,我们先看看几个经典的代码坏味道: 这段代码常规运行不会有问题,但是在一些场景下循环执行,那可能就会有问题了, 我们来看看: (注:ex2 是上述代码编译出的可执行文件名字)

很明显,有句柄泄露。原因也很简单,http response 的 body 没有关闭。但这个关闭语句,一不注意也容易写错:

这时候如果百度挂了,上述程序程序就会因为空指针引用,造成非预期的 panic,非常的不优雅。所以正确的做法应该是在 err 判断之后再行关闭 body(关于 Client.Do 具体的各种限制,大家可以参考这里: https://golang.org/pkg/net/http/#Client.Do)

如此种种,此类小问题在实际编码活动中非常常见,且不容易一眼看出问题。甚至常规的测试可能也难检测出来,可谓非常棘手。好在 Go 语言的开发者们为我们想到了这一点,内置工具链中的 vet 命令,就能方便的检测到很多类似的问题。

还比如下面的代码场景,我在实际的测试用例和业务代码都看到过:

go vet 可以很容易检测出这个问题(其他 vet 功能,可以参考这里: https://golang.org/cmd/vet/)。

go 的工具链中,还有一个不得不提,那就是大名鼎鼎的 go fmt,其了却了其他语言经常陷入的代码风格之争,是 Go 语言生态构建非常巧妙的地方。另外 golint 也是 google 主推的 go 语言代码代码风格工具,虽非强制,但强烈建议新项目适用。

Go linters 业界现状

上面主要说到 Go 工具链的内置工具,还有一些非官方的工具也比较有名,比如 staticcheck, errcheck在 github 上 Star 都较多。此类工具有个专门的的 github 库,收集的比较全,参见 awesone-static-analysis

同时还有些项目旨在聚合此类工具,提供更方便的使用方式,以及一些酷炫的产品化。比如golangci-lint, 其衍生的商业化项目,可以自动针对 github PR 做代码审核,对有问题的地方自动 comments,比较有意思。

如何才能优雅的落地 linter 检查?

linter 工具必须为产品质量服务,不然就是做无用功。实践中,我们应该思考的是如何才能优雅的落地 linter 检查,如何才能建立有效的质量卡点。

推荐针对 PR,做代码检查,保障入库代码质量。基于 PR 做事情是我比较看好的,因为这是调动所有研发力量,天然契合的地方。且进一步讲,这也是测试基础设施更能体现价值的地方。

目前 Github 上有很多这方面的集成系统做的都比较好,能够快速的帮我们落地 PR 测的检查,比如 Travis, Circle CI 等。另外就是著名的 Kubernetes 社区,也自行构建了强大的 Prow 系统,其不光是基于 CICD 系统,还构建了 chat ops 模式,为参与 Kubernetes 的社区的贡献者提供了方便。

细看 Kubernetes 库,会发现,其会针对每个 PR 都做如下静态检查:

Kubernetes 只利用了官方的几款工具, 在检测准确性上比较有保障。有了这些检查点,也能倒逼研发人员关注提交代码的质量,会迫使其在本地或者 IDE 上就配置好检查,确保每次提交的 PR 都能通过检查,不浪费 CI 资源。这也是合格工程师的基本要求。

总结

高质量的代码是业务质量保障的基础。而编写高质量的代码是技术问题,同时也应该是企业文化问题。因为当大家都开始注重技术,注重代码质量时,自然会朝着精益求精的路上行进,视糟糕的代码为仇寇。

我的一位老板跟我说过,要做就做 Number One。而在没达到第一的时候,那就要向业界标杆看齐,比如 Netflix,Google,Facebook 等。当大家都非常注重自己代码质量时,工程师才有时间去关注解决更加系统性的问题,而不用一直在 Low Level 徘徊。笔者深以为然。

Go并发编程实践

· 阅读需 8 分钟
CarlJi
Coder|Blogger|Engineer|Mentor

前言

并发编程一直是Golang区别与其他语言的很大优势,也是实际工作场景中经常遇到的。近日笔者在组内分享了我们常见的并发场景,及代码示例,以期望大家能在遇到相同场景下,能快速的想到解决方案,或者是拿这些方案与自己实现的比较,取长补短。现整理出来与大家共享。

简单并发场景

很多时候,我们只想并发的做一件事情,比如测试某个接口的是否支持并发。那么我们就可以这么做:

func RunScenario1() {
count := 10
var wg sync.WaitGroup

for i := 0; i < count; i++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
doSomething(index)
}(i)
}

wg.Wait()
}

使用goroutine来实现异步,使用WaitGroup来等待所有goroutine结束。这里要注意的是要正确释放WaitGroup的counter(在goroutine里调用Done()方法)。

但此种方式有个弊端,就是当goroutine的量过多时,很容易消耗完客户端的资源,导致程序表现不佳。

规定时间内的持续并发模型

我们仍然以测试某个后端API接口为例,如果我们想知道这个接口在持续高并发情况下是否有句柄泄露,这种情况该如何测试呢?

这种时候,我们需要能控制时间的高并发模型:

func RunScenario2() {
timeout := time.Now().Add(time.Second * time.Duration(10))
n := runtime.NumCPU()

waitForAll := make(chan struct{})
done := make(chan struct{})
concurrentCount := make(chan struct{}, n)

for i := 0; i < n; i++ {
concurrentCount <- struct{}{}
}

go func() {
for time.Now().Before(timeout) {
<-done
concurrentCount <- struct{}{}
}

waitForAll <- struct{}{}
}()

go func() {
for {
<-concurrentCount
go func() {
doSomething(rand.Intn(n))
done <- struct{}{}
}()
}
}()

<-waitForAll
}

上面的代码里,我们通过一个buffered channel来控制并发的数量(concurrentCount),然后另起一个channel来周期性的发起新的任务,而控制的条件就是 time.Now().Before(timeout),这样当超过规定的时间,waitForAll 就会得到信号,而使整个程序退出。

这是一种实现方式,那么还有其他的方式没?我们接着往下看。

基于大数据量的并发模型

前面说的基于时间的并发模型,那如果只知道数据量很大,但是具体结束时间不确定,该怎么办呢?

比如,客户给了个几TB的文件列表,要求把这些文件从存储里删除。再比如,实现个爬虫去爬某些网站的所有内容。

而解决此类问题,最常见的就是使用工作池模式了(Worker Pool)。以删文件为例,我们可以简单这样来处理:

  • Jobs - 可以从文件列表里读取文件,初始化为任务,然后发给worker
  • Worker - 拿到任务开始做事
  • Collector - 收集worker处理后的结果
  • Worker Pool - 控制并发的数量

虽然这只是个简单Worker Pool模型,但已经能满足我们的需求:

func RunScenario3() {
numOfConcurrency := runtime.NumCPU()
taskTool := 10
jobs := make(chan int, taskTool)
results := make(chan int, taskTool)
var wg sync.WaitGroup

// workExample
workExampleFunc := func(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for job := range jobs {
res := job * 2
fmt.Printf("Worker %d do things, produce result %d \n", id, res)
time.Sleep(time.Millisecond * time.Duration(100))
results <- res
}
}

for i := 0; i < numOfConcurrency; i++ {
wg.Add(1)
go workExampleFunc(i, jobs, results, &wg)
}

totalTasks := 100 // 本例就要从文件列表里读取

wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < totalTasks; i++ {
n := <-results
fmt.Printf("Got results %d \n", n)
}
close(results)
}()

for i := 0; i < totalTasks; i++ {
jobs <- i
}
close(jobs)
wg.Wait()
}

在Go里,分发任务,收集结果,我们可以都交给Channel来实现。从实现上更加的简洁。

仔细看会发现,本模型也是适用于按时间来控制并发。只要把totalTask的遍历换成时间控制就好了。

等待异步任务执行结果

goroutine和channel的组合在实际编程时经常会用到,而加上Select更是无往而不利。

func RunScenario4() {
sth := make(chan string)
result := make(chan string)
go func() {
id := rand.Intn(100)
for {
sth <- doSomething(id)
}
}()
go func() {
for {
result <- takeSomthing(<-sth)
}
}()

select {
case c := <-result:
fmt.Printf("Got result %s ", c)
case <-time.After(time.Duration(30 * time.Second)):
fmt.Errorf("指定时间内都没有得到结果")
}
}

在select的case情况,加上time.After()模型可以让我们在一定时间范围内等待异步任务结果,防止程序卡死。

定时反馈异步任务结果

上面我们说到持续的压测某后端API,但并未实时收集结果。而很多时候对于性能测试场景,实时的统计吞吐率,成功率是非常有必要的。

func RunScenario5() {
concurrencyCount := runtime.NumCPU()
for i := 0; i < concurrencyCount; i++ {
go func(index int) {
for {
doUploadMock()
}
}(i)
}

t := time.NewTicker(time.Second)
for {
select {
case <-t.C:
// 计算并打印实时数据
}
}
}

这种场景就需要使用到Ticker,且上面的Example模型还能控制并发数量,也是非常实用的方式。

知识点总结

上面我们共提到了五种并发模式:

  • 简单并发模型
  • 规定时间内的持续并发模型
  • 基于大数据量的持续并发模型
  • 等待异步任务结果模型
  • 定时反馈异步任务结果模型

归纳下来其核心就是使用了Go的几个知识点:Goroutine, Channel, Select, Time, Timer/Ticker, WaitGroup. 若是对这些不清楚,可以自行Google之。

另完整的Example 代码可以参考这里:https://github.com/jichangjun/golearn/blob/master/src/carlji.com/experiments/concurrency/main.go

使用方式: go run main.go <场景>

比如 :

参考文档

这篇是Google官方推荐学习Go并发的资料,从初学者到进阶,内容非常丰富,且权威。