关于go:golang中经常会犯的一些错误

50次阅读

共计 9899 个字符,预计需要花费 25 分钟才能阅读完成。

0.1、索引

https://waterflow.link/articles/1664080524986

1、未知的枚举值

咱们当初定义一个类型是 unit32 的 Status,他能够作为枚举类型,咱们定义了 3 种状态

type Status uint32

const (
    StatusOpen Status = iota
    StatusClosed
    StatusUnknown
)

其中咱们应用了 iota,相干的用法自行 google。最终对应的状态就是:

0- 开启状态,1- 敞开状态,2- 未知状态

当初咱们假如有一个申请参数过去,数据结构如下:

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

能够看到是一个 json 类型的字符串,其中就蕴含了 Status 状态,咱们的申请是心愿把状态批改为敞开状态。

而后咱们在服务端创立一个构造体,不便把这些字段解析进去:

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

好了,咱们在 main 中执行下代码,看下解析是否正确:

package main

import (
    "encoding/json"
    "fmt"
)

type Status uint32

const (
    StatusOpen Status = iota
    StatusClosed
    StatusUnknown
)

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

func main() {
    js := `{
        "Id": 1234,
        "Timestamp": 1563362390,
        "Status": 1
      }`

    request := &Request{}
    err := json.Unmarshal([]byte(js), request)
    if err != nil {fmt.Println(err)
        return
    }
}

执行后的后果如下:

go run main.go
&{1234 1563362390 1}

能够看到解析是没问题的。

然而,让咱们再提出一个未设置状态值的申请(无论出于何种起因):

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

在这种状况下,申请构造的状态字段将被初始化为其零值(对于 uint32 类型:0)。因而,StatusOpen 而不是 StatusUnknown。

最佳实际是将枚举的未知值设置为 0:

type Status uint32

const (
    StatusUnknown Status = iota
    StatusOpen
    StatusClosed
)

在这里,如果状态不是 JSON 申请的一部分,它将被初始化为 StatusUnknown,正如咱们所冀望的那样。

2、指针无处不在?

按值传递变量将创立此变量的正本。而通过指针传递它只会复制内存地址。

因而,传递指针总是会更快,对么?

如果你置信这一点,请看看这个例子。这是一个 0.3 KB 数据结构的基准测试,咱们通过指针和值传递和接管。0.3 KB 并不大,但这与咱们每天看到的数据结构类型(对于咱们大多数人来说)应该相差不远。

当我在本地环境中执行这些基准测试时,按值传递比按指针传递快 4 倍以上。这可能有点违反直觉,对吧?

这其实与 Go 中如何治理内存无关。咱们都晓得变量能够调配在堆上或栈上,也晓得:

  • 栈蕴含给定 goroutine 的正在进行的变量。一旦函数返回,变量就会从堆栈中弹出。
  • 堆蕴含共享变量(全局变量等)。

让咱们看下上面这个简略的例子:

type foo struct{}

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

这里,一个后果变量由以后的 goroutine 创立。这个变量被压入以后堆栈。一旦函数返回,客户端将收到此变量的正本。变量自身从堆栈中弹出。它依然存在于内存中,直到它被另一个变量擦除,但它不能再被拜访。

咱们当初批改下下面的例子,应用指针:

type foo struct{}

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

后果变量依然由以后的 goroutine 创立,但客户端将收到一个指针(变量地址的正本)。如果后果变量从堆栈中弹出,则此函数的客户端无奈再拜访它。

在这种状况下,Go 编译器会将后果变量转移到能够共享变量的中央:堆。

然而,传递指针是另一种状况。例如:

type foo struct{}

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

因为咱们在同一个 goroutine 中调用 f,所以 p 变量不须要被转移。它只是被压入堆栈,子函数能够拜访它。

比方在 io.Reader 的 Read 办法中接管切片而不是返回切片的间接后果,也不会转移到堆上。

然而返回一个切片(它是一个指针)会将其转移到堆中。

为什么堆栈那么快?次要起因有两个:

  • 堆栈不须要垃圾收集器。正如咱们所说,一个变量在创立后被简略地压入,而后在函数返回时从堆栈中弹出。无需进行简单的过程来回收未应用的变量等。
  • 堆栈属于一个 goroutine,因而与将变量存储在堆上相比,存储变量不须要同步。这也导致性能增益。

论断就是:

当咱们创立一个函数时,咱们的默认行为应该是应用值而不是指针。仅当咱们想要共享变量时才应应用指针。

最初:

如果咱们遇到性能问题,一种可能的优化可能是查看指针在某些特定状况下是否有帮忙。应用以下命令能够晓得编译器何时将变量转移到堆中:go build -gcflags “-m -m”。(内存逃逸)

3、中断 for/switch 或 for/select

咱们看下上面的代码会产生什么:

package main

func f() bool {return true}

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

咱们将调用 break 语句。然而,这会毁坏 switch 语句,而不是 for 循环。

雷同的状况还会呈现在 fo/select 中,像上面这样:

package main

import (
    "context"
    "time"
)

func main() {ch := make(chan struct{})
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()
    for {
        select {
        case <-ch:
        // Do something
        case <-ctx.Done():
            break
        }
    }
}

尽管调用了 break,然而还是会陷入死循环。break 与 select 语句无关,与 for 循环无关。

突破 for/switch 或 for/select 的,一种计划是间接 return 完结整个函数,上面如果还有代码不会被执行。

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {ch := make(chan struct{})
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()
    for {
        select {
        case <-ch:
        // Do something
        case <-ctx.Done():
            return
        }
    }

  // 这里不会执行
    fmt.Println("done")
}

还有一种计划是应用中断标记

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {ch := make(chan struct{})
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()
loop:
    for {
        select {
        case <-ch:
        // Do something
        case <-ctx.Done():
            break loop
        }
    }

  // 会持续往下执行
    fmt.Println("done")
}

4、谬误治理

一个谬误应该只解决一次。记录谬误就是处理错误。因而,应该记录或流传谬误。

咱们可能心愿为谬误增加一些上下文并具备某种模式的层次结构。

让咱们看一个接口申请数据库的例子,咱们分为接口层,service 层和类库层。咱们心愿返回的层次结构像上面这样:

unable to serve HTTP POST request for id 1
 |_ unable to insert customer
     |_ unable to commit transaction

如果咱们应用 pkg/errors,咱们能够这样做:

package main

import (
    "fmt"

    "github.com/pkg/errors"
)

func postHandler(id int) string {err := insert(id)
    if err != nil {fmt.Printf("unable to serve HTTP POST request for id %d\n", id)
        return `{ok: false}`
    }
    return `{ok: true}`
}

func insert(id int) error {err := dbQuery(id)
    if err != nil {return errors.Wrapf(err, "unable to insert customer")
    }
    return nil
}

func dbQuery(id int) error {
    // Do something then fail
    return errors.New("unable to commit transaction")
}

func main() {res := postHandler(1)
    fmt.Println(res)
}

初始谬误(如果不是由内部库返回)能够应用 errors.New 创立。service 层 insert 通过向其增加更多上下文来包装此谬误。而后,接口层通过记录谬误来处理错误。每个级别都返回或处理错误。

例如,咱们可能还想查看谬误起因自身以实现重试。假如咱们有一个来自解决数据库拜访的内部库的 db 包。这个库可能会返回一个名为 db.DBError 的临时(长期)谬误。要确定是否须要重试,咱们必须查看谬误起因:

package main

import (
    "fmt"

    "github.com/pkg/errors"
)

type DbError struct {msg string}

func (e *DbError) Error() string {return e.msg}

func postHandler(id int) string {err := insert(id)
    if err != nil {errCause := errors.Cause(err)
        if _, ok := errCause.(*DbError); ok {fmt.Println("retry")
        } else {fmt.Printf("unable to serve HTTP POST request for id %d\n", id)
            return `{ok: false}`
        }

    }
    return `{ok: true}`
}

func insert(id int) error {err := dbQuery(id)
    if err != nil {return errors.Wrapf(err, "unable to insert customer")
    }
    return nil
}

func dbQuery(id int) error {
    // Do something then fail
    return &DbError{"unable to commit transaction"}
}

func main() {res := postHandler(1)
    fmt.Println(res)
}

这是应用 errors.Cause 实现的,它也来自 pkg/errors。(能够通过 errors.Cause 查看。errors.Cause 将递归检索没有实现 causer 的最顶层谬误,这被认为是原始起因。)

有时候也会有人这么用。例如,查看谬误是这样实现的:

package main

import (
    "fmt"

    "github.com/pkg/errors"
)

type DbError struct {msg string}

func (e *DbError) Error() string {return e.msg}

func postHandler(id int) string {err := insert(id)
    if err != nil {switch err.(type) {
        default:
            fmt.Printf("unable to serve HTTP POST request for id %d\n", id)
            return `{ok: false}`
        case *DbError:
            fmt.Println("retry")

        }
    }
    return `{ok: true}`
}

func insert(id int) error {err := dbQuery(id)
    if err != nil {return errors.Wrapf(err, "unable to insert customer")
    }
    return nil
}

func dbQuery(id int) error {
    // Do something then fail
    return &DbError{"unable to commit transaction"}
}

func main() {res := postHandler(1)
    fmt.Println(res)
}

如果 DBError 被包装,它永远不会触发重试。

5、切片初始化

有时,咱们晓得切片的最终长度是多少。例如,假如咱们要将 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
    }
  • 或者应用 0 长度和预约义容量对其进行初始化:

    func convert(foos []Foo) []Bar {bars := make([]Bar, 0, len(foos))
        for _, foo := range foos {bars = append(bars, fooToBar(foo))
        }
        return bars
    }

选哪个更好呢?第一个略微快一点。然而,你可能更喜爱第二个,因为无论咱们是否晓得初始大小,在切片开端增加一个元素都是应用 append 实现的。

6、上下文治理

context.Context 对咱们来说十分好用,他能够在协程之间传递数据、能够管制协程的生命周期等等。然而这也造成了它的滥用。

go 官网文档是这么定义的:

== 一个 Context 携带一个截止日期、一个勾销信号和其余跨 API 边界的值。==

这个形容很宽泛,足以让一些人对为什么以及如何应用它感到困惑。

让咱们试着具体阐明一下。上下文能够携带:

  • 一个截止工夫 。它意味着一个持续时间(例如 250 毫秒)或日期工夫(例如 2022-01-08 01:00:00),咱们认为如果达到,咱们必须勾销正在进行的流动(I/O 申请,期待通道输出等)。
  • 勾销信号 (基本上是 <-chan struct{})。在这里,行为是类似的。一旦咱们收到信号,咱们必须进行正在进行的流动。例如,假如咱们收到两个申请。一个插入一些数据,另一个勾销第一个申请(因为它不再须要)。这能够通过在第一次调用中应用可勾销上下文来实现,一旦咱们收到第二个申请,该上下文将被勾销。
  • 键 / 值列表 (均基于 interface{} 类型)。

另外须要阐明的是。

首先,上下文是可组合的。因而,咱们能够有一个蕴含截止日期和键 / 值列表的上下文。

此外,多个 goroutine 能够共享雷同的上下文,因而勾销信号可能会进行多个流动。

咱们能够看下一个具体的谬误例子

一个 Go 应用程序是基于 urfave/cli 的(如果你不晓得,那是一个在 Go 中创立命令行应用程序的好库)。一旦开始,开发人员就会继承某种应用程序上下文。这意味着当应用程序进行时,库将应用此上下文发送勾销信号。

我理解的是,这个上下文是在调用 gRPC 端点时间接传递的。这不是咱们想要做的。

相同,咱们想向 gRPC 库传递:请在应用程序进行时或在 100 毫秒后勾销申请。

为此,咱们能够简略地创立一个组合上下文。如果 parent 是应用程序上下文的名称(由 urfave/cli 创立),那么咱们能够简略地这样做:

package main

import (
    "context"
    "fmt"
    "log"
    "os"
    "time"

    "github.com/urfave/cli/v2"
)

func main() {

    app := &cli.App{
        Name:  "boom",
        Usage: "make an explosive entrance",
        Action: func(parent *cli.Context) error {
      // 父上下文传进来,给个超时工夫
            ctx, cancel := context.WithTimeout(parent.Context, 10*time.Second)
            defer cancel()
            grpcClientSend(ctx)

            return nil
        },
    }

    if err := app.Run(os.Args); err != nil {log.Fatal(err)
    }
}

func grpcClientSend(ctx context.Context) {
    for {
        select {case <-ctx.Done(): // 达到超时工夫就完结
            fmt.Println("cancel!")
            return
        default:
            time.Sleep(2 * time.Second)
            fmt.Println("do something!")
        }
    }
}

7、应用文件名作为函数输出?

假如咱们必须实现一个函数来计算文件中的空行数。个别咱们是这样实现的:

package main

import (
    "bufio"
    "fmt"
    "os"

    "github.com/pkg/errors"
)

func main() {cou, err := count("a.txt")
    if err != nil {fmt.Println(err)
        return
    }
    fmt.Println(cou)
}

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 body 实现雷同的逻辑,咱们将不得不为此创立另一个函数。

Go 带有两个很棒的形象:io.Reader 和 io.Writer。咱们能够简略地传递一个 io.Reader 来形象数据源,而不是传递文件名。

是文件吗?HTTP body?字节缓冲区?这并不重要,因为咱们仍将应用雷同的 Read 办法。

在咱们的例子中,咱们甚至能够缓冲输出以逐行读取。因而,咱们能够应用 bufio.Reader 及其 ReadLine 办法:

咱们把读取文件的局部放到函数里面

package main

import (
    "bufio"
    "fmt"
    "io"
    "os"

    "github.com/pkg/errors"
)

func main() {

    filename := "a.txt"
    file, err := os.Open(filename)
    if err != nil {fmt.Println(err, "unable to open", filename)
        return
    }
    defer file.Close()
    count, err := count(bufio.NewReader(file))
    if err != nil {fmt.Println(err)
        return
    }
    fmt.Println(count)
}

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++}
    }
}

应用第二种实现,无论理论数据源如何,都能够调用该函数。同时,这将有助于咱们的单元测试,因为咱们能够简略地从字符串创立一个 bufio.Reader:

package main

import (
    "bufio"
    "fmt"
    "io"
    "strings"

    "github.com/pkg/errors"
)

func main() {count, err := count(bufio.NewReader(strings.NewReader("input\n\n")))

    if err != nil {fmt.Println(err)
        return
    }
    fmt.Println(count)
}

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++}
    }
}

8、Goroutines 和循环变量

我看到一个常见谬误是应用带有循环变量的 goroutines。

以下示例的输入是什么?

package main

import (
    "fmt"
    "time"
)

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

    time.Sleep(time.Second)
}

在这个例子中,每个 goroutine 共享雷同的变量实例,所以它会产生 3 3 3。而不是咱们认为的 1 2 3

有两种解决方案能够解决这个问题。第一个是将 i 变量的值传递给闭包(外部函数):

package main

import (
    "fmt"
    "time"
)

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

    time.Sleep(time.Second)
}

第二个是在 for 循环范畴内创立另一个变量:

package main

import (
    "fmt"
    "time"
)

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

    time.Sleep(time.Second)
}

调用 i := i 可能看起来有点奇怪,但它齐全无效。处于循环中意味着处于另一个范畴内。所以 i := i 创立了另一个名为 i 的变量实例。当然,为了便于浏览,咱们可能想用不同的名称来称说它。

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

正文完
 0