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

golang中经常会犯的一些错误的更多相关文章

  1. 【转】十个JavaScript中易犯的小错误,你中了几枪?

    目录 常见错误一:对于this关键词的不正确引用 常见错误二:传统编程语言的生命周期误区 常见错误三:内存泄露 常见错误四:比较运算符 常见错误五:低效的DOM操作 常见错误6:在for循环中的不正确 ...

  2. 关于JDBC学习过程中的注意事项(分享自己犯过的错误,写给初学JDBC的小伙伴的八条建议)

    关于JDBC学习过程中的注意事项(分享自己犯过的错误,写给初学JDBC的小伙伴的八条建议) 前言:最近在学习JDBC,总结了几个小问题,特地分享给大家,让大家不要犯这样的错误,也希望大家养成学会总结的 ...

  3. 【编写程序中经常犯的一些错误】 Python | 面向对象(一)

    [编写程序中经常犯的一些错误]Python | 面向对象(一) 在学习Python的面向对象这一部分时,经常出现以下错误: 这是错误范例,请仔细甄别: class Person: def __int_ ...

  4. Golang中常用的代码优化点

    Golang中常用的代码优化点 大家好,我是轩脉刃. 这篇想和大家聊一聊golang的常用代码写法.在golang中,如果大家不断在一线写代码,一定多多少少会有一些些代码的套路和经验.这些经验是代表你 ...

  5. 【转载学习前辈的经验】-- Mistakes I made (as a developer) 我(作为一名开发者)所犯过的错误

    我 2006 年开始工作,至今已经 10 年.10 年是个里程碑,我开始回顾自己曾经犯过的错误,以及我希望从同行那里得到什么类型的忠告.一切都在快速改变,10 年了,我不能确定这些秘诀是否还有用. 不 ...

  6. PHP开发者常犯的MySQL错误

    PHP开发者常犯的MySQL错误   数据库是WEB大多数应用开发的基础.如果你是用PHP,那么大多数据库用的是MYSQL也是LAMP架构的重要部分. PHP看起来很简单,一个初学者也可以几个小时内就 ...

  7. Golang中的坑二

    Golang中的坑二 for ...range 最近两周用Golang做项目,编写web服务,两周时间写了大概五千行代码(业务代码加单元测试用例代码).用Go的感觉很爽,编码效率高,运行效率也不错,用 ...

  8. Golang 中的坑 一

    Golang 中的坑 短变量声明  Short variable declarations 考虑如下代码: package main import ( "errors" " ...

  9. golang中Context的使用场景

    golang中Context的使用场景 context在Go1.7之后就进入标准库中了.它主要的用处如果用一句话来说,是在于控制goroutine的生命周期.当一个计算任务被goroutine承接了之 ...

随机推荐

  1. 4步教你学会使用Linux-Audit工具

    摘要:简单来讲audit是Linux上的审计工具,可以用来记录和监控对文件.目录.系统资源的更改:Audit无法直接增强系统的安全性,但是它可以用于发现违反系统安全政策的行为. 本文分享自华为云社区& ...

  2. 解决使用(Jenkins检出代码)git clone检出代码提示必须安装 .NET framework,Version =v4.7.2

    一.事件背景 真的是非常想使用pipeline流水线进行自动化部署打包测试. 于是,晚上下班回家后,真的是"现学现卖",开始做流水线脚本. 经过不懈努力,熬到凌晨两点多,终于把整个 ...

  3. MySQL入门笔记一

    MySQL应用笔记   一MySQL关系型数据库.开源,中小型公司常用类型的数据库Oracle 大型公司常用数据库 MySQL基本的命令一. 创建.删除.查看数据库(database)创建库creat ...

  4. ASP.Net Core异步编程

    ASP.Net Core异步编程 概念 什么是异步编程? 异步编程是可以让程序并行运行的一种手段,其可以让程序中的一个工作单元与主应用程序线程分开独立运行,并且在工作单元运行结束后,会通知主应用程序线 ...

  5. 操作系统学习笔记5 | 用户级线程 && 内核级线程

    在上一部分中,我们了解到操作系统实现多进程图像需要组织.切换.考虑进程之间的影响,组织就是用PCB的队列实现,用到了一些简单的数据结构知识.而本部分重点就是进程之间的切换. 参考资料: 课程:哈工大操 ...

  6. C#基础_析构函数

    C#析构函数 1. 析构函数的定义与注意的问题 析构函数用于释放被占用的系统资源. 析构函数的名字由符号"-"加类名组成. 使用析构函数时,应该注意下面的问题: ?只能在类中使用析 ...

  7. python进阶__struct数据处理详解

    一.此模块可以执行 Python 值和以 Python bytes 对象表示的 C 结构之间的转换 二.打包解包时,需要按照特定的方式来打包或者解包.该方式就是格式化字符串,它指定了数据类型,除此之外 ...

  8. Springboot连接数据库(解决报错2)

    好家伙, 新建项目,不出意外的话总是会出点意外的 第一天正常运行,第二天就炸了. 1.看报错 百度一下找解决方案 试着将 application.properties中的 com.mysql.jdbc ...

  9. docker存储管理及实例

    一.Docker存储概念 1.容器本地存储与Docke存储驱动 容器本地存储:每个容器都被自动分配了内部存储,即容器本地存储.采用的是联合文件系统.通过存储驱动进行管理. 存储驱动:控制镜像和容器在 ...

  10. multiprocessing 让子进程忽略信号,手动关闭子进程

    起因 同事想要写一个代码,主进程中监听SIGINT.SIGTERM信号退出,并关闭启动的子进程,代码类似这样 import signal import sys import time from mul ...