Go开发中的十大常见陷阱[译]

原文: The Top 10 Most Common Mistakes I’ve Seen in Go Projects

作者: Teiva Harsanyi

译者: Simon Ma

我在Go开发中遇到的十大常见错误。顺序无关紧要。

未知的枚举值

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

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
}

这里没有什么特别的,状态会被unmarshalledStatusOpen

然而,让我们以另一个未设置状态值的请求为例:

{
  "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优化成内联函数。

延伸阅读

High Performance Go Workshop

被转移的指针

在函数调用中,按值传递的变量将创建该变量的副本,而通过指针传递只会传递该变量的内存地址。

那么,指针传递会比按值传递更快吗?请看一下这个例子

我在本地环境上模拟了0.3KB的数据,然后分别测试了按值传递和指针传递的速度。

结果显示:按值传递比指针传递快4倍以上,这很违背直觉。

测试结果与Go中如何管理内存有关。我虽然不能像威廉·肯尼迪那样出色地解释它,但让我试着总结一下。

译者注开始

作者没有说明Go内存的基本存储方式,译者补充一下。

  1. 下面是来自Go语言圣经的介绍:

    一个goroutine会以一个很小的栈开始其生命周期,一般只需要2KB。

    一个goroutine的栈,和操作系统线程一样,会保存其活跃或挂起的函数调用的本地变量,但是和OS线程不太一样的是,一个goroutine的栈大小并不是固定的;栈的大小会根据需要动态地伸缩。

    而goroutine的栈的最大值有1GB,比传统的固定大小的线程栈要大得多,尽管一般情况下,大多goroutine都不需要这么大的栈。

  2. 译者自己的理解:
    • 栈:每个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)
}

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

  1. 堆栈不需要垃圾收集器。就像我们说的,变量一旦创建就会被入栈,一旦函数返回就会从出栈。不需要一个复杂的进程来回收未使用的变量。
  2. 储存变量不需要考虑同步。堆属于一个Goroutine,因此与在堆上存储变量相比,存储变量不需要同步。

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

如果我们遇到性能问题,可以使用go build -gcflags "-m -m"命令,来显示编译器将变量转义到堆的具体操作。

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

延伸阅读

  1. Language Mechanics On Stacks And Pointers
  2. Understanding Allocations: the Stack and the Heap - GopherCon SG 2019

出乎意料的break

如果f返回true,下面的例子中会发生什么?

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

我们将调用break语句。然而,将会breakswitch语句,而不是for循环。

同样的问题:

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

breakselect语句有关,与for循环无关。

breakfor/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.DBErrorwrapped,它将永远不会执行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.Context 经常被误用。 根据官方文档:

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

这种描述非常笼统,以至于让一些人对使用它感到困惑。

让我们试着详细描述一下。Context可以包含:

  • A deadline(最后期限)。它意味着到期之后(250ms之后或者一个指定的日期),我们必须停止正在进行的操作(I/O请求,等待的channel输入,等等)。
  • A cancelation signal(取消信号)。一旦我们收到信号,我们必须停止正在进行的活动。例如,假设我们收到两个请求:一个用来插入一些数据,另一个用来取消第一个请求。这可以通过在第一个调用中使用cancelable上下文来实现,一旦我们获得第二个请求,这个上下文就会被取消。
  • A list of key/value (键/值列表)均基于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 的最佳特性之一。

延伸阅读

  1. Understanding the context package in golang
  2. gRPC and Deadlines

被遗忘的-race参数

我经常看到的一个错误是在没有-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.Readerio.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")))

Goruntines与循环变量

我见过的最后一个常见错误是使用 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

你还想提到其他常见的错误吗?请随意分享,继续讨论;)

原文地址:https://www.cnblogs.com/jinma/p/11369326.html

时间: 2024-11-13 07:15:15

Go开发中的十大常见陷阱[译]的相关文章

Android 应用中十大常见 UX 错误

[核心提示] Android 开发者关系团队每天都会试用无数的 App 或者受到无数的开发者发来的请求评测的 App,在评测如此之多的应用之后,他们总结出了10个最常见的错误. 作为一个长期使用 Android 的用户,我在使用 Android 应用的时候经常遇到各种各样的交互上的问题,并且早就想整理它们写一篇文章了.但是由于懒惰和拖延,这篇文章一直处于草稿的状态.正巧,这期 ADiA 中,Android 开发团队为我们着重强调了当下 Android 应用中很常见的,应该避免的错误. Andro

Linux开发环境必备十大开发工具

原文链接Linux是一个优秀的开发环境,但是如果没有好的开发工具作为武器,这个环境给你带来的好处就会大打折扣.幸运的是,有很多好用的Linux和开源开发工具供你选择,如果你是一个新手,你可能不知道有哪些工具可用.本文将介绍其中十个杰出的开源开发工具,它们将帮助你提升自己的开发效率. 1.Bluefish Bluefish是进行Web开发时最受欢迎的IDE之一.它能够处理编程和标记语言,但是该工具的重点用途在于创建动态和交互式网站.和许多 Linux应用程序一样,Bluefish是一个轻量级工具,

敏捷开发中的10大错误认识

敏捷开发中的10大错误认识 原文:http://www.computerweekly.com/opinion/The-top-10-myths-about-agile-development 作者:Peter Measey 译者:张某人ER  http://blog.csdn.net/xinxing__8185/article/ 摘要:对于快速发展的敏捷软件开发领域,本文将对其最常见的错误认识进行分析. 在如今全球市场的背景下,如何可以灵活变通,对于一个企业来讲,已然变得至关重要,因此,IT系统

机器学习与数据挖掘中的十大经典算法

背景: top10算法的前期背景是吴教授在香港做了一个关于数据挖掘top10挑战的一个报告,会后有一名内地的教授提出了一个类似的想法.吴教授觉得非常好,开始着手解决这个事情.找了一系列的大牛(都是数据挖掘的大牛),都觉得想法很好,但是都不愿自己干.原因估计有一下几种:1.确实很忙2.得罪人3.一系列工作很繁琐等等.最后和明尼苏达大学的Vipin Kumar教授一起把这件事情承担下来.先是请数据挖掘领域获过kdd和icdm大奖的十四个牛人提名候选,其中一人因为确实很忙,正从ibm转行到微软,吴教授

数学建模学习笔记(建模中的十大常用算法总结)

数学建模中的十大常用算法 1.    蒙特卡洛方法: 又称计算机随机性模拟方法,也称统计实验方法.可以通过模拟来检验自己模型的正确性. 2.    数据拟合.参数估计.插值等数据处理 比赛中常遇到大量的数据需要处理,而处理的数据的关键就在于这些方法,通常使用matlab辅助,与图形结合时还可处理很多有关拟合的问题. 3.    规划类问题算法: 包括线性规划.整数规划.多元规划.二次规划等:竞赛中又很多问题都和规划有关,可以说不少的模型都可以归结为一组不等式作为约束条件,几个函数表达式作为目标函

面试十大常见Java String问题

本文介绍Java中关于String最常见的10个问题: 1. 字符串比较,使用 "==" 还是 equals() ?简单来说, "==" 判断两个引用的是不是同一个内存地址(同一个物理对象).而 equals 判断两个字符串的值是否相等.除非你想判断两个string引用是否同一个对象,否则应该总是使用 equals()方法.如果你了解 字符串的驻留 ( String Interning ) 则会更好地理解这个问题 2. 对于敏感信息,为何使用char[]要比Stri

Android开发不可或缺的十大网站及工具

1. Google 做开发前完全是小白,真心不知道有Google这东西,只晓得百度,遇到问题直接百度,不是黑百度,百度在娱乐八卦方面确实靠谱,但是技术方面查出来的东西基本千篇一律,有些答案甚至还会起到误导作用,直到有一天我的老大告诉我用Google,我才知道这个世界上原来还有另外一个搜索引擎,那个时候Google还没有被墙,从此算是迈过了一道坎...自此便成为脑残G粉. 海量技术文章:http://tieba.yunxunmi.com/ 海量技术文章:http://tieba.yunxunmi.

程序猿开发生活的十大禁忌

程序员在编程的时候难免会犯错误,但如果不从错误中吸取教训,那么习惯成自然,你会经常犯错的.从错误中不断的学习,锻炼好的行为习惯有助于事业上的稳定. 程序员在编程的时候难免会犯错误,但如果不从错误中吸取教训,那么习惯成自然,你会经常犯错的.从错误中不断的学习,锻炼好的行为习惯有助于事业上的稳定.这就是我们如何将小麦从糟糠中区别出来以及如何避免编程禁忌的绝佳经验.此外,最重要的就是可以为客户带来更好的用户体验. 1. 不提升非技术技能 我们认为非技术技能是项目成功的主要因素.这些非技术技能也可以称之

1000多个项目中的十大JavaScript错误以及如何避免

通过统计数据库中的1000多个项目,我们发现在 JavaScript 中最常出现的错误有10个.下面会向大家介绍这些错误发生的原因以及如何防止. 对于这些错误发生的次数,我们是通过收集的数据统计得出的.Rollbar 会收集每个项目中的所有错误,并总结每个错误发生的次数,然后通过各个错误的特征进行分组. 下图是发生次数最多的10大 JavaScript 错误: 下面开始深入探讨每个错误发生的情况,以便确定导致错误发生的原因以及如何避免. 1.   Uncaught TypeError: Cann