Go 项目中常见的 10 种错误

马哥Linux运维

共 13451字,需浏览 27分钟

 ·

2021-09-12 21:49

本文总结了10种 go 语言编成中可能导致性能下降的坏实践。有代码洁癖的同学来自我检查吧!

这篇文章主要讲述了我在 Go 项目中见到过的常见错误清单,顺序无关。

未知的Enum

来看个简单的例子

type Status uint32

const (
    StatusOpen Status = iota
    StatusClose
    StatusUnknown
)

在上面的代码中,使用iota创建了一个enum类型,分别代指下面的状态信息:

StatusOpen    = 0
StatusClose   = 1
StatusUnknown = 2

现在,我们假设Status 是一个 JSON 请求中被Marshalled / Unmarshalled的一个属性,我们可以设计出下面的数据结构:

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

然后,假设收到的Request 的接口返回值为:

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

到目前为止,没有什么特殊的表达,Status将会被反序列化为StatusOpen,是吧?

好的,我们来看一个未设置 status 返回值的请求(不管是出于什么原因吧)。

{
    "Id"1234,
    "Timestamp"1563362390
}

在这个例子中,Request结构体的Status字段将会被初始化为默认零值zeroed value, 对于 uint32 类型来说,值就是0。因此,StatusOpen就替换掉了原本值应该是StatusUnknown

对于这类场景,把unknown value 设置为枚举类型0 应该比较合适,如下:

type Status uint32

const (
    StatusUnknown Status  = iota
    StatusOpen
    StatusClose
)

这样,即时返回的 JSON 请求中没有Status属性,结构体RequestStatus属性也会按我们预期的,被初始化为StatusUnknown

性能测试

正确地进行性能测试很困难,因为过程中有太多的因素会影响测试结果了。

其中一个最常见的错误就是被一些编译器优化参数糊弄,让我们以teivah/bitvector库中的一个真实案例来进行阐述:

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

这个函数会清理给定长度n的二进制位,对这个函数进行性能测试的话,我们可能会写出下面的代码:

func BenchmarkWrong(b *testing.B) {
 for i := 0; i < b.N; i++ {
  clear(12218920808091211063)
 }
}

在这个性能测试中,编译器发现clear函数是并没有调用其他函数,因此编译器就会进行inline处理。除此之外,编译器还发现这个函数中也没有side-effects。因此,clear就会被删除,不去计算它的耗时,因此这就会导致测试结果的不准确。

一个建议是设置全局变量,如下:

var result uint64

func BenchmarkCorrect(b *testing.B) {
 var r uint64
 for i := 0; i < b.N; i++ {
  r = clear(12218920808091211063)
 }
 result = r
}

这样的话,编译器就不知道clear函数是否会造成side-effect了,因此,性能测试的结果就会变得更加准确。

拓展阅读

指针,到处都是指针!

值传递的时候,会创建一个同值变量;而指针传递的时候,只是将变量地址进行拷贝。

因此,指针传递总是会很快,是不?

如果你觉得是这样,可以看一下这个例子。在这个性能测试中,一个大小为0.3K的数据结构分别以值传递和指针传递进行测试。0.3K 不大,但是也不能和大部分我们日常用到的场景中的数据结构大小相差甚远,接近即可。

当我在自己的本地环境中执行这个性能测试代码的时候,值传递比指针传递快了4 倍还多,是不是感觉有悖常理?

关于这个现象的解释涉及到了 Go 中的内存管理,我没法解释得像 William Kennedy 解释的那样精炼,一起来整理总结下吧:

变量可以被分配到heapstack上,粗略解释为:

  • 栈包含哪些分配给了 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 的编译器会把result放置到可以被共享的变量空间:heap。

下面来看另一个场景,比如:

func main() {
    p := &foo{}
    f(p)
}

f的调用方与 f所属为同一个 goroutine,变量p不会被转换,它只是被简单放回到栈中,因此子函数依旧可以访问到。

举例来说,io.Reader中的Read方法接收指针,而不是返回一个,因为返回一个切片就会被转换到堆中。

为什么栈会这么快?这里有两个主要的原因:

  • 栈不需要垃圾收集。正如我们所说,一个变量创建时被压入栈,函数返回时从栈中弹出。根本不需要复杂的处理来回收未使用的变量。
  • 一个栈隶属于一个 goroutine,与堆中变量相比,不需要同步处理,这同样会使得栈很快。

总结一下,当我们创建一个函数的时候,我们应该使用值传递而不是指针传递。只有我们期待某个变量被共享使用时,才使用指针传递适用。

当我们下次遇到性能优化的问题时,一个可能的优化方向就是检查在某些场景下,指针传递是否真的会有所帮助。一个需要了解的常识是:当使用go build \-gcflags "-m \-m"时,编译器会默认将一个变量转换到堆中。

再强调下,在日常开发中,应该总是首先考虑值传递。

拓展阅读 Language Mechanics On Stacks And Pointers

干掉 for/switch 或者 for/select

如果f函数返回了 true,会发生什么?

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

break 语句会被调用,这会导致switch语句退出,而不是 loop 退出。再看一个类似问题:

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

break 同样只是退出select语句,而不是 for 循环。

一个可能的解决方案是使用labeled break 标签,例如:

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

错误管理

Go 中的错误处理机制还是有点简单,或许到了 Go2.0,它会变得好一点。

当前标准库只提供创建错误类型数据结构的方法,具体可查看 pkg/errors。

这个库很好的展示了一些本该被遵守却经常不被遵守的规则的好例子。

一个错误只应该被处理一次。把错误打印到日志中也是在处理错误。所以一个错误要么被打日志,要么被传到调用方。

当前的标准库,如果我们想分层化或者在错误中添加上下文信息是非常困难的。接下来,我们一起看个期待使用 REST 形式调用而导致 DB 出问题的例子:

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来初始化错误信息(如果内部方法调用没有返回 error 的话)。中间调用层insert, 仅仅是通过添加更多上下文信息来包装了错误。然后insert的调用方通过日志进行了打印,每一层要么返回错误,要么处理错误。

有些时候,我们可能会检查错误以便于做重试处理。假如我们有一个叫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被包装了,那么重试机制将永远不会触发。

切片初始化

有时候我们知道切片的最终长度,比如:将切片Foo转换成切片Bar,这意味着两个切片的长度会是一致的。

我经常见到有人这么初始化切片:

var bar []Bar

bars := make([]Bar, 0)

切片不是魔术结构,实际上当空间不足时,Go来动态的维护切片的长度。在这个场景下,一个新的更大容量的数组会自动被创建,然后将旧的数组元素一个个的拷贝到新数组中。

现在,假设我们要多次数以千计的增加[]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, 0len(foos))
 for _, foo := range foos {
  bars = append(bars, fooToBar(foo))
 }
 return bars
}

那么,这俩方法哪个更好呢?

第一个更快一点点,而第二个更符合编码预期:不考虑初始长度,每次只通过append往尾部追加数据。

上下文管理

context.Context 经常被开发者所误解,下面看下官方的解释:

上下文以 API 边界形式,可携带截止时间、取消信号以及其他值。

这段描述通常让人疑惑这玩意儿有啥用,咋用啊?

我们举几个例子,看看它到底能携带什么数据:

  • 截止日期 不管是遇到250 ms还是遇到 2019-01-08 01:00:00格式的时间,必须立刻终止执行(执行的内容可能是 I/O 请求,等待 channel 输入等)
  • 取消信号 类似于上面,一旦接收到信号,就需要立刻终止执行后续处理。例如:接收两个请求,一个是插入数据,另一个是取消第一个的插入,这个场景就可以借助在第一个请求中加入一个可取消的上下文来实现。
  • 其他值 以Key-Value形式,即便都是 interface{}类型。

context 是可组合的,因此可以添加截止时间和其他 key-value 类型数据;另外,多个协程可共享同一个上下文,因此取消信号可以阻止多个执行流程。

回到正题,继续来说说错误问题。

一个 基于 urface/cli (一个用于制作命令行应用的库)Go 应用,一旦启动,开发者继承了一串上下文,使用 context 的终止信号来终止所有的执行。当我意识到请求一个 gRPC 终端的时候,context 只是直接被传递了下去。这不是我想看到的。

相反,我们想让 gRPC 库在收到终止信号或者超过 100ms 处理时间时进行取消处理。为了达到这个目标,我们可以创建一个简单的组合上下文,如果parent是应用上下文的名字(通过 urfave/cli 创建),然后我们就可以写出下面的代码:

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

上下文不难理解,而且在我眼中,它是Go 语言中最棒的特色之一。

不要使用-race选项

我经常见的一个错误就是在测试时使用-race选项。

“即使 Go 是被设计成让并发更容易,更少错误的语言”, 我们仍然经受着很多并发问题的折磨。

显而易见的是,Go 语言中的 race 探查器对独立的并发问题而言并无帮助。不过,当测试我们的应用时开启它也是很有价值的。

使用文件名作为输入

另一个常见问题就是把文件名作为函数的参数。加入我们要实现一个统计文件中空行数量的函数,最自然的实现方式可能就是这样的:

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来抽象化数据源。

它是文件还是 HTTP 的响应体,或者是一个字节缓冲区?都不重要了,我们只需要使用Read方法就都可以搞定。在下面的例子中,我们甚至可以一行一行地读入数据。

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")))

协程与循环变量

最后一个常见的错误就是在循环结构中使用协程。

下面例子中的输出是什么?

ints := []int{123}
for _, i := range ints {
    go func(){
        fmt.Println("%v\n", i)
    }()
}

你是不是以为会是按顺序输出1 2 3?并不是哦。在这个例子中,每一个协程都会共享同一个变量实例,因此它最终大概率会输出3 3 3

有两种解决方案来解决类似问题,第一个就是把循环遍历当做参数传给闭包,比如:

ints := []int{123}
for _, i := range ints {
  go func(i int) {
    fmt.Printf("%v\n", i)
  }(i)
}

另一种方式就是在循环内部的作用域中创建临时变量,比如:

ints := []int{123}
for _, i := range ints {
  i := i
  go func() {
    fmt.Printf("%v\n", i)
  }()
}

虽然看着i := i很奇怪,但是它真的有效。一个循环内部意味着在另一个作用域中,因此i := i 就创建了一个新的变量实例,称之为i。当然,为了可读性我们也可以定义成一个别的名字。

转自:

guoruibiao.blog.csdn.net/article/details/108054295

文章转载:Go开发大全

(版权归原作者所有,侵删)


点击下方“阅读原文”查看更多

浏览 26
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报