go snippets

有用的 go 语言代码片段收集。

特别是不常见,或者常见但容易出错的。

尽量

  • 实际运行验证过
  • 标注验证的 go 版本和相关的环境
  • 标注使用要点

接口实现的静态类型检查

go struct 实现 interface 不需要显式的声明,只需要实现其所有方法签名。

这实际上是一种 duck typing(鸭子类型,『当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子。』),而 go 很可能是唯一一个实现鸭子类型的静态语言。

鸭子类型关注行为而不是继承链,给灵活实现提供了便利。不过由于没有显式声明,如果在实现时出了差错,比较难发现(如打错方法签名,或者 go 特有的问题:方法 receiver 弄混了 值类型 和 指针类型)。所以需要加入静态检查,利用好静态语言的优势。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var (
// 静态类型检查
_ Ducker = (*Duck)(nil)
)
// 接口定义
type Ducker interface {
Quack() string
}
// 类型定义
type Duck struct{
// ...
}
// 方法实现
func (d *Duck)Quack() string {
return "Gagaga!"
}

这个类型转换将空值转为 Duck 指针,然后赋值给 Ducker 类型,最后丢弃。这一行最后会被编译器优化掉(因为没有任何实际意义),不会有任何实际开销,但是在静态检查中如果类型不对,还是会报错。

优雅关闭

Graceful Shutdown 是指,在接收到退出信号时,不是马上退出,而是先停止接受新请求,然后把当前请求处理完,再主动退出。

这适合守护进程 daemon 型应用(典型的是服务器),实现一个循环持续接受请求并处理,处理完并不会退出而是等待接下来的请求,直到收到退出信号。

单(主) goroutine 版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// go 1.13
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
loop()
// 如果不是优雅退出,则无论如何执行不到这里
fmt.Println("cleaning up")
}
func loop() {
// 缓冲大小为 1 的信号通道,避免写阻塞
sigch := make(chan os.Signal, 1)
// 注册接收信号
signal.Notify(sigch, syscall.SIGINT, syscall.SIGTERM)
for { // 死循环直到 return
select {
// select 在不阻塞的 case 里随机选一个,都阻塞就走 default
case <-sigch:
// 注意这里如果 break 只能跳出 select
// 如果不用return,就要用 break LABEL 指定跳出的层次
return
default:
// 在收到退出信号前,一直跑这个分支
fmt.Println("do something")
time.Sleep(time.Second)
}
}
}

运行输出:

1
2
3
4
5
do something
do something
...
(Ctrl + C)
cleaning up

独立工作 goroutine 版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
sigch := make(chan os.Signal, 1)
signal.Notify(sigch, syscall.SIGINT, syscall.SIGTERM)
done := make(chan bool, 1)
go func() {
for {
select {
case <-sigch:
done <- true
return
default:
fmt.Println("do something")
time.Sleep(time.Second)
}
}
}()
fmt.Println("goroutine started.")
// 阻塞在这里等子协程,否则主协程结束退出,会连带结束子协程
// 如果有多个子协程,可以用 WaitGroup
<-done
fmt.Println("goroutine stoped. exit.")
}

运行输出:

1
2
3
4
5
6
goroutine started.
do something
do something
...
(Ctrl + C)
goroutine stoped. exit.

测试直接 os.Exit() 的函数的 exit code

单元测试有助于提高软件组件质量,及早发现错误,减少后期发现和修复问题的负担。

go 在 工具链 和 原生库 级别就给单元测试提供了大量支持,只需要引入原生库就可以写成比较完善的测试。在此基础上再引入少量第三方库(如 testify)就可以写出非常优雅的测试。

这降低了写单元测试的负担,提高了大家写单元测试的积极性。如果使用 TDD 进行开发,用运行单元测试来保证每次修改的质量,能够极大地解放开发者的心智负担。

不过最近遇到了一种情况,在做重构时使用单元测试来确保没有破坏原有的功能。结果无论怎么改,测试都是通过。一开始还以为是确实没错。直到我发现了一个 bug,但是测试依然通过,我才反应过来被测函数里有 os.Exit(int) ,测试其实被提前退出了,根本没有测。而这种情况, go test 仍然算 passed

子进程测试

怎么办呢?这个函数确实需要遇到错误提前退出。在参考 stackoverflow 之后,我发现了这种写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// go 1.13
import (
"os"
"os/exec"
"runtime"
"testing"
)
func TestFoo(t *testing.T) {
if os.Getenv("TEST_RUNNER") == "1" {
// 如果是子进程,直接调用要测试的函数
//(只要你选用的环境变量没有跟已有的环境变量撞名字,第一次进来一定不会运行这里)
Foo("some args", 1, false) // Foo() 里面有调用 os.Exit(int)
return
}
// 如果不是子进程,创建一个子进程只执行当前测试,并且通过环境变量 TEST_RUNNER 标记为子进程
// 然后根据子进程的返回值判断测试结果
cmd := exec.Command(os.Args[0], "-test.run=^(TestFoo)$") // 避免函数名部分重复,加上行首和行尾限定
cmd.Env = append(os.Environ(), "TEST_RUNNER=1")
err := cmd.Run()
e, ok := err.(*exec.ExitError)
// 这里假定 exit 为 0 是正常
if ok && e.ExitCode() != 0 {
t.Errorf("exit code got %d, expected 0", e.ExitCode())
}
return
}

子进程测试的工具函数

不过每个测试函数都这样写,太累,还容易出错。能不能写成通用的函数呢?可以。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
func assertExitCode(t *testing.T, f func(), expected int) {
if os.Getenv("TEST_RUNNER") == "1" {
// 待测试函数作为参数传进来然后在子进程执行
f()
return
}
// 获取单元测试函数名
pc := make([]uintptr, 1)
// 读取调用栈的第三帧(跳过两帧,第一帧是 Callers 函数自身)
runtime.Callers(2, pc)
ft := runtime.FuncForPC(pc[0])
nn := strings.Split(ft.Name(), ".")
cmd := exec.Command(os.Args[0], "-test.run=^("+nn[len(nn)-1]+")$")
cmd.Env = append(os.Environ(), "TEST_RUNNER=1")
err := cmd.Run()
e, ok := err.(*exec.ExitError)
// 预期 exit = 0
if expected == 0 {
if ok && e.ExitCode() != 0 {
t.Errorf("exit code got %d, expected %d", e.ExitCode(), 0)
}
return
}
// 预期 exit 非 0 的分两种情况
// 1. 返回的 error 不是 ExitError,一般是 nil 说明 exit 0,或者是别的 error
if !ok {
t.Errorf("expect ExitError with code %d, got err %v", expected, err)
return
}
// 2. 返回的是 ExitError,但是 code 不对
if e.ExitCode() != expected {
t.Errorf("exit code got %d, expected %d", e.ExitCode(), expected)
}
}
func TestFoo(t *testing.T) {
assertExitCode(t, func(){
Foo("some args", 1, false)
}, 0)
}

支持测试多个/次 os.Exit() 的工具函数

不过以上两种写法,都有一个 副作用 :一个单元测试里只能测试一次带有 os.Exit(int) 的函数。调用两次或以上的话,实际上只会测试到第一次,然后就退出了。这就只能每个测试分开测试函数写。

当然,额外增加一些参数,还是可以让子进程之间区分开的,但这样代码和心智的负担都增加了,还不如分开写测试。除非是表格驱动测试,测试用例量很大,那只好再改进一下了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
func assertExitCode(t *testing.T, f func(), flag string, expected int) {
if env := os.Getenv("TEST_RUNNER"); env != "" {
// 只要环境变量非空(说明已经是子进程)就不再创建子进程
// 但只有 flag 一致,才实际执行
if env == flag {
f()
}
return
}
// 获取单元测试函数名
pc := make([]uintptr, 1)
// 读取调用栈的第三帧(跳过两帧,第一帧是 Callers 函数自身)
runtime.Callers(2, pc)
ft := runtime.FuncForPC(pc[0])
nn := strings.Split(ft.Name(), ".")
cmd := exec.Command(os.Args[0], "-test.run=^("+nn[len(nn)-1]+")$")
cmd.Env = append(os.Environ(), "TEST_RUNNER="+flag)
err := cmd.Run()
e, ok := err.(*exec.ExitError)
// 预期 exit = 0
if expected == 0 {
if ok && e.ExitCode() != 0 {
t.Errorf("exit code got %d, expected %d", e.ExitCode(), 0)
}
return
}
// 预期 exit 非 0 的分两种情况
// 1. 返回的 error 不是 ExitError,一般是 nil 说明 exit 0,或者是别的 error
if !ok {
t.Errorf("expect ExitError with code %d, got err %v", expected, err)
return
}
// 2. 返回的是 ExitError,但是 code 不对
if e.ExitCode() != expected {
t.Errorf("exit code got %d, expected %d", e.ExitCode(), expected)
}
}
func TestFooBar(t *testing.T) {
assertExitCode(t, func(){
Foo("some args", 1, false)
}, "foo", 0)
assertExitCode(t, func(){
Bar("some args", 2)
}, "bar", 0)
}

对了,以上三种写法 还有一个副作用 :因为测试不是在当前进程里做的,凡是用这种方法测试的,都不计算覆盖率。


知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。