IP Address:34.239.162.61



使用 Go语言开发程序时,常常犯一些错误,以下是 Go 程序常常出现的错误(排序不分先后)。

未知的枚举值

让我们看一个简单的例子:

type Status uint32

const (
	StatusOpen Status = iota
	StatusClosed
	StatusUnknown
)

在这里,我们使用”iota”创建了一个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,是这样吧?

然而,如果我们使用另一个请求,它没有设置状态值(无论出于何种原因):

{
  "Id": 1234,
  "Timestamp": 1563362390
}

在这种情况下,”Request” 的”Status”字段将被初始化为其零值(对于 uint32 类型来说为0)。 因此,该请求的状态会被解码为”StatusOpen”而不是”StatusUnknown”。

避免出现此类错误的办法就是将未知类型值设置成为枚举的0值:

type Status uint32

const (
	StatusUnknown Status = iota
        StatusOpen
	StatusClosed
)

现在,如果 JSON 请求中不含有”Status”字段,则它将按照我们的预期被初始化为”StatusUnknown”。

基准

正确地进行基准测试很难,因为影响特定结果的因素有很多。

一个常见的错误是被一些编译器优化所欺骗,让我们从”teivah / bitvector”库中获取一个具体的例子:

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

“clear”函数能够清除给定范围内的位,为了达到此目的,我们可能会这样做:

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
}

此时,编译器不会知道函数会不会产生副作用, 从而,基准将是准确的。

指针!到处是指针

按值传递变量将创建此变量的副本,而通过指针传递只会复制其内存地址。

因此,传递指针总会更快。是这样吗?

如果你相信这一点,请看一下这个例子。 这是通过指针然后按值传递和接收的0.3 KB 数据结构的基准测试。0.3 KB 并不是很大,但这与我们每天看到的数据结构类型(大多数人使用的)相差不远。

在本地环境中执行这些基准测试时,我发现传递值比传递指针快 4 倍。 这可能有点违反直觉,对吧?

对此结果的解释与 Go 中如何管理内存有关,虽然我不能完美的解释其原因,但是我将阐述一下我的理解。

变量被分配在堆或堆栈上,作为原始数据:

  • 堆栈保存给定 goroutine 的运行时变量,一旦函数返回,则变量会被从堆栈中弹出
  • 而堆则保存了共享变量(例如全局变量)

让我们看一个简单的例子,产生一个返回值:

func getFooValue() foo {
	var result foo
	// Do something
	return result
}

这里,”result”变量由当前的 goroutine 创建,该变量被推入当前堆栈。函数返回后,调用端将收到此变量的副本,而变量本身将从堆栈中弹出。但它仍然存在于内存中,直到它被另一个变量所覆盖,只是它不能再被访问。

同样的例子,现在返回指针:

func getFooPointer() *foo {
	var result foo
	// Do something
	return &result
}

此时,”result”变量仍由当前goroutine创建,但调用端将接收指针(变量地址的副本)。 如果”result”变量从堆栈中弹出,则调用端将无法再访问它。

于是,Go 编译器会将结果变量转义到可以共享变量的存储位置:堆。

但是,传递指针则是另外一种情况。例如:

func main()  {
	p := &foo{}
	f(p)
}

因为我们在同一个 goroutine 中调用”f”,所以”p”变量并不需要转义。它只是被推送到堆栈,子函数仍然可以访问它。

这也是”io.Reader”接口”Read”方法接收一个切片而不是返回一个切片的直接原因,如果返回一个切片(这是一个指针),会将它转义到堆中。

为什么堆栈比堆速度快?主要有两个原因:

  • 首先是堆栈不需要垃圾回收机制,正如我们所说,变量只是在创建后被推送,然后在函数返回后从堆栈中弹出,无需复杂的过程来回收未使用的变量
  • 堆栈属于一个 goroutine,因此与将其存储在堆上相比,存储变量不需要同步,从而导致性能提升

总之,当我们创建一个函数时,我们的默认行为应该是使用值而不是指针。只有在我们想要共享变量时,才应使用指针。

当然,如果我们遇到程序性能问题,一项优化可能是检查指针在某些特定情况下是否有帮助。通过使用以下命令,可以知道编译器何时将变量转义到堆:

go build -gcflags "-m -m"

还是需要强调的是,对于我们日常的项目,值传递往往是最佳选择。

跳出”for/switch”或者”for/select”循环

下面的程序中,如果”f”返回”true”时会得到什么结果:

for {
  switch f() {
  case true:
    break
  case false:
    // Do something
  }
}

我们本来想调用”break”来中止”for”循环,但是此时的”break”只是中止了”switch”。

同样的问题也会出现在”for/select”循环中:

for {
  select {
  case <-ch:
  // Do something
  case <-ctx.Done():
    break
  }
}

“break”表达式只中止了”select”,并没有结束”for”循环。

解决这个尴尬问题的办法就是使用标记:

loop:
	for {
		select {
		case <-ch:
		// Do something
		case <-ctx.Done():
			break loop
		}
	}

错误处理

Go 在错误处理方便还有待提升,这也是 Go 2.0 最令人期待的功能之一。

当前标准库只提供构建错误的函数,如果查看”pkg/errors”包,会发现对于错误处理的规则,它并不完全遵守:

一个错误只被处理一次,记录一个错误也是一次处理。所以,错误要么被记录要么被收集。

使用当前的标准库,很难遵守以上的规则,因为我们常常希望为错误添加一些内容并让其具有某种层次结构。

让我们举一个例子,希望通过 REST 调用来查看数据库问题:

unable to server 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 server 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")
}

最初,错误由”errors.New”函数创建,并在”insert”函数中通过”errors.Wrap”来向错误中添加内容来包装此错误。最后,在父级函数中通过”log”包来记录错误。每个层级都会返回会处理错误。

我们有时可能还想检查错误本身来实现重试,例如,假设我们有一个来自外部库的”db”包来处理数据库访问。该库可能会返回一个名为”db.DBError”的瞬时(临时)错误。 要确定是否需要重试,我们必须检查错误原因:

func postHandler(customer Customer) Status {
	err := insert(customer.Contract)
	if err != nil {
		switch errors.Cause(err).(type) {
		default:
			log.WithError(err).Errorf("unable to server 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
}

这样,通过使用”pkg/errors”的”errors.Cause”函数来实现”retry”。

使用”pkg/errors”包来检查错误的常见错误方式如下:

switch err.(type) {
default:
  log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID)
  return Status{ok: false}
case *db.DBError:
  return retry(customer)
}

此时,如果”db.DBError”被包装过,那么它将永远不能触发”retry”函数。

初始化切片

有时,我们知道切片的最终长度是多少,例如,假设我们想要将”Foo”切片转换为”Bar”切片,这意味着两个切片具有相同的长度。

经常有人这样初始化切片:

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

切片并不是魔术结构,如果没有更多的存储空间,它自动实施增长。在这种情况下,系统会自动创建一个更大的数组,并将原来的项目复制过去。

可以想像的到,如果”Foo”切片包含数以万计的元素的话,那么将要进行很多次容量扩充,每次扩容的复杂度也不断提升,从而最终影响程序性能。

因此,如果我们知道切片的确切长度,我们可以这样来处理:

  • 使用预定义长度来初始化切片
func convert(foos []Foo) []Bar {
	bars := make([]Bar, len(foos))
	for i, foo := range foos {
		bars[i] = fooToBar(foo)
	}
	return bars
}
  • 或者使用长度为 0 预定义容量来初始化切片
func convert(foos []Foo) []Bar {
	bars := make([]Bar, 0, len(foos))
	for _, foo := range foos {
		bars = append(bars, fooToBar(foo))
	}
	return bars
}

两种方法各有优点,第一种运行速度稍快,但是很多人可能更喜欢第二种方法,因为无论是否知道初始大小,都可以通过”append”函数来完成向切片中添加元素。

上下文管理

context.Context经常会给开发人员带来困扰,根据官方文档的解释:

上下文跨越API边界携带截止日期,取消信号和其他值。

这段描述很抽象,足以让人对于为什么要使用它以及如何使用它产生困惑。

让我们详细解释一下上面的内容:

  • 截止日期:表示一个时间段(例如250毫秒)或是一个具体日期时间(例如2019-01-01 01:00:00),如果达到该截止日期,就必须取消正在进行的行为(如 I / O 请求、等待频道输入等)
  • 取消信息:基本上是”<-chan struct{}”。这里所指的行为都是类似的,一旦收到信息,就立即中止正在进行的行为。例如,我们收到两个请求,第一个为插入一些数据,而第二个请求为取消第一个请求。这时,就可以通过在第一次调用时添加取消上下文来实现,一旦收到第二个请求,则马上取消该请求。
  • 其它值:指基于空接口的键值对

需要强调两点:首先,上下文是可组合的。因此,我们可以有一个带有截止日期的上下文和一个键/值列表。其次,多个 goroutine 可以共享相同的上下文,因此取消信号可能会停止多个协程。

回到正题,这是一个具体错误。

Go 应用程序基于urfave / cli(不知道它也没关系,它是在 Go 中创建命令行应用程序时使用的一个很好的库)。一旦启动,开发人员就会继承一种应用程序上下文。这意味着当应用程序停止时,库将使用此上下文发送取消信号。

很多人经历的是,例如,在调用 gRPC 中止时直接传递了这个上下文,这不是我们想要做的。

相反,我们想要的结果是指示 gRPC 库:请在应用程序停止时或在100毫秒后取消请求。

为此,我们可以简单地创建一个组合上下文。如果 parent 是应用程序上下文的名称(由urfave / cli创建),那么我们可以简单地执行此操作:

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

上下文理解之后,其实并不复杂,并且它是 Go 语言的最佳特性之一。

不使用”-race”选项

在 Go 程序测试时经常遇到的一个错误是就是不使用”-race”选项。

虽然有报告曾经指出,Go 语言旨在让并发编程更加容易,更加不容易出错,但实际开发时经常会遇到问题。

显然,Go race 并不能解决所有并发程序遇到的问题,然而,它仍然是一个非常有用的工具,我们应当在测试 Go 程序时启用它。

使用文件名作为输入

另一个常见错误就是将文件名直接传递给函数。

假设我们想要实现一个函数来计算文件中的空行数,最自然的实现方案想必是:

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
}

“filename”作为函数的输入,我们根据文件名来打开文件并实现相应逻辑,看上去没有任何问题,对吧?

现在,我们想要对这个函数进行单元测试,使用普通文件、空文件以及具有不同编码格式的文件等进行测试时,会发现管理测试非常之困难。

另外,如果我们想要对于 HTTP 正文实现该功能,则不得不重新编写一个具有类似代码的函数。

事实上,Go 语言有两个非常棒的接口:io.Reader和io.Writer。我们可以简单的传递 io.Reader和io.Writer,而不是传递文件名。

无论是文件、HTTP正文还是字节缓冲,这些都不重要,只要它们实现了”Read”方法,都可以调用函数实现功能。

在我们的例子中,我们甚至可以缓冲输入以逐行读取它。因此,我们可以使用”bufio.Reader”及其”ReadLine”方法:

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++
		}
	}
}

打开文件本身的工作则交由”count”函数的调用端来完成:

file, err := os.Open(filename)
if err != nil {
  return errors.Wrapf(err, "unable to open %s", filename)
}
defer file.Close()
count, err := count(bufio.NewReader(file))

使用第二种方式,无论实际数据源为何种形式,都可以调用该函数。同时,它将方便我们进行单元测试,因为我们可以简单地从字符串创建一个”bufio.Reader”:

count, err := count(bufio.NewReader(strings.NewReader("input")))

Goroutines 和循环变量

最后一个常见错误就是不当使用 Goroutines 和循环变量。

下面这段代码输出的结果是什么?

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

是乱序的”1 2 3″吗?当然不是。

示例中的三个 Goroutines 会共享同一个变量实例,从而导致输出为”3 3 3″。

针对此问题,有两种解决方案。第一种是将变量”i”作为参数传递给闭包函数:

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

第二种方法是在 for 循环范围内创建另一个变量:

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

虽然”i:=i”可能看起来有点奇怪,但它完全有效。处于循环体中,则意味着处于另一个范围内。所以可以使用”:=”来创建另一个名为”i”的变量实例。当然,为了便于阅读,我们可能希望使用不同的名称来调用它。

发表评论

电子邮件地址不会被公开。 必填项已用*标注