这篇文章是我在Go-projects中遇到的最常见错误的重头戏。 顺序无关紧要。

未知值的枚举
让我们看一个简单的例子:
type Status uint32 const ( StatusOpen Status = iota StatusClosed StatusUnknown )
在这里,我们使用iota创建一个枚举器,它将导致这种状态:
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": 1235, "Timestamp": 1563362390 }
在这种情况下,Request结构的Status字段将被初始化为零(对于uint32,它为0)。 因此,我们再次获得StatusOpen而不是StatusUnknown。
在这种情况下,最好先设置枚举器的未知值-即 0:
type Status uint32 const ( StatusUnknown Status = iota StatusOpen StatusClosed )
如果状态不是JSON请求的一部分,则将按照我们的预期在StatusUnknown中进行初始化。
标杆管理
正确的基准测试非常困难。 太多因素会影响结果。
一个常见的错误被编译器优化所欺骗。 让我们从
teivah / bitvector库中查看一个具体示例:
func clear(n uint64, i, j uint8) uint64 { return (math.MaxUint64<<j | ((1 << i) - 1)) & n }
此功能清除给定范围内的位。 我们可以通过以下方式测试性能:
func BenchmarkWrong(b *testing.B) { for i := 0; i < bN; i++ { clear(1221892080809121, 10, 63) } }
在此测试中,编译器将注意到clear不会调用任何其他函数,因此它只是按原样嵌入它。 内置后,编译器将看到没有副作用发生。 因此,清除呼叫将被简单地删除,这将导致结果不准确。
一种解决方案是将结果设置为全局变量,例如:
var result uint64 func BenchmarkCorrect(b *testing.B) { var r uint64 for i := 0; i < bN; i++ { r = clear(1221892080809121, 10, 63) } result = r }
在这里,编译器将不知道该调用是否会产生副作用。 因此,基准将是准确的。
指针! 指针无处不在!
按值传递变量将创建此变量的副本。 通过指针传递时,只需将地址复制到内存中即可。
因此,传递指针将总是更快,对吗?
如果您这样认为,请看一下
这个例子 。 这是0.3 KB数据结构的基准,我们首先通过指针发送,然后通过值发送和接收。 0.3 KB有点-关于我们每天使用的常规数据结构占用了大约那么多。
当我在本地环境中运行这些测试时,按价值传输的速度要快4倍以上。 太出乎意料了吧?
该结果的解释与对Go中内存管理如何进行的理解有关。 我不能像
威廉·肯尼迪 (
William Kennedy)那样出色
地解释它,但是让我们尝试概括一下。
可以将变量放在堆或堆栈上:
- 堆栈包含该程序的当前变量。 函数返回后,变量将从堆栈中弹出。
- 堆包含公共变量(全局变量等)。
让我们看一个简单的例子,我们返回一个值:
func getFooValue() foo { var result foo
这里的结果变量是由当前goroutine创建的。 该变量被压入当前堆栈。 函数返回后,客户端将立即收到此变量的副本。 变量本身从堆栈中弹出。 它仍然存在于内存中,直到另一个变量被覆盖,但无法再访问它。
现在是相同的示例,但是有一个指针:
func getFooPointer() *foo { var result foo
结果变量仍由当前goroutine创建,但是客户端将收到一个指针(变量地址的副本)。 如果从堆栈弹出结果变量,则此函数的客户端将无法访问它。
在这种情况下,Go编译器会将结果变量输出到可以共享变量的位置,即 一堆。
另一个用于传递指针的脚本:
func main() { p := &foo{} f(p) }
由于我们在同一程序中调用f,因此不需要堆积变量p。 只需将其压入堆栈,子功能就可以访问它。
例如,以这种方式,在io.Reader的Read方法中获得了一个切片。 返回切片(是指针)会将其放入堆中。
为什么堆栈这么快? 有两个原因:
- 无需在堆栈上使用垃圾收集器。 正如我们已经说过的,变量在创建后就被简单地压入,然后在函数返回时从堆栈中弹出。 无需费劲费力地返回未使用的变量等复杂的过程。
- 堆栈属于一个goroutine,因此变量的存储不需要同步,因为它与堆上的存储一起发生,这也导致了性能的提高。
总之,当我们创建一个函数时,我们的默认操作应该是使用值而不是指针。 仅当我们要共享变量时才应使用指针。
另外,如果我们遇到性能问题,可能的优化之一可能是检查指针在特定情况下是否有帮助? 可以使用以下命令找出编译器是否将变量输出到堆:
go build -gcflags "-m -m"
。
但是,再次,对于我们的大多数日常任务,使用价值是最好的。
中止/切换或选择
如果f返回true,在下面的示例中会发生什么?
for { switch f() { case true: break case false:
我们称之为休息。 只有此中断会中断开关,而不会中断for循环。
同样的问题在这里:
for { select { case <-ch:
Break与select语句关联,而不与for循环关联。
中断/切换或/选择的一种可能解决方案是使用标签:
loop: for { select { case <-ch:
错误处理
Go还很年轻,尤其是在错误处理方面。 克服这一缺点是Go 2中最令人期待的创新之一。
当前的标准库(在Go 1.13之前)仅提供用于构造错误的函数。 因此,看一下
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 {
可以使用errors.New创建初始错误(如果外部库未返回)。 中间层,插入,包装此错误,并为其添加更多上下文。 然后父母将其记录下来。 因此,每个级别要么返回要么处理错误。
我们可能还想查找错误原因,例如回叫。 假设我们有一个来自外部库的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 }
这是使用errors.Causes完成的,原因也包含在
pkg / errors中 :
我遇到的常见错误之一是仅部分使用
pkg /错误 。 例如,执行错误检查的步骤如下:
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,它将永远不会再进行第二次调用。
切片初始化
有时我们知道切片的最终长度是多少。 例如,假设我们要将Foo切片转换为Bar切片,这意味着这两个切片将具有相同的长度。
我经常遇到以这种方式初始化的切片:
var bars []Bar bars := make([]Bar, 0)
切片不是神奇的结构。 在幕后,他实施了一项策略,即在没有更多可用空间的情况下增加大小。 在这种情况下,将自动创建一个新的数组(具有更大的容量),并将所有元素复制到该数组中。
现在让我们想象一下,由于[] Foo包含数千个元素,因此需要重复执行几次增大大小的操作。 插入算法的复杂度将保持O(1),但实际上这会影响性能。
因此,如果我们知道最终长度,则可以:
func convert(foos []Foo) []Bar { bars := make([]Bar, len(foos)) for i, foo := range foos { bars[i] = fooToBar(foo) } return bars }
func convert(foos []Foo) []Bar { bars := make([]Bar, 0, len(foos)) for _, foo := range foos { bars = append(bars, fooToBar(foo)) } return bars }
最好的选择是什么? 首先要快一点。 但是,您可能更喜欢后者,因为它更加一致:无论我们是否知道初始大小,都可以使用append在切片的末尾添加元素。
上下文管理
上下文通常被开发人员误解。 根据官方文件:
上下文在API的边界上带有截止日期,取消信号和其他值。
该描述非常笼统,因此可能会使程序员混淆如何正确使用它。
让我们尝试找出答案。 上下文可以携带:
- 期限-表示持续时间(例如250毫秒)或日期时间(例如2019-01-08 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)
上下文并不难理解,我认为这是该语言的最佳功能之一。
不使用-race选项
没有-race选项测试Go应用程序是我经常遇到的错误。
如
本文所述 ,尽管Go的
设计旨在 “
使并行编程更简单,更不易出错 ”,但我们仍然遭受并发问题的困扰。
显然,种族探测器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 }
文件名被设置为输入,所以我们打开它,然后实现我们的逻辑,对吗?
现在,假设我们要用单元测试覆盖此功能。 我们将对常规文件,空文件,具有不同编码类型的文件等进行测试。可能很难管理。
另外,例如,如果要对HTTP主体实现相同的逻辑,则需要为此创建另一个函数。
Go带有两个很棒的抽象:io.Reader和io.Writer。 不用传递文件名,我们只需传递io.Reader,它将抽象数据源。
这是文件吗? 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++ } } }
现在,打开文件的职责已委派给计数客户端:
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")))
Goroutine和循环变量
我遇到的最后一个常见错误是将goroutines与循环变量一起使用时。
以下示例的结论是什么?
ints := []int{1, 2, 3} for _, i := range ints { go func() { fmt.Printf("%v\n", i) }() }
1 2 3随机? 不行
在此示例中,每个goroutine使用变量的相同实例,因此将输出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创建变量i的另一个实例。 当然,出于可读性考虑,我们可以使用其他名称来命名。
如果您知道其他常见错误,请随时在评论中写出。