《100 Go Mistakes and How to Avoid Them》之标准库篇

2023-07-17 ⏳5.5分钟(2.2千字)

10.1 提供了错误的time.Duration值

很多的标准库提供通用方法接收类型为time.Duration的参数,然而time.Duration只是int64的一种别名类型,新开发者很容易会陷入误区,比如java开发者会传递一个毫秒数。

ticker := time.NewTicker(1000)
        for {
            select {
            case <-ticker.C:
                // Do something
            }
}

上面的例子会启动一个1000纳秒的定时器,正常使用应该是ticker = time.NewTicker(1000 * time.Millisecond)

10.2 time.After内存泄漏

time.After功能是在x时间后会接受到一条channel信息,经常用在并发场景中。有时候在循环中调用了time.After会引起内存泄漏。我们下面举个场景:

func consumer(ch <-chan Event) {
    for {
        select {
        case event := <-ch:
            handle(event)
        case <-time.After(time.Hour):
            log.Println("warning: no messages received")
        }
    } 
}

上面是一种消费场景,期望如果一小时都没有接收到事件则warn。这样的代码看似没问题。实际上会引起内存泄漏。 time.After会返回一个channel,我们可能期望在循环结束后channel会关闭,但实际不是。time.After创建的资源直到时间结束才会回收,而每一次的调用会占用200b的内存。所以如果我们一小时接收500w的消息,那就会产生1GB的内存泄漏。

解决方案之一是使用ctx,然后接收ctx.Done().这种方式的缺点是会在每次迭代中重复创建ctx。

func consumer(ch <-chan Event) {
    for {
        ctx, cancel := context.WithTimeout(context.Background(), time.Hour) 
        select {
        case event := <-ch:
            cancel()
            handle(event)
        case <-ctx.Done():
            log.Println("warning: no messages received")
        }
    } 
}

另一种解决方案是使用time.NewTimer,创建一个全局的定时器,在每次迭代前调用Reset方法重置。同时他也提供了Stop方法可以在其他使用场景使用.

func consumer(ch <-chan Event) {
    timerDuration := 1 * time.Hour
    timer := time.NewTimer(timerDuration)

    for {
        timer.Reset(timerDuration)
        select {
        case event := <-ch:
            handle(event)
        case <-timer.C:
            log.Println("warning: no messages received")
        }
    } 
}

10.3 常见的json操作错误

10.3.1 类型内嵌1上的异常行为

我们先举个例子。

type Event struct {
    ID int
    time.Time // 内嵌类型
}
event := Event{
    ID:   1234,
    Time: time.Now(),
}
b, err := json.Marshal(event)
if err != nil {
    return err 
}
fmt.Println(string(b))

结果是2021-05-18T21:15:08.381652+02:00。与你心里的预期相符么,为什么ID字段消失了?要了解原因我们需要知道2个原理:

type Marshaler interface {
    MarshalJSON() ([]byte, error)
}

结果其实已经显而易见了,Event继承了time.Time的MarshalJSON方法,所以json.Marshal只处理了time.Time.

我们的解决方案可以为:

type Event struct {
    ID   int
    Time time.Time
}
func (e Event) MarshalJSON() ([]byte, error) {
    return json.Marshal(
        struct {
            ID   int
            Time time.Time
        }{
            ID: e.ID,
            Time: e.Time,
        },
    ) 
}

10.3.2 JSON 以及 单调时钟2

当我们序列化/反序列化一个带有time.Time的结构体时,很容易出现一些比较上的错误。这是因为Go的Time涵盖了wall clock(常见的时间)& monotonic clock(单调时间,所谓单调,就是只会不停的往前增长,不受校时操作的影响,这个时间是自进程启动以来的秒数).本章节主要讲下两种时钟类型以及对于json的影响。

wall clock 表示当前的时间点,如果时钟是同步于NTP的,他会往前或者往后调整。我们没法使用wall clock去度量时间间隔。所以操作系统提供了第二种时钟类型:monotonic clock.保证了时间会一直往前走不会受到校时的影响。

我们看个例子:

type Event struct {
        Time time.Time
}

func main() {
        t := time.Now()
        event1 := Event{Time: t}
        b, err := json.Marshal(event1)
        if err != nil {
                fmt.Println(err)
        }
        var event2 Event
        err = json.Unmarshal(b, &event2)
        if err != nil {
                fmt.Println(err)
        }
        fmt.Println(event1 == event2)
        fmt.Println(event1.Time)
        fmt.Println(event2.Time)
}

会发现结果是:

false
2023-07-14 17:44:23.781872 +0800 CST m=+0.000096126
2023-07-14 17:44:23.781872 +0800 CST

Go中,time.Time包含了两种时钟.

当我们反序列化json时,time.Time只包括wall time.所以会出现不一致的问题。解决方案我们可以使用:

fmt.Println(event1.Time.Equal(event2.Time))
true

Equal函数不会考虑monotonic time。但是这种方式只能比较time.Time字段,并不是Event结构体. 所以我们可以使用Truncate函数。

event1 := Event{
    Time: t.Truncate(0),
}

Truncate函数将时间调整某个精度的倍数。

10.3.3 Map中的any

当kv类型不确定时,我们会使用map[string]any去接收json反序列化的结果。但里面也会有一些坑可能会导致panic。

b := getMessage()
var m map[string]any
err := json.Unmarshal(b, &m)
if err != nil {
    return err 
}

当我们参数为:

{
    "id": 32,
    "name": "foo"
}

可以得到结果 map[id:32 name:foo] 然后这里会有一个坑:任何的数字类型的值,无论是否有小数点,都会被转化成float64类型。

fmt.Printf("%T\n", m["id"])
float64

10.4 常见的SQL错误

database/sql提供了一系列的接口围绕SQL数据库,在使用的时候也会出现一些常见的错误。

10.4.1 忘记sql.Open并不需要和数据库建立连接

sql.Open并不会建立与数据库之间的连接。 Open may just validate its arguments without creating a connection to the database. 在某些case下,我们想要在所有依赖都准备完成之后启动服务。也就是我们需要在sql.Open之后确定数据库时可到达的,我们可以使用Ping函数。

db, err := sql.Open("mysql", dsn)
if err != nil {
    return err 
}
if err := db.Ping(); err != nil {
    return err
}

10.4.2 忘记连接池

我们需要了解Go中是如何处理数据库连接池的。sql.Open返回一个*sql.DB的结构体.这个结构体代表的不是单一的数据库连接,而是连接池。同样,使用合适的参数去配置连接池同样重要。

10.4.3 不使用预编译语句

预编译语句是很多数据库都支持的特性,可以处理重复的SQL语句。内部SQL语句已经预编译了,根据数据参数来区分。这样有两个好处:

stmt, err := db.Prepare("SELECT * FROM ORDER WHERE ID = ?")
if err != nil {
    return err 
}
rows, err := stmt.Query(id)
// ...

10.4.4 错误处理null值

rows, err := db.Query("SELECT DEP, AGE FROM EMP WHERE ID = ?", id)
if err != nil {
    return err 
}
var (
    department string
    age int
)
for rows.Next() {
    err := rows.Scan(&department, &age)
    if err != nil {
        return err 
    }
// ... 
}

上述例子当遇到Null值时,会报错:

2021/10/29 17:58:05 sql: Scan error on column index 0, name "DEPARTMENT": converting NULL to string is unsupported

第一种解决方式是使用一个字符串指针:

var department *string,当遇到Null值,department会是nil。

另一种方案是使用sql.NullXXX类型,比如sql.NullString:

var department sql.NullStringsql.NullString是字符串的一个封装,包括了2个导出字段:String包含了字符串,Valid代表是否为Null。

10.4.5 没有处理row的迭代错误

rows, err := db.QueryContext(ctx,sql)
...
for rows.Next() {
        err := rows.Scan(&department, &age)
        if err != nil {
            return "", 0, err
        }
}
return

当我们迭代rows.Next()时,我们同样不能遗漏error的情况。

for rows.Next() {
        // ...
}
    if err := rows.Err(); err != nil {
        return "", 0, err
}

10.5 没有关闭临时资源

开发者在使用临时资源的时候一定要记得关闭,避免内存泄漏。实现了io.Closer的结构体也同样如此。

10.5.1 HTTP body

如下述例子:

type handler struct {
    client http.Client
    url    string
}
func (h handler) getBody() (string, error) {
    resp, err := h.client.Get(h.url)
    if err != nil {
        return "", err
    }
    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return "", err
    }
    return string(body), nil
}

我们使用http.Get,然后使用io.ReadAll解析返回内容.看上去没什么问题,其实会有资源泄漏。 resp的类型是*http.Response。包含了io.ReadCloser字段Body.如果http.Get没有报错,那我们就需要关闭他。我们需要defer resp.Body.Close()

10.5.2 sql.Rows

sql.Rows是一组SQL查询返回值的结构体,它实现了io.Closer。我们需要defer rows.Close()

10.5.3 os.File

os.File是打开文件描述符。一样需要关闭。

10.6 在HTTP请求后忘记return语句

当我们处理HTTPhandler时,很容易忘记return.比如下面的例子:

func handler(w http.ResponseWriter, req *http.Request) {
    err := foo(req)
    if err != nil {
        http.Error(w, "foo", http.StatusInternalServerError)
    }
    _, _ = w.Write([]byte("all good"))
    w.WriteHeader(http.StatusCreated)
}

忘记了return,遇到了error也会继续执行逻辑,最终返回all good。所以我们需要知道http.Error并不能停止逻辑执行,我们必须要手动终止返回。

10.7 使用默认的HTTP客户端和服务端

http标准库提供了HTTP客户端以及服务端的实现。然而开发者很容易犯的一个通用错误:在生产环境使用默认的实现。

10.7.1 HTTP client

我们先定义下default client是什么意思。当我们使用零值的http.Client:

client := &http.Client{}
resp, err := client.Get("https://golang.org/")

或者使用http.Get函数:

resp, err := http.Get("https://golang.org/")

两种方式最终都会使用到DefaultClient

// DefaultClient is the default Client and is used by Get, Head, and Post.
var DefaultClient = &Client{}

那么使用默认客户端会有什么问题呢?首先,它不包括任何的超时。可能会导致请求永不结束,然后消耗完系统资源。

我们可以举个例子去复写这些超时控制字段

client := &http.Client{
    Timeout: 5 * time.Second,
    Transport: &http.Transport{
        DialContext: (&net.Dialer{
            Timeout: time.Second,
        }).DialContext,
    TLSHandshakeTimeout:   time.Second,
    ResponseHeaderTimeout: time.Second,
    }, 
}

其次,默认客户端的连接池也是被配置好的,在生产环境不一定适用。

10.7.2 HTTP server

默认的服务端也是一样,当我们使用http.Serve,http.ListenAndServe,http .ListenAndServeTLS时,使用的都是默认http.Server.

http.TimeoutHandler会封装一个handler去限定时间,当超时发生,会返回503 Service Unavailable,并且会cancel上下文。

PS: 我们故意忽略了http.Server.WriteTimeout。在http.TimeoutHandler出现后,并不是必须要用的。而且它使用起来比较复杂。超时发生后,它会关闭连接并且不返回HTTP code.他也不会去取消上下文,执行协程会一直执行。

当我们暴露站点给客户端时,最好的方式是设置http.Server.ReadHeaderTimeout,并且使用http.TimeoutHandler包装函数。举例:

s := &http.Server{
    Addr: ":8080",
    ReadHeaderTimeout: 500 * time.Millisecond,
    ReadTimeout: 500 * time.Millisecond,
    Handler: http.TimeoutHandler(handler, time.Second, "foo"),
}