欢迎,来自IP地址为:98.81.24.230 的朋友
使用 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”的变量实例。当然,为了便于阅读,我们可能希望使用不同的名称来调用它。