您当前的位置:首页 > 电脑百科 > 程序开发 > 语言 > Go语言

Go 项目开发中 10 个最常见的错误

时间:2020-01-17 12:37:49  来源:  作者:

以下文章来源于Golang来啦 ,作者Seekload

 

Go 项目开发中 10 个最常见的错误

 

 

原文地址:https://itnext.io/the-top-10-most-common-mistakes-ive-seen-in-go-projects-4b79d4f6cd65

原文作者:Teiva Harsanyi

 

四哥翻译水平有限,如有翻译或理解错误,烦请帮忙指出,感谢!

原文如下:


这篇文章列举了我在 Go 项目中遇见的最常见错误,排序先后不重要。

1.未知的枚举值

来看个简单的例子:

type Status uint32

const (
    StatusOpen Status = iota
    StatusClosed
    StatusUnknown
)

我们使用 iota 创建了枚举值,结果如下:

StatusOpen = 0
StatusClosed = 1
StatusUnknown = 2

现在,我们假设 Status 类型是 JSON 请求的一部分,将会被 marshalled/unmarshalled。设计的结构体如下:

type Request struct {
    ID        int    `json:"Id"`
    Timestamp int    `json:"Timestamp"`
    Status    Status `json:"Status"`
}

接收到请求的结果如下:

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

这没什么特殊的,Status 会被解析成 StatusOpen,没错吧?

好,我们再请求一次,得到的结果没有设置 Status(不管什么原因):

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

这个例子中,Request 结构体的 Status 字段会初始化为零值(对于 uint32 类型来说是 0)。因此,对应的是 StatusOpen 而不是 StatusUnknown。

最好的做法就是将未知的值设置为枚举值 0:

type Status uint32

const (
    StatusUnknown Status = iota
    StatusOpen
    StatusClosed
)

这样,即使 Status 不是请求 JSON 的一部分,就像我们所期望的那样,它也会初始化为 StatusUnknown。

2.基准测试

想要准确无误地进行基准测试很难,因为有太多因素会对结果产生影响。

最常见的错误之一就是代码会被编译器优化,我们来看一个具体的例子,这个例子选自 teivah/bitvector[1] 库:

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

在这个基准测试中,编译器注意到 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
}

这样,编译器不知道调用是否会产生副作用。因此,基准测试结果是正确的。

拓展阅读:High Performance Go Workshop[2]

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

变量传值会创建变量的副本;如果传指针,复制的则是内存地址。

因此,传指针通常会更快,是这样吗?

如果你相信这是真的,请看下这个例子[3]。这是一个基准测试,对一个 0.3 KB 的结构体分别做传值和传指针的对比。0.3 KB 不算大,与我们每天接触的结构体的大小相差不远。

当在我本地环境执行基准测试,传值要比传指针快 4 倍,是不是有点违反直觉?

这个答案的解释与 Go 的内存管理有关。我无法像 William Kennedy 一样出色地解释,但不妨碍我们来总结一下:

一个变量可以分配在堆上或者栈上:

•栈中存储当前 goroutine 正在使用的变量,一旦函数返回,变量便会从栈中弹出;•堆存储共享变量(全局变量等);

我们来看一个返回值的例子:

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

示例中,当前 goroutine 创建了变量 result,被推入当前的栈中。函数返回的时候,函数调用者将会接收到变量的副本。而变量 result 本身被当前 goroutine 弹出栈。但它依然存储在内存里(但再也不能访问),直到被其他变量覆盖。

来看一个传指针的例子:

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

当前 goroutine 创建了变量 result,但函数调用者将接收到一个指针(变量地址的副本),如果变量 result 被弹出栈,函数调用者将再也不能访问它。

在这种情况下,Go 编译器会将变量 result 逃逸到一个变量可以共享的地方:堆。

来看下传指针的另外一种情况:


 

因为我们在同一个 goroutine 里调用 f() 函数,变量 p 不会被逃逸。只是被推入栈中,子函数任然可以访问它。

这也是 io.Reader 接口中 Read 方法接收切片而不是返回切片的直接原因,如果返回一个切片,它将会逃逸到堆中。

为什么栈会如此之快?有两个主要的原因:

栈不需要垃圾回收机制。我们说过,变量创建的时候便会被推入栈中,函数返回时会被弹出栈。不需要一个复杂的过程回收未使用的变量。•栈属于一个 goroutine,与堆相比,将变量存储在栈中不需要同步机制。这也可以提高性能。

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

当我们遭遇性能问题时,在一些特定情况下,可能的优化策略是检查下指针,这获取对我们有所帮助。通过使用下面这条命令,可以知道编译器何时将变量转义到堆:


 

再次强调下,对于大多数日常用例来说,值传递是最合适的。

拓展阅读:Language Mechanics On Stacks And Pointers[4]

4.使用 break 跳出 for/switch 或者 for/select

下面的例子中,如果函数 f() 返回 true 将会发生什么:

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

将会走 break 语句,跳出 switch 语句而不是终止 for 循环。

下面是同样的问题:


 

break 只会跳出 select 语句,而不是终止 for 循环。

上述问题的一种可行方法是使用标记,就像下面这样:

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

5.错误管理

Go 在错误处理方面仍然有待提高,这也是 Go2.0 最令人期待的特性之一。

当前标准库(Go1.13 之前)只提供构建错误的功能,如果查看 pkg/errors[5] 包,会发现对于错误处理的规则,它并不完全遵守:

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

使用当前的标准库,很难遵守以上规则,因为总是希望对错误加一些上下文信息或者让其具有某种层次结构。

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

unable to serve 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 serve 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.Wrapf() 添加一些上下文信息包装次错误;最后,在父级函数中通过 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 serve 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() 函数实现重试。

我见过的使用 pkg/errors 包处理错误的常见错误方式如下:

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

在这个例子中,如果 db.DBError 被包装过,将永远不会触发 retry() 函数。

拓展阅读:Don’t just check errors, handle them gracefully[6]

6.初始化切片

一些情况下,我们知道一个切片的最终长度。例如,假设我们将一个切片 转化成一个切片 Bar,很明显我们知道这两个切片有相同的长度。

我经常看到使用下面这种方式对切片进行初始化:

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

切片并不是一个神奇的结构。在底层,切片实现了自动增长策略,如果当前切片没有足够容量可用时候会自动增长。这种情况下,会自动创建一个新的更大长度[7]的数组,原有的数组元素会拷贝到新数组。

现在,让我们想象下,当 []Foo 有成千上万个元素时,是不是需要多次重复自增长操作?插入操作的时间复杂度仍然是 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
}

到底哪种才是最佳的方式呢?第一种会稍稍快一点。然而,你可以更喜欢第二种,因为它更加有一致性:不管我们是否知道初始大小,都可以使用 append 在切片的末尾添加元素。

7.Context 管理

context.Context 经常被开发者误解,根据官方文档描述:

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

这个描述非常通用,通用的足以让很多人对为什么要使用和如何去使用 context 感到非常疑惑。

让我们详细描述下,一个 context 可以包含:

•一个截止时间。意味着,一个时间段(例如 250 ms)结束之后或者到了某一个时间点(例如 2019-01-08 01:00:00),我们必须取消正在进行的操作(例如:I/O 请求、等待一个协程输入等)。•一个取消信号(基本上都是 <-chan struct{})。在这里,行为与之前的是类似的。一旦我们接收到信号,我们必须停止正在进行的活动。例如,假设我们接收到两个请求:第一个是插入数据,而另一个是取消第一个请求(这两个请求是不相关的)。这可以通过在第一个调用中使用可取消的 context 来实现,一旦我们收到第二个请求的时候就可以调用这个 context 发送信号,进而让第一个请求停止执行。•一个 key/value 对的列表(都是 interface{}类型)。

补充两点:第一,上下文是可组合的。因此,我们可以有一个包含截止时间和 key/value 列表的上下文;第二,多个 goroutine 可以共享同一 context,因此取消信号可能会终止多个活动。

言归正传,下面是我见过的一个具体错误。

一个 Go 应用基于 urfave/cli[8](一个非常好用的可创建命令行引用的第三方包)。一旦引用启动,开发人员将继承一种应用的 context,这意味着当应用停止之后,库将使用次 context 发送取消信号。

我遇到的情况是,在我调用 gRPC 服务的时候,这个 context 被直接传递过去了。这并不是我们想要的。

相反,我们希望指示 gRPC库:请在程序停止或者 100ms 以后取消这个请求。为此,我们可以简单的创建一个组合的 context,如果 parent 是应用 context 的名字,我们可以简单的这样做:

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

context 理解起来并不复杂,而且我认为它是 Go 语言的最佳特性之一。

拓展阅读:1 Understanding the context package in golang[9] 2 gRPC and Deadlines[10]

8.不使用 -race 选项

测试 Go 应用的时候,我经常碰到的一个错误是没有 -race 选项。正如在这份报告[11]里描述的一样,尽管 Go 语言的设计旨在“使并发编程变得更容易且不容易出错”,我们依然会遇到各种并发问题。

显然,Go 的竞争检查(race detector)无法解决每一个并发问题。不过,它依然是有使用价值的工具,在测试 Go 应用的时候,应当开启它。

拓展阅读:Does the Go race detector catch all data race bugs?[12]

9.使用文件名作为输入参数

另一个常见的错误是将一个文件名作为参数传递。

假设我们要编写一个函数,实现计算文件里空行的数目的功能。最常见的做法就是像下面这样:


 

文件名作为参数传递,然后在函数里打开它,接着实现其他的逻辑,应当是这样吗?

现在,假设我们想要基于这个函数实现单元测试,测试普通文件、空文件或者不同编码类型的文件等。这会变得难以管理。

再比如,如果是从一个 HTTP Body 接收内容去实现相同的逻辑(计算空行数目), 那就不得不为此再创建一个函数。

Go 语言里面有两个非常棒的抽象函数:io.Reader 和 io.Writer。我们可以传递一个抽象方法 io.Reader 代替传文件名,这就可以使得函数更具通用性了。

不管输入源是文件、HTTP 包体还是一个字节 Buffer,都不重要,我们都可以使用同一个读取方法实现相同功能。

在我们的例子中,我们甚至可以通过逐行读取的方式将输入缓存起来,因此,我们可以使用 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")))

10.协程和循环变量

最后一个常见的错误是使用循环变量的方式创建 goroutine。

下面的例子输出什么?

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

正如所希望的一样,按顺序输出 1 2 3,不过真的是这样吗?

在这个例子中,每一个 goroutine 共享相同的变量,所以会输出 3 3 3(最有可能)。

这个问题有两个解决办法,第一种是将变量 i 的值传递给闭包(内部的函数):


 

第二种解决办法是在 for-range 循环体内创建一个临时变量:

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

这行代码 i := i 看起来有些奇怪,但完全是有效的。进入循环体里就是进入另一个变量作用域,因此 i:=i 创建了一个新的、名称相同的变量 i。当然为了提高可读性,我们也可以使用其他的变量名。

拓展阅读:Common Mistakes:Using goroutines on loop iterator variables[13]



Tags:Go   点击:()  评论:()
声明:本站部分内容及图片来自互联网,转载是出于传递更多信息之目的,内容观点仅代表作者本人,如有任何标注错误或版权侵犯请与我们联系(Email:2595517585@qq.com),我们将及时更正、删除,谢谢。
▌相关推荐
一. 配置yum源在目录 /etc/yum.repos.d/ 下新建文件 google-chrome.repovim /etc/yum.repos.d/google-chrome.repo按i进入编辑模式写入如下内容:[google-chrome]name=googl...【详细内容】
2021-12-23  Tags: Go  点击:(7)  评论:(0)  加入收藏
昨日谷歌宣布,自2022年12月19日开始停止对OnHub的软件支持,OnHub路由器仍将提供Wi-Fi信号,但用户无法用谷歌Home应用程序管理它。无法更新Wi-Fi网络设置、添加额外的Wifi设备或...【详细内容】
2021-12-22  Tags: Go  点击:(5)  评论:(0)  加入收藏
zip 是一种常见的归档格式,本文讲解 Go 如何操作 zip。首先看看 zip 文件是如何工作的。以一个小文件为例:(类 Unix 系统下)$ cat hello.textHello!执行 zip 命令进行归档:$ zip...【详细内容】
2021-12-17  Tags: Go  点击:(13)  评论:(0)  加入收藏
流水线(Pipeline)是把一个重复的过程分解为若干个子过程,使每个子过程与其他子过程并行进行的技术。本文主要介绍了诞生于云原生时代的流水线框架 Argo。 什么是流水线?在计算机...【详细内容】
2021-11-30  Tags: Go  点击:(21)  评论:(0)  加入收藏
大家好,我是 polarisxu。前段时间,Russ Cox 明确了泛型相关的事情,原计划在标准库中加入泛型相关的包,改放到 golang.org/x/exp 下。目前,Go 泛型的主要设计者 ianlancetaylor 完...【详细内容】
2021-11-30  Tags: Go  点击:(24)  评论:(0)  加入收藏
前言最近因为项目需要写了一段时间的 Go ,相对于 Java 来说语法简单同时又有着一些 Python 之类的语法糖,让人大呼”真香“。 但现阶段相对来说还是 Python 写的多一些,偶尔还...【详细内容】
2021-11-25  Tags: Go  点击:(29)  评论:(0)  加入收藏
前几节课我们学习了Django加载网页数据的相关知识,今天我们讲一下怎么加载静态文件,我们以加载图片为例,学习怎么配置静态文件。 1.思路讲解 首先我们需要新建文件(test2)作为我...【详细内容】
2021-11-23  Tags: Go  点击:(43)  评论:(0)  加入收藏
在本教程中,我们将介绍如何使用 Django 发送电子邮件。我们将介绍如何配置 Django SMTP 连接,如何为您的电子邮件提供商设置应用程序密码,以及如何通过 Django shell 发送电子...【详细内容】
2021-11-10  Tags: Go  点击:(22)  评论:(0)  加入收藏
golang context 很好用,就使用php实现了github地址 : https://github.com/qq1060656096/php-go-context context使用闭坑指南1. 将一个Context参数作为第一个参数传递给传入和...【详细内容】
2021-11-05  Tags: Go  点击:(41)  评论:(0)  加入收藏
谷歌宣布调整服务费费率,从明年起Google Play上所有付费订阅的抽成将从30%降低到15%。此外,电子书和点播音乐流媒体服务还将有资格享受低至10%的费率。此前,Google Play上的开...【详细内容】
2021-10-28  Tags: Go  点击:(38)  评论:(0)  加入收藏
▌简易百科推荐
zip 是一种常见的归档格式,本文讲解 Go 如何操作 zip。首先看看 zip 文件是如何工作的。以一个小文件为例:(类 Unix 系统下)$ cat hello.textHello!执行 zip 命令进行归档:$ zip...【详细内容】
2021-12-17  Go语言中文网    Tags:Go语言   点击:(13)  评论:(0)  加入收藏
大家好,我是 polarisxu。前段时间,Russ Cox 明确了泛型相关的事情,原计划在标准库中加入泛型相关的包,改放到 golang.org/x/exp 下。目前,Go 泛型的主要设计者 ianlancetaylor 完...【详细内容】
2021-11-30  Go语言中文网    Tags:slices 包   点击:(24)  评论:(0)  加入收藏
前言最近因为项目需要写了一段时间的 Go ,相对于 Java 来说语法简单同时又有着一些 Python 之类的语法糖,让人大呼”真香“。 但现阶段相对来说还是 Python 写的多一些,偶尔还...【详细内容】
2021-11-25  crossoverJie    Tags:Go   点击:(29)  评论:(0)  加入收藏
go-micro是基于 Go 语言用于开发的微服务的 RPC 框架,主要功能如下:服务发现,负载均衡 ,消息编码,请求/响应,Async Messaging,可插拔接口,最后这个功能牛p安装步骤安装proto...【详细内容】
2021-09-06    石老师小跟班  Tags:go-micro   点击:(197)  评论:(0)  加入收藏
GoLand 2021.2 EAP 5 现已发布。用户可以从工具箱应用程序中获得 EAP 构建,也可以从官方网站手动下载。并且从此 EAP 开始,只有拥有有效的 JetBrains 帐户才能加入该计划。手...【详细内容】
2021-06-29  IT实战联盟  今日头条  Tags:GoLand   点击:(185)  评论:(0)  加入收藏
作者:HDT3213今天给大家带来的开源项目是 Godis:一个用 Go 语言实现的 Redis 服务器。支持: 5 种数据结构(string、list、hash、set、sortedset) 自动过期(TTL) 发布订阅、地理位...【详细内容】
2021-06-18  HelloGitHub  今日头条  Tags:Go   点击:(125)  评论:(0)  加入收藏
统一规范篇合理规划目录本篇主要描述了公司内部同事都必须遵守的一些开发规矩,如统一开发空间,既使用统一的开发工具来保证代码最后的格式的统一,开发中对文件和代码长度的控制...【详细内容】
2021-05-18  1024课堂    Tags:Go语言   点击:(232)  评论:(0)  加入收藏
闭包概述 闭包不是Go语言独有的概念,在很多编程语言中都有闭包 闭包就是解决局部变量不能被外部访问的一种解决方案 是把函数当作返回值的一种应用 代码演示总体思想:在函数...【详细内容】
2021-05-14  HelloGo  今日头条  Tags:Go语言   点击:(223)  评论:(0)  加入收藏
一时想不开,想了解一下Go语言,于是安装了并体验了一下。下载1. 进入golang.google.cn 点击Download Go 2.选择对应的操作系统,点击后开始下载。 安装1. windows下执行傻瓜式安...【详细内容】
2021-05-12  程序员fearlazy  fearlazy  Tags:Go语言   点击:(236)  评论:(0)  加入收藏
1.简介channel是Go语言的一大特性,基于channel有很多值得探讨的问题,如 channel为什么是并发安全的? 同步通道和异步通道有啥区别? 通道为何会阻塞协程? 使用通道导致阻塞的协程...【详细内容】
2021-05-10  程序员麻辣烫  今日头条  Tags:Go通道   点击:(274)  评论:(0)  加入收藏
最新更新
栏目热门
栏目头条