0. 概述

这篇文章是我在许多 Go 项目中见过的最常见的错误的 top 列表,在后面的列举中,顺序和他们的常见和重要程度等无关。

1. 未知的 ENUM 类型

在开始阐述这条问题之前,先来看个例子:

  1. type Status uint32
  2. const (
  3. StatusOpen Status = iota
  4. StatusClosed
  5. StatusUnknown
  6. )

在这个例子中,我通过 itoa 创建了一个 enum 类型用于表示这些状态:

  1. StatusOpen = 0
  2. StatusClosed = 1
  3. StatusUnknown = 2

现在,假设这些 Status 类型是 JSON 请求中的一部分,然后他们会被序列化和反序列化,例如这么一个结构:

  1. type Request struct {
  2. ID int `json:"Id"`
  3. Timestamp int `json:"Timestamp"`
  4. Status Status `json:"Status"`
  5. }

当我们接收到请求的时候,可能是这样的:

  1. {
  2. "Id": 1234,
  3. "Timestamp": 1563362390,
  4. "Status": 0
  5. }

这里没有特别之处,status 字段将会被反序列化成 StatusOpen,这个没问题吧?然后,让我们来看另外一个 status 值没有设置的请求:

  1. {
  2. "Id": 1235,
  3. "Timestamp": 1563362390
  4. }

在这个例子中,请求结构体中的 Status 字段将会被初始化为值 0,这也就意味着 StatusOpen 替代了 StatusUnknown。所以,这里的一个最佳实践就是将 unknown 设置为 enum 的 0 值:

  1. type Status uint32
  2. const (
  3. StatusUnknown Status = iota
  4. StatusOpen
  5. StatusClosed
  6. )

这样,当 status 不是 JSON 请求的一部分时,它就会被反序列化成我们期望的 StatusKnown。

2. 性能测试

想要正确的性能测试真的很难。因为有太多的因素会影响正确的结果了。这里一个常见的错误就是我们会被一些编译器的优化给愚弄,这里我就举一个 teivah/bitvector 库中的例子:

  1. func clear(n uint64, i, j uint8) uint64 {
  2. return (math.MaxUint64<<j | ((1 << i) - 1)) & n
  3. }

这个函数用于清理给定范围内的 bits,为了测试它的性能,我们可能会这么做:

  1. func BenchmarkWrong(b *testing.B) {
  2. for i := 0; i < b.N; i++ {
  3. clear(1221892080809121, 10, 63)
  4. }
  5. }

在这个性能测试中,编译器会注意到 clear 是一个叶子函数(没有调用其他函数),所以编译器会把它内联了。一旦它被内联,就意味这这个函数是没有副作用的,所以调用clear 会被简化删除,从而导致错误的结果。

一个可选的操作就是将结果赋值给一个全局变量,例如:

  1. var result uint64
  2. func BenchmarkCorrect(b *testing.B) {
  3. var r uint64
  4. for i := 0; i < b.N; i++ {
  5. r = clear(1221892080809121, 10, 63)
  6. }
  7. result = r
  8. }

这样编译器就不知道这里的调用会不会有什么副作用,因此,性能测试也就能正常地处理了。

3. 指针! 到处都是指针!

传递一个变量值将会导致创建这个变量的一个拷贝,同时,传递一个指针也只会拷贝一下它的内存地址。但是,传递一个指针真的能更快么?

如果你觉得是,那么请看一下这个例子,这是一个 0.3 KB 的数据结果的性能测试,我们通过传递和接收指针和值。0.3 KB 其实还远没有平时我们每天使用的数据结构大。当我在我本地执行这个性能测试的时候,传值的速度比传指针快了 4 倍,这是不是有点违反直觉?

要想解释这个,还得了解一下 Go 是怎么管理内存的,我不能像 William Kennedy 哪样解释地那么通常,但是,总结一下是一个变量是在栈中还是堆中分配,取决于:

让我们看一个返回数值的一个简单例子:

  1. func getFooValue() foo {
  2. var result foo
  3. // Do something
  4. return result
  5. }

这里,我们在当前 goroutine 创建了一个 result 变量,这个变量被压入当前的栈中,一旦这个函数返回了,调用端就会接收到一个这个变量的拷贝。这个变量自身会从栈中被弹出,它会在内存中存在,直到被另外一个变量覆盖,但是,你没法继续访问它。

接着,我们来看另外一个使用指针的例子:

  1. func getFooPointer() *foo {
  2. var result foo
  3. // Do something
  4. return &result
  5. }

这里我们还是在当前 goroutine 中创建了一个 result 变量,但是,调用者接收到的却是一个指针(变量地址的一个拷贝)。当结果变量被从栈中弹出的时候,这个函数的调用者将无法继续访问它了。在这种场景,Go 编译器就会将这个result 变量逃逸到一个可以共享这个变量的地方:堆。

传递指针参数又是另外一种情况,例如:

  1. func main() {
  2. p := &foo{}
  3. f(p)
  4. }

这里因为我们是在同一个 goroutine 中调用 f,所以变量 p 将不需要逃逸,这里只需要简单地将它压入栈中,子函数就可以访问它了。例如,一个直接的例子就是我们在使用 io.ReaderRead 方法的时候,是直接接收一个 slice 而不是返回一个 slice,因为返回一个 slice 将会导致它逃逸到堆中。

那么,为什么栈这么快呢?这里有两个主要原因:

总结一下,当我们创建一个函数的时候,我们的第一反应应该是使用值来替代指针,但是,指针只是在我们希望共享一个变量时使用。如果我们因为性能的缘故,那么可能的优化就是需要确保在特定的情况下,指针可以帮助到我们。如果你像知道编译器是否会将一个变量分配到堆中,你可以使用这条命令:go build -gcflags "-m -m"

但,还是啰嗦一句,在日常的使用场景中,可能值会是更适合的那个。

4. Breaking for/switch 或者 for/select

在下面这个示例中如果 f 返回 true 将会怎样:

  1. for {
  2. switch f() {
  3. case true:
  4. break
  5. case false:
  6. // Do something
  7. }
  8. }

这里我们想要的是调用 break 语句,但是,这个 break 的却是 switch 语句,而不是 for 循环,具有同样问题的还有:

  1. for {
  2. select {
  3. case <-ch:
  4. // Do something
  5. case <-ctx.Done():
  6. break
  7. }
  8. }

这个 break 是和 select 语句相关联的,而不是 for 循环。一个用于 break for/switch 和 for/select 的可行解决方法是通过使用 labeled break,像这样:

  1. loop:
  2. for {
  3. select {
  4. case <-ch:
  5. // Do something
  6. case <-ctx.Done():
  7. break loop
  8. }
  9. }

5. 错误管理

Go 在处理错误的时候还是有一点年轻,毫无疑问,这可能是 Go 2 中最让人期待的其中一个特性了。在当前的标准库(Go 1.13)中,只提供了一些用于构建 errors 的函数,你可以在 pkg/errors 中可以看到他们。

你可能会在一个 REST 请求的时候,因为一个 DB 错误,而看到这样的错误链条:

  1. unable to serve HTTP POST request for customer 1234
  2. |_ unable to insert customer contract abcd
  3. |_ unable to commit transaction

如果我们使用 pkg/errors 的话,那么代码可能这么写:

  1. func postHandler(customer Customer) Status {
  2. err := insert(customer.Contract)
  3. if err != nil {
  4. log.WithError(err).Errorf("unable to serve HTTP POST request for customer %s", customer.ID)
  5. return Status{ok: false}
  6. }
  7. return Status{ok: true}
  8. }
  9. func insert(contract Contract) error {
  10. err := dbQuery(contract)
  11. if err != nil {
  12. return errors.Wrapf(err, "unable to insert customer contract %s", contract.ID)
  13. }
  14. return nil
  15. }
  16. func dbQuery(contract Contract) error {
  17. // Do something then fail
  18. return errors.New("unable to commit transaction")
  19. }

但是,我们有时可能需要判断一下错误的类型,所以可以这么改进:

  1. func postHandler(customer Customer) Status {
  2. err := insert(customer.Contract)
  3. if err != nil {
  4. switch errors.Cause(err).(type) {
  5. default:
  6. log.WithError(err).Errorf("unable to serve HTTP POST request for customer %s", customer.ID)
  7. return Status{ok: false}
  8. case *db.DBError:
  9. return retry(customer)
  10. }
  11. }
  12. return Status{ok: true}
  13. }
  14. func insert(contract Contract) error {
  15. err := db.dbQuery(contract)
  16. if err != nil {
  17. return errors.Wrapf(err, "unable to insert customer contract %s", contract.ID)
  18. }
  19. return nil
  20. }

这里我们使用了一个 errors.Cause 来用获取真正的错误,一个很常见的错误用法是这样的:

  1. switch err.(type) {
  2. default:
  3. log.WithError(err).Errorf("unable to serve HTTP POST request for customer %s", customer.ID)
  4. return Status{ok: false}
  5. case *db.DBError:
  6. return retry(customer)
  7. }

这样的话,下面的 db.DBError 根本就不会被执行到。

6. slice 初始化

有时,我们知道一个 slice 的最终长度,例如让我们将一个 Foo Slice 转换成另外一个 Bar Slice,那么这就意味着这两个 slice 具有相同的长度,这个时候我们可能会这么做:

  1. var bars []Bar
  2. bars := make([]Bar, 0)

但是,slice 不是一个神奇的数据结构,如果我们理解它内部的结构的话,就知道它会根据元素的个数做动态伸缩,并且在伸缩的过程中会产生拷贝,这是一个性能损失,所以这里的最佳实践是:

  1. func convert(foos []Foo) []Bar {
  2. bars := make([]Bar, len(foos))
  3. for i, foo := range foos {
  4. bars[i] = fooToBar(foo)
  5. }
  6. return bars
  1. func convert(foos []Foo) []Bar {
  2. bars := make([]Bar, 0, len(foos))
  3. for _, foo := range foos {
  4. bars = append(bars, fooToBar(foo))
  5. }
  6. return bars
  7. }

那么这两种方式哪种更好呢?方式一更快,但是方式二更明确,因为我们知道 Slice 的初始长度以及知道 append 是将元素添加到 Slice 的尾部。

7. Context 管理

很多开发者堆 Context 都不甚了解,其实,根据官方的文档解释:

A Context carries a deadline, a cancelation signal, and other values across API boundaries.

这里可以看到 Context 其实就包含 3 个东西:

所以,理解了这个之后,使用一个 Context 即可这么简单:

  1. ctx, cancel := context.WithTimeout(parent, 100 * time.Millisecond)
  2. response, err := grpcClient.Send(ctx, request)

8. 没有使用 -race 选项

我在测试一个 Go 应用的时候,经常犯的一个错误就是忘记使用 -race 选项了。在这个 repo 中指出,虽然 Go “被设计成让并发编程更简单更少错误”,但是,它依然可能引发很多并发问题。

虽然 Go 的竞态检测器不能在每个并发问题上都能帮得上忙,但是,这是一个值得我们在测试我们应用的应用的时候一直使用的工具。

9. 使用文件名作为输入

再一个常见的错误就是将一个文件名传递给一个函数作为参数,例如这是一个统计文件中有多少空行的函数:

  1. func count(filename string) (int, error) {
  2. file, err := os.Open(filename)
  3. if err != nil {
  4. return 0, errors.Wrapf(err, "unable to open %s", filename)
  5. }
  6. defer file.Close()
  7. scanner := bufio.NewScanner(file)
  8. count := 0
  9. for scanner.Scan() {
  10. if scanner.Text() == "" {
  11. count++
  12. }
  13. }
  14. return count, nil
  15. }

这里很简单,只需要提供我们的文件名就可以快速的读取文件内容,然后处理逻辑。但是,有没有想过,如果要写测试怎么办?所以,这里有两个很不错的接口 io.Readerio.Writer 可以帮助我们:

  1. func count(reader *bufio.Reader) (int, error) {
  2. count := 0
  3. for {
  4. line, _, err := reader.ReadLine()
  5. if err != nil {
  6. switch err {
  7. default:
  8. return 0, errors.Wrapf(err, "unable to read")
  9. case io.EOF:
  10. return count, nil
  11. }
  12. }
  13. if len(line) == 0 {
  14. count++
  15. }
  16. }
  17. }

这样通用性就强了很多。

10. Goroutine 和循环变量

先来看下这个例子,你可以先猜测一下输出是什么:

  1. ints := []int{1, 2, 3}
  2. for _, i := range ints {
  3. go func() {
  4. fmt.Printf("%v\n", i)
  5. }()
  6. }

你可能会猜测是 123,但是事实上是 333,因为每个 goroutine 都是使用的同一个变量,这里有两种改进方法:

  1. ints := []int{1, 2, 3}
  2. for _, i := range ints {
  3. go func(i int) {
  4. fmt.Printf("%v\n", i)
  5. }(i)
  6. }

或者使用第二种:

  1. for _, i := range ints {
  2. i := i
  3. go func() {
  4. fmt.Printf("%v\n", i)
  5. }()
  6. }

小结

本文翻译有所仓促之处,如果看得不习惯,不妨看下原文中的最后,有另外一份翻译。

原文