go语言中经常犯的错误
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://www.toutiao.com/article/7236617853640770109/?log_from=5f732d9a4dc79_1686129944632