原文: The Top 10 Most Common Mistakes I’ve Seen in Go Projectshtml
做者: Teiva Harsanyigit
译者: Simon Magithub
我在Go开发中遇到的十大常见错误。顺序可有可无。golang
让咱们看一个简单的例子:数据库
type Status uint32
const (
StatusOpen Status = iota
StatusClosed
StatusUnknown
)
复制代码
在这里,咱们使用iota建立了一个枚举,其结果以下:编程
StatusOpen = 0
StatusClosed = 1
StatusUnknown = 2
复制代码
如今,让咱们假设这个Status
类型是JSON请求的一部分,将被marshalled/unmarshalled
。json
咱们设计了如下结构:bash
type Request struct {
ID int `json:"Id"`
Timestamp int `json:"Timestamp"`
Status Status `json:"Status"`
}
复制代码
而后,接收这样的请求:网络
{
"Id": 1234,
"Timestamp": 1563362390,
"Status": 0
}
复制代码
这里没有什么特别的,状态会被unmarshalled
为StatusOpen
。数据结构
然而,让咱们以另外一个未设置状态值的请求为例:
{
"Id": 1235,
"Timestamp": 1563362390
}
复制代码
在这种状况下,请求结构的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 < 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
}
复制代码
如此一来,编译器将不知道clear
是否会产生反作用。
所以,不会将clear
优化成内联函数。
在函数调用中,按值传递的变量将建立该变量的副本,而经过指针传递只会传递该变量的内存地址。
那么,指针传递会比按值传递更快吗?请看一下这个例子。
我在本地环境上模拟了0.3KB
的数据,而后分别测试了按值传递和指针传递的速度。
结果显示:按值传递比指针传递快4倍以上,这很违背直觉。
测试结果与Go中如何管理内存有关。我虽然不能像威廉·肯尼迪那样出色地解释它,但让我试着总结一下。
译者注开始
做者没有说明Go内存的基本存储方式,译者补充一下。
下面是来自Go语言圣经的介绍:
一个goroutine会以一个很小的栈开始其生命周期,通常只须要2KB。
一个goroutine的栈,和操做系统线程同样,会保存其活跃或挂起的函数调用的本地变量,可是和OS线程不太同样的是,一个goroutine的栈大小并非固定的;栈的大小会根据须要动态地伸缩。
而goroutine的栈的最大值有1GB,比传统的固定大小的线程栈要大得多,尽管通常状况下,大多goroutine都不须要这么大的栈。
译者本身的理解:
栈:每一个Goruntine开始的时候都有独立的栈来存储数据。(Goruntine分为主Goruntine和其余Goruntine,差别就在于起始栈的大小)
堆: 而须要被多个Goruntine共享的数据,存储在堆上面。
译者注结束
众所周知,能够在堆或栈上分配变量。
Goroutine
的正在使用的变量(译者注: 可理解为局部变量)。一旦函数返回,变量就会从栈中弹出。让咱们看一个简单的例子,返回单一的值:
func getFooValue() foo {
var result foo
// Do something
return result
}
复制代码
当调用函数时,result
变量会在当前Goruntine栈建立,当函数返回时,会传递给接收者一份值的拷贝。而result
变量自身会从当前Goruntine栈出栈。
虽然它仍然存在于内存中,但它不能再被访问。而且还有可能被其余数据变量所擦除。
如今,在看一个返回指针的例子:
func getFooPointer() *foo {
var result foo
// Do something
return &result
}
复制代码
当调用函数时,result
变量会在当前Goruntine栈建立,当函数返回时,会传递给接收者一个指针(变量地址的副本)。若是result
变量从当前Goruntine栈出栈,则接收者将没法再访问它。(译者注:此状况称为“内存逃逸”)
在这个场景中,Go编译器将把result
变量转义到一个能够共享变量的地方:堆。
不过,传递指针是另外一种状况。例如:
func main() {
p := &foo{}
f(p)
}
复制代码
由于咱们在同一个Goroutine中调用f
,因此p
变量不须要转义。它只是被推送到堆栈,子功能能够访问它。(译者注:不须要其余Goruntine共享的变量就存储在栈上便可)
好比,io.Reader
中的Read
方法签名,接收切片参数,将内容读取到切片中,返回读取的字节数。而不是返回读取后的切片。(译者注:若是返回切片,会将切片转义到堆中。)
type Reader interface {
Read(p []byte) (n int, err error)
}
复制代码
为何栈如此之快? 主要有两个缘由:
总之,当建立一个函数时,咱们的默认行为应该是使用值而不是指针。只有在咱们想要共享变量时才应使用指针。
若是咱们遇到性能问题,可使用go build -gcflags "-m -m"
命令,来显示编译器将变量转义到堆的具体操做。
再次重申,对于大多很多天经常使用例来讲,值传递是最合适的。
若是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
的一种解决方案是使用带标签的break,以下所示:
loop:
for {
select {
case <-ch:
// Do something
case <-ctx.Done():
break loop
}
}
复制代码
Go在错误处理方面仍然有待提升,以致于如今错误处理是Go2中最使人期待的需求。
当前的标准库(在Go 1.13以前)只提供error
的构造函数,天然而然就会缺失其余信息。
让咱们看一下pkg/errors库中错误处理的思想:
An error should be handled only once. Logging an error is handling an error. So an error should either be logged or propagated.
(译:错误应该只处理一次。记录log 错误就是在处理错误。因此,错误应该记录或者传播)
对于当前的标准库,很难作到这一点,由于咱们但愿向错误中添加一些上下文信息,使其具备层次结构。
例如: 所指望的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")
}
复制代码
若是不是由外部库返回的初始error
可使用error.New
建立。中间层insert
对此错误添加更多上下文信息。最终经过log
错误来处理错误。每一个级别要么返回错误,要么处理错误。
咱们可能还想检查错误缘由来判读是否应该重试。假设咱们有一个来自外部库的db
包来处理数据库访问。 该库可能会返回一个名为db.DBError
的临时错误。要肯定是否须要重试,咱们必须检查错误缘由:
使用pkg/errors
中提供的errors.Cause
能够判断错误缘由。
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
。 例如,经过这种方式检查错误:
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
被wrapped
,它将永远不会执行retry
。
Don’t just check errors, handle them gracefully
有时,咱们知道切片的最终长度。假设咱们想把Foo
切片转换成Bar
切片,这意味着这两个切片的长度是同样的。
我常常看到切片如下面的方式初始化:
var bars []Bar
bars := make([]Bar, 0)
复制代码
切片不是一个神奇的数据结构,若是没有更多可用空间,它会进行双倍扩容。在这种状况下,会自动建立一个切片(容量更大),并复制其中的元素。
若是想容纳上千个元素,想象一下,咱们须要扩容多少次。虽然插入的时间复杂度是O(1)
,但它仍会对性能有所影响。
所以,若是咱们知道最终长度,咱们能够:
用预约义的长度初始化它
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
}
复制代码
context.Context
常常被误用。 根据官方文档:
A Context carries a deadline, a cancelation signal, and other values across API boundaries.
这种描述很是笼统,以致于让一些人对使用它感到困惑。
让咱们试着详细描述一下。Context
能够包含:
I/O
请求,等待的channel
输入,等等)。cancelable
上下文来实现,一旦咱们得到第二个请求,这个上下文就会被取消。interface{}
类型。值得一提的是,Context是能够组合的。例如,咱们能够继承一个带有截止日期和键/值列表的Context
。此外,多个goroutines
能够共享相同的Context
,取消一个Context
可能会中止多个活动。
回到咱们的主题,举一个我经历的例子。
一个基于urfave/cli (若是您不知道,这是一个很好的库,能够在Go中建立命令行应用程序)建立的Go应用。一旦开始,程序就会继承父级的Context
。这意味着当应用程序中止时,将使用此Context
发送取消信号。
我经历的是,这个Context
是在调用gRPC
时直接传递的,这不是我想作的。相反,我想当应用程序中止时或无操做100毫秒后,发送取消请求。
为此,能够简单地建立一个组合的Context
。若是parent
是父级的Context
的名称(由urfave/cli建立),那么组合操做以下:
ctx, cancel := context.WithTimeout(parent, 100 * time.Millisecond)
response, err := grpcClient.Send(ctx, request)
复制代码
Context
并不复杂,在我看来,可谓是 Go 的最佳特性之一。
我常常看到的一个错误是在没有-race
参数的状况下测试 Go 应用程序。
正如本报告所述,虽然Go“旨在使并发编程更容易,更不容易出错”,但咱们仍然遇到不少并发问题。
显然,Go 竞争检测器没法解决每个并发问题。可是,它仍有很大价值,咱们应该在测试应用程序时始终启用它。
Does the Go race detector catch all data race bugs?
另外一个常见错误是将文件名传递给函数。
假设咱们实现一个函数来计算文件中的空行数。最初的实现是这样的:
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 Body
实现相同的逻辑,将不得不为此建立另外一个函数。
Go 设计了两个很棒的接口:io.Reader
和 io.Writer
(译者注:常见IO 命令行,文件,网络等)
因此能够传递一个抽象数据源的io.Reader
,而不是传递文件名。
仔细想想统计的只是文件吗?一个HTTP正文?字节缓冲区?
答案并不重要,重要的是不管Reader
读取的是什么类型的数据,咱们都会使用相同的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))
复制代码
不管数据源如何,均可以调用count
。而且,还将促进单元测试,由于能够从字符串建立一个bufio.Reader
,这大大提升了效率。
count, err := count(bufio.NewReader(strings.NewReader("input")))
复制代码
我见过的最后一个常见错误是使用 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
至关于建立了另外一个名为i
的变量实例。
固然,为了便于阅读,最好使用不一样的变量名称。
Using goroutines on loop iterator variables
你还想提到其余常见的错误吗?请随意分享,继续讨论;)