开篇

终于在上个月Go官方发布了Go1.18,除了大家心心念念的泛型新的参数支持,还带来很多新的特性,在这一个月的体验感受下来,新版本对语言还是可以说做了有史以来最大的改变,无论是性能发挥还是特性支持都得到了较高的满意度

特性介绍

泛型

基本现状

在此之前版本很多时候如果想做一些通用数据类型的方法和操作时,基本都需要借助interface实现,有的内部甚至引入各种类型断言或者通过反射reflect机制实现
比如想实现基本快速排序

func quickSort(sequence []int64) {
    // sort logic
}

这个方法的参数只能是int64的切片类型,如果需要实现通用化参数的方法,常见思路就是借助interface

// 通用参数类型排序
func quickSort(sequence []interface{}) {
    switch sequence[0].(type) {
    case int64:
       // int64类型 logic
    case float64:
       // float类型 logic
    // other type
    default:
        panic("type not support!")
   }

另一种常见解决思路就是借助reflect反射来编写泛型函数,但是问题是这样不仅执行速度慢而且需要显示的类型断言,并且没有静态类型检查

泛型玩法

早在去年年底发布了1.18的beta版本里就开始启用泛型支持,很多人已经提前体验了一番。现在已经支持社区大多数用户的泛型需求,算是正式支持generic编程
泛型教程:https://go.dev/doc/tutorial/generics
首先Go支持泛型函数和泛型类型,还是基于上面的case

泛型函数
// 定义标准的泛型函数模板
// 其中[T any]即为参数列表,T为类型参数,any则为参数约束
func quickSort[T any](args T) {
      // sort logic
}
// 实际调用
quickSort[int]([]int{1,2,5,6})

// any 实际定义 V1.18.1
// https://github.com/golang/go/blob/go1.18.1/src/builtin/builtin.go#L95
type any = interface{}

再来实现一个经典的多类型相加

func add[T any](a, b T) T {
   return a + b
}

但是会发现报错提示:Invalid operation: a + b (the operator + is not defined on []T)
这是因为问题出现在any这个参数约束,和C++乃至Java中类似,T受限于数值的运算符操作,以此对一些不支持的类型进行规避

// 修改为想要的数值比较类型
func add[T int64 | float64](a, b T) T {
   return a + b
}

类型别名

type SelfInt int64
func add[T ~int64 | float64](a, b T) T { // ~限制参数底层实现的某种具体类型的别名
   return a + b
}
add[SelfInt](2, 4)

泛型类型
除了对方法的泛型抽象,也可以定义符合数据类型的结构,以常见链表节点为例

type DataType interface { // 接口实现类型约束
   int64 | float32 | string
}
type Element[V DataType] struct {
   key   string
   value V  // 节点类型只能为DataType指定的int64&float32&string
}

Go 1.18将移除用于泛型的类型约束constraints包,主要原因是很多约束类型使用场景太少,基本围绕any和comparable这2个类型约束足够

悲伤故事

尽管已经支持了泛型函数和泛型类型,但是在Go的泛型提案中:no-parameterized-methods也表示并不会支持泛型方法
主要原因Go泛型的处理是在编译的时候实现的,泛型方法在编译的时候,如果没有上下文的分析推断,很难判断泛型方案该如何实例化,甚至判断不了
最终导致目前Go 1.18实现中不支持泛型方案,不过是可以期待下在之后的版本会支持上
详细说明可以看下 https://colobu.com/2021/12/22/no-parameterized-methods/

泛型库应用
  • Lodash 泛型工具库 >> https://github.com/samber/lo

模糊测试

基本介绍

模糊测试是一种自动化测试,通过不断创建输入以检查输出结果是否符合预期。作为常见单测的补充。单测往往是对静态输入的最终预期结果验证,而模糊测试通常也更擅长发现程序安全漏洞问题
发展历程
关于模糊测试的issue问题还在持续迭代 >>

  • 2015年在GopherCon上Google工程师Dmitry Vyukov介绍了相关第三方解决方案: go-fuzz (4.4kstar)已实现了相关功能
  • 2016年Dmitry Vyukov在Go官方issue列表中创建“cmd/compile: coverage instrumentation for fuzzing”的issue来说明三方工具无法实现的问题,并开始推动Fuzzing进入Go原生工具链
    • 而在新版本中基本也是借鉴了实现思路,从而集成到标准工具链的testing pkg
  • 2021年在官方正式进行Fuzzing提案,提议为 Go 添加模糊测试支持, issue >>
  • 2022年3月发布Go1.18将fuzz testing纳入了go test工具链,与单元测试、性能基准测试等一起成为了Go原生测试工具链中的重要成员

基本规则

方法介绍

  • f.Add函数把指定输入作为模糊测试的种子语料库(seed corpus),fuzzing基于种子语料库生成随机输入
  • f.Fuzz函数接收一个fuzz target函数作为入参。fuzz target函数有多个参数,第一个参数是*testing.T,其它参数是被模糊的类型(注意:被模糊的类型目前只支持部分内置类型, 列在 Go Fuzzing docs,未来会支持更多的内置类型)

官方给出的模糊测试基本规则

  • 模糊测试必须是一个名称类似FuzzXxx的函数,仅仅接收一个*testing.F类型的参数,没有返回值
  • 模糊测试必须在*_test.go文件中才能运行
  • Fuzz target(模糊目标)必须是对(testing.F).Fuzz的方法调用,参数是一个函数,并且此函数的第一个参数是testing.T,然后是模糊参数(fuzzing argument),没有返回值
  • 一个模糊测试中必须只有一个模糊目标
  • 所有的种子语料库(seed corpus)必须具有与模糊参数相同的类型,顺序相同。对(*testing.F).Add的调用也是如此, 同样也适用模糊测试中的testdata/fuzz中的语料文件
  • 模糊参数只能是下面的类型
    • string, []byte
    • int, int8, int16, int32/rune, int64
    • uint, uint8/byte, uint16, uint32, uint64
    • float32, float64
    • bool

场景玩法

编写一个简易的字符串反转函数

// 字符串反转
func Reverse(s string) string {
   b := []byte(s)
   for i, j := 0, len(b)-1; i < len(b)/2; i, j = i+1, j-1 {
      b[i], b[j] = b[j], b[i]
   }
   return string(b)
}

单元测试

func TestReverse(t *testing.T) {
   testCases := map[string]string{
      "Hello":     "olleH",
      "ByteDance": "ecnaDetyB",
   }
   for in, out := range testCases {
      rev := Reverse(in)
      if rev != out {
         t.Errorf("rev:%s, expecte:%s", rev, out)
      }
   }
}

模糊测试

func FuzzReverse(f *testing.F) {
   testCases := []string{"Hello", "World", "ByteDance"}
   for _, tc := range testCases {
      f.Add(tc) // 指定输入作为种子语料库
   }
   f.Fuzz(func(t *testing.T, orig string) {
      rev := Reverse(orig)
      doubleRev := Reverse(rev)
      if orig != doubleRev { // check
         t.Errorf("Before: %q, after: %q", orig, doubleRev)
      }
   })
}

使用种子语料库

// 可以理解为mock后的单测
$ go test -run=FuzzReverse
PASS ok

基于种子语料库生成随机测试数据

// 新增-fuzz参数
$ go test -fuzz=FuzzReverse


这次测试失败了,而失败的输入case则入了testdata以方便问题排查
打开后是这样的

go test fuzz v1  // 语料库的编码版本,目前是v1
string("Ғ")      // 引入测试错误的输入数据

代码用例修复
错误的原因则是因为待测试的Reverse函数是基于字节(byte)进行的反转,因此对于本身就占用多字节的字符就会出现无效字符,常见的就比如中文字符
因此思路也很简单按照rune进行字符反转以得到有效的UTF-8编码的字符串
修改后指定错误用例继续测试 ok

但是继续用新的随机测试数据会发现新的问题

原因也是因为非法的unicode数据输入导致编码字节切片后无法还原,限定输入合法最终即可通过

// 修改后测试函数
func Reverse(s string) (string, error) {
   if !utf8.ValidString(s) {
      return s, errors.New("input is not valid UTF-8")
   }
   r := []rune(s)
   for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
      r[i], r[j] = r[j], r[i]
   }
   return string(r), nil
}

定制化参数
上面的默认测试已经可以满足大部分模糊测试场景,不过官方还是提供了其他定制参数

  • fuzztime: 执行的模糊目标在退出的时候要执行的总时间或者迭代次数,默认是用不结束
  • fuzzminimizetime: 模糊目标在每次最少尝试时要执行的时间或者迭代次数,默认是60秒。你可以禁用最小化尝试,只需把这个参数设置为0
  • parallel: 同时执行的模糊化数量,默认是$GOMAXPROCS即调用器Processor数量。当前进行模糊化测试时设置-cpu无效果

架构原理


gofuzz 是一个多进程的fuzzer,其组件可分为协调进程Coordinator、工作进程WorkerRPC

Coordinator

Coordinator的职责是运行和唤醒工作进程、命令工作进行去fuzz下一个输入、如果发生crash则将interesting data 写入语料库等,实现代码 >>
CoordinateFuzzingOpts >>

// 定义了 CoordinateFuzzing 的一系列参数
type CoordinateFuzzingOpts struct {
        Log io.Writer
        Timeout time.Duration // 语料库加载后的挂钟时间
        Limit int64           // 生成的随机值数量
        MinimizeTimeout time.Duration
        MinimizeLimit int64
        Parallel int          // 并行运行的工作进程数,对应执行参数parallel
        Seed []CorpusEntry    // 测试种子列表,对应f.Add()
        Types []reflect.Type  // 语料库条目类型列表
        CorpusDir string      // 写入包含使正在测试的代码崩溃的值的文件
        CacheDir string       // interesting data的目录
}

CoordinateFuzzing >>
CoordinateFuzzing函数基于传入的CoordinateFuzzingOpts一系列参数管理多个worker进程的生命周期,如果遇到测试崩溃则返回崩溃信息以及确保crash后写入语料库
Coordinator >>
定义Coordinator与worker进程的channel,比如如coordinator传递fuzz数据到worker的channel inputC以及worker传递fuzzing结果到coordinator的channel resultC等

Worker

worker的功能主要包括种子变异、最小化、运行fuzz函数、收集覆盖率、返回crash等
工作流程

  1. 执行go -test -fuzz=xx 启动模糊测试
  2. Coordinator唤醒工作进程并分配worker对象
  3. Coordinator从种子语料库和缓存语料库选择输入来进行模糊测试
  4. workerClient作为RPC客户端调用worker进程的方法
  5. RPC基于workerComm提供进程间通信的管道和共享内存
  6. workerServer作为RPC服务端处理请求任务
  7. fuzzIn()读取序列化后的RPC消息
  8. fuzz()则在共享内存中根据随机输入在有限的持续时间或迭代次数内来运行测试函数
  9. 遇到crash则返回

覆盖率&最小化

gofuzz采用覆盖率反馈的方式引导fuzzing,Go 编译器已经对libFuzzer(覆盖引导的模糊测试库)提供了检测支持 ,所以在gofuzz中重用了该部分。 编译器为每个基本块添加一个 8 位计数器用来统计覆盖率
实现思路
当coordinator接收到产生新覆盖范围的输入时,它会将该worker进程的覆盖范围与当前组合的覆盖范围数组进行比较:

  • 如果另一个worker进程已经发现了提供相同覆盖范围的输入,则把该输入丢弃。
  • 如果新的输入确实提供了新的覆盖,则coordinator将其发送回worker进程(可能是不同的worker)以进行最小化处理
  • coordinator收到导致错误的输入时,它会再次将输入发送回worker进程以进行最小化。在这种情况下会尝试找到仍然会导致错误的较小输入。输入最小化后,将其保存到 testdata/corpus/$FuzzTarget最终退出
    最小化实现代码 >>

变异

为了达到模糊测试的效果,对于其支持的基本类型会进行变异操作,如int类型,通过加上或减去一个随机数,并判断其变异后的返回值不能超高int支持的最大范围以此来不断产生新的输入用例提升覆盖
变异实现代码 >>

References

https://go.dev/blog/go1.18
https://go.dev/blog/intro-generics
官方教程:如何开始使用泛型
Go语言单元测试最佳实践手册
https://colobu.com/2021/08/30/how-is-go-generic-implemented/
https://colobu.com/2022/01/03/go-fuzzing/
https://colobu.com/2021/12/22/no-parameterized-methods/
https://rakyll.org/generics-facilititators/
https://tip.golang.org/doc/fuzz/