0. 概述
这篇文章是我在许多 Go 项目中见过的最常见的错误的 top 列表,在后面的列举中,顺序和他们的常见和重要程度等无关。
1. 未知的 ENUM 类型
在开始阐述这条问题之前,先来看个例子:
type Status uint32
const (
StatusOpen Status = iota
StatusClosed
StatusUnknown
)
在这个例子中,我通过 itoa
创建了一个 enum 类型用于表示这些状态:
StatusOpen = 0
StatusClosed = 1
StatusUnknown = 2
现在,假设这些 Status 类型是 JSON 请求中的一部分,然后他们会被序列化和反序列化,例如这么一个结构:
type Request struct {
ID int `json:"Id"`
Timestamp int `json:"Timestamp"`
Status Status `json:"Status"`
}
当我们接收到请求的时候,可能是这样的:
{
"Id": 1234,
"Timestamp": 1563362390,
"Status": 0
}
这里没有特别之处,status 字段将会被反序列化成 StatusOpen,这个没问题吧?然后,让我们来看另外一个 status 值没有设置的请求:
{
"Id": 1235,
"Timestamp": 1563362390
}
在这个例子中,请求结构体中的 Status 字段将会被初始化为值 0,这也就意味着 StatusOpen 替代了 StatusUnknown。所以,这里的一个最佳实践就是将 unknown 设置为 enum 的 0 值:
type Status uint32
const (
StatusUnknown Status = iota
StatusOpen
StatusClosed
)
这样,当 status 不是 JSON 请求的一部分时,它就会被反序列化成我们期望的 StatusKnown。
2. 性能测试
想要正确的性能测试真的很难。因为有太多的因素会影响正确的结果了。这里一个常见的错误就是我们会被一些编译器的优化给愚弄,这里我就举一个 teivah/bitvector
库中的例子:
func clear(n uint64, i, j uint8) uint64 {
return (math.MaxUint64<<j | ((1 << i) - 1)) & n
}
这个函数用于清理给定范围内的 bits,为了测试它的性能,我们可能会这么做:
func BenchmarkWrong(b *testing.B) {
for i := 0; i < b.N; i++ {
clear(1221892080809121, 10, 63)
}
}
在这个性能测试中,编译器会注意到 clear
是一个叶子函数(没有调用其他函数),所以编译器会把它内联了。一旦它被内联,就意味这这个函数是没有副作用的,所以调用clear
会被简化删除,从而导致错误的结果。
一个可选的操作就是将结果赋值给一个全局变量,例如:
var result uint64
func BenchmarkCorrect(b *testing.B) {
var r uint64
for i := 0; i < b.N; i++ {
r = clear(1221892080809121, 10, 63)
}
result = r
}
这样编译器就不知道这里的调用会不会有什么副作用,因此,性能测试也就能正常地处理了。
3. 指针! 到处都是指针!
传递一个变量值将会导致创建这个变量的一个拷贝,同时,传递一个指针也只会拷贝一下它的内存地址。但是,传递一个指针真的能更快么?
如果你觉得是,那么请看一下这个例子,这是一个 0.3 KB 的数据结果的性能测试,我们通过传递和接收指针和值。0.3 KB 其实还远没有平时我们每天使用的数据结构大。当我在我本地执行这个性能测试的时候,传值的速度比传指针快了 4 倍,这是不是有点违反直觉?
要想解释这个,还得了解一下 Go 是怎么管理内存的,我不能像 William Kennedy 哪样解释地那么通常,但是,总结一下是一个变量是在栈中还是堆中分配,取决于:
- 栈是用来保存指定 goroutine 中的变量的,如果一个函数返回了,那么它的变量就会从栈中弹出;
- 堆包含的是共享变量(全局变量等)
让我们看一个返回数值的一个简单例子:
func getFooValue() foo {
var result foo
// Do something
return result
}
这里,我们在当前 goroutine 创建了一个 result
变量,这个变量被压入当前的栈中,一旦这个函数返回了,调用端就会接收到一个这个变量的拷贝。这个变量自身会从栈中被弹出,它会在内存中存在,直到被另外一个变量覆盖,但是,你没法继续访问它。
接着,我们来看另外一个使用指针的例子:
func getFooPointer() *foo {
var result foo
// Do something
return &result
}
这里我们还是在当前 goroutine 中创建了一个 result
变量,但是,调用者接收到的却是一个指针(变量地址的一个拷贝)。当结果变量被从栈中弹出的时候,这个函数的调用者将无法继续访问它了。在这种场景,Go 编译器就会将这个result
变量逃逸到一个可以共享这个变量的地方:堆。
传递指针参数又是另外一种情况,例如:
func main() {
p := &foo{}
f(p)
}
这里因为我们是在同一个 goroutine 中调用 f
,所以变量 p 将不需要逃逸,这里只需要简单地将它压入栈中,子函数就可以访问它了。例如,一个直接的例子就是我们在使用 io.Reader
的 Read
方法的时候,是直接接收一个 slice 而不是返回一个 slice,因为返回一个 slice 将会导致它逃逸到堆中。
那么,为什么栈这么快呢?这里有两个主要原因:
- 栈无需垃圾回收,正如我们前面所说的,一个变量只需要在它创建的时候简单地压入栈中,并且在函数返回时从栈中弹出即可。对于不再使用的变量没有其他复杂操作的额外开销;
- 栈是属于特定的 goroutine 的,所以相比较于堆,存取一个变量无需进行同步控制,这同样导致了性能提升
总结一下,当我们创建一个函数的时候,我们的第一反应应该是使用值来替代指针,但是,指针只是在我们希望共享一个变量时使用。如果我们因为性能的缘故,那么可能的优化就是需要确保在特定的情况下,指针可以帮助到我们。如果你像知道编译器是否会将一个变量分配到堆中,你可以使用这条命令:go build -gcflags "-m -m"
但,还是啰嗦一句,在日常的使用场景中,可能值会是更适合的那个。
4. Breaking for/switch 或者 for/select
在下面这个示例中如果 f
返回 true 将会怎样:
for {
switch f() {
case true:
break
case false:
// Do something
}
}
这里我们想要的是调用 break 语句,但是,这个 break 的却是 switch 语句,而不是 for 循环,具有同样问题的还有:
for {
select {
case <-ch:
// Do something
case <-ctx.Done():
break
}
}
这个 break 是和 select
语句相关联的,而不是 for 循环。一个用于 break for/switch 和 for/select 的可行解决方法是通过使用 labeled break,像这样:
loop:
for {
select {
case <-ch:
// Do something
case <-ctx.Done():
break loop
}
}
5. 错误管理
Go 在处理错误的时候还是有一点年轻,毫无疑问,这可能是 Go 2 中最让人期待的其中一个特性了。在当前的标准库(Go 1.13)中,只提供了一些用于构建 errors 的函数,你可以在 pkg/errors
中可以看到他们。
你可能会在一个 REST 请求的时候,因为一个 DB 错误,而看到这样的错误链条:
unable to serve HTTP POST request for customer 1234
|_ unable to insert customer contract abcd
|_ unable to commit transaction
如果我们使用 pkg/errors
的话,那么代码可能这么写:
func postHandler(customer Customer) Status {
err := insert(customer.Contract)
if err != nil {
log.WithError(err).Errorf("unable to serve HTTP POST request for customer %s", customer.ID)
return Status{ok: false}
}
return Status{ok: true}
}
func insert(contract Contract) error {
err := dbQuery(contract)
if err != nil {
return errors.Wrapf(err, "unable to insert customer contract %s", contract.ID)
}
return nil
}
func dbQuery(contract Contract) error {
// Do something then fail
return errors.New("unable to commit transaction")
}
但是,我们有时可能需要判断一下错误的类型,所以可以这么改进:
func postHandler(customer Customer) Status {
err := insert(customer.Contract)
if err != nil {
switch errors.Cause(err).(type) {
default:
log.WithError(err).Errorf("unable to serve HTTP POST request for customer %s", customer.ID)
return Status{ok: false}
case *db.DBError:
return retry(customer)
}
}
return Status{ok: true}
}
func insert(contract Contract) error {
err := db.dbQuery(contract)
if err != nil {
return errors.Wrapf(err, "unable to insert customer contract %s", contract.ID)
}
return nil
}
这里我们使用了一个 errors.Cause
来用获取真正的错误,一个很常见的错误用法是这样的:
switch err.(type) {
default:
log.WithError(err).Errorf("unable to serve HTTP POST request for customer %s", customer.ID)
return Status{ok: false}
case *db.DBError:
return retry(customer)
}
这样的话,下面的 db.DBError
根本就不会被执行到。
6. slice 初始化
有时,我们知道一个 slice 的最终长度,例如让我们将一个 Foo Slice 转换成另外一个 Bar Slice,那么这就意味着这两个 slice 具有相同的长度,这个时候我们可能会这么做:
var bars []Bar
bars := make([]Bar, 0)
但是,slice 不是一个神奇的数据结构,如果我们理解它内部的结构的话,就知道它会根据元素的个数做动态伸缩,并且在伸缩的过程中会产生拷贝,这是一个性能损失,所以这里的最佳实践是:
- 预先定义 Slice 的长度
func convert(foos []Foo) []Bar {
bars := make([]Bar, len(foos))
for i, foo := range foos {
bars[i] = fooToBar(foo)
}
return bars
- 或者预定 Slice 的容量
func convert(foos []Foo) []Bar {
bars := make([]Bar, 0, len(foos))
for _, foo := range foos {
bars = append(bars, fooToBar(foo))
}
return bars
}
那么这两种方式哪种更好呢?方式一更快,但是方式二更明确,因为我们知道 Slice 的初始长度以及知道 append 是将元素添加到 Slice 的尾部。
7. Context 管理
很多开发者堆 Context 都不甚了解,其实,根据官方的文档解释:
A Context carries a deadline, a cancelation signal, and other values across API boundaries.
这里可以看到 Context 其实就包含 3 个东西:
- 一个 deadline. 这可以是一个时间区间,也可能是一个我们期望到达的具体时间点,到达了这个时间,那么对外的活动都应该被取消;
- 一个取消信号,这里的行为也是类似的,如果我们一旦手的哦呵一个信号,那么我们就必须停下来正在指定的活动;
- 一个 key/value (都是 interface{} 类型)列表
所以,理解了这个之后,使用一个 Context 即可这么简单:
ctx, cancel := context.WithTimeout(parent, 100 * time.Millisecond)
response, err := grpcClient.Send(ctx, request)
8. 没有使用 -race 选项
我在测试一个 Go 应用的时候,经常犯的一个错误就是忘记使用 -race
选项了。在这个 repo 中指出,虽然 Go “被设计成让并发编程更简单更少错误”,但是,它依然可能引发很多并发问题。
虽然 Go 的竞态检测器不能在每个并发问题上都能帮得上忙,但是,这是一个值得我们在测试我们应用的应用的时候一直使用的工具。
9. 使用文件名作为输入
再一个常见的错误就是将一个文件名传递给一个函数作为参数,例如这是一个统计文件中有多少空行的函数:
func count(filename string) (int, error) {
file, err := os.Open(filename)
if err != nil {
return 0, errors.Wrapf(err, "unable to open %s", filename)
}
defer file.Close()
scanner := bufio.NewScanner(file)
count := 0
for scanner.Scan() {
if scanner.Text() == "" {
count++
}
}
return count, nil
}
这里很简单,只需要提供我们的文件名就可以快速的读取文件内容,然后处理逻辑。但是,有没有想过,如果要写测试怎么办?所以,这里有两个很不错的接口 io.Reader
和 io.Writer
可以帮助我们:
func count(reader *bufio.Reader) (int, error) {
count := 0
for {
line, _, err := reader.ReadLine()
if err != nil {
switch err {
default:
return 0, errors.Wrapf(err, "unable to read")
case io.EOF:
return count, nil
}
}
if len(line) == 0 {
count++
}
}
}
这样通用性就强了很多。
10. Goroutine 和循环变量
先来看下这个例子,你可以先猜测一下输出是什么:
ints := []int{1, 2, 3}
for _, i := range ints {
go func() {
fmt.Printf("%v\n", i)
}()
}
你可能会猜测是 123,但是事实上是 333,因为每个 goroutine 都是使用的同一个变量,这里有两种改进方法:
ints := []int{1, 2, 3}
for _, i := range ints {
go func(i int) {
fmt.Printf("%v\n", i)
}(i)
}
或者使用第二种:
for _, i := range ints {
i := i
go func() {
fmt.Printf("%v\n", i)
}()
}
小结
本文翻译有所仓促之处,如果看得不习惯,不妨看下原文中的最后,有另外一份翻译。