Go 语言
常见陷阱
标准库的坑

标准库的坑

time 的坑

Go 标准库中的 time 包提供了时间、日期、定时器等日常开发中最常用的时间相关的工具,但很多人在初次使用 time 包时都会遇到下面的示例的问题:

func main() {
    t := time.Now()
    fmt.Println(t.Format("%Y-%m-%d %H:%M:%S"))
}
// 输出
// %Y-%m-%d %H:%M:%S

很多语言采用字符化的占位符如(%Y %m 等) 拼接处时间的目标输出格式布局如(%Y-%m-%d %H:%M:%S)几乎是时间格式化输出的标准方案。 但在 Go 语言中采用的是参考时间(reference time)方案。使用参考时间构筑出的时间格式串与最终输出串是一模一样的,这样就省去了开发者再次在大脑中对格式串进行解析的过程:

func main() {
    t := time.Now()
    fmt.Println(t.Format("2006-01-02 15:04:05"))
}
// 输出
// 2023-10-10 15:04:05

encoding/json 的坑

Go 语言在 Web 服务开发及 API 领域获得开发者的广泛青睐,这使得 encoding/json 包成为了 Go 标准库中使用频率最高的包之一。

1、未导出的结构体字段不会被编码到 JSON 文本中

使用 json 包将结构体类型编码为 JSON 文本十分简单,通过为结构体字段添加表情(tag)的方式来指示其在 JSON 文本中的名字:

type User struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
    id   int    `json:"id"`
}
 
func main() {
    u := User{"Tom", 20, 123123}
    b, _ := json.Marshal(u)
    fmt.Println(string(b))
}
// 输出
// struct field id has json tag but is not exported
// {"name":"Tom","age":20}

我们看到最终输出的 JSON 文本中并没有包含 id 字段。这是因为 json 包默认仅对结构体中的导出字段(字段名首字母大写)进行编码,非导出字段并不会被编码。 解码时亦是如此:

s := `{"name":"Tom","age":20,"id":123123}`
var u User
json.Unmarshal([]byte(s), &u)
fmt.Println(u)
// 输出
// {Tom 20 0}

除了 JSON 外,在 Go 标准库的 encoding 目录下的各类编解码包(如 xml、gob 等)也都遵循相同的规则。

2、nil 切片和空切片可能被编码为不同文本

在日常开发中,我们很容易哦混淆 nil 切片和空切片。 nil 切片是指尚未初始化的切片,Go 运行时尚未为其分配存储空间; 而空切片则是已经初始化了的切片,Go 运行时为其分配了存储空间,但是该切片的长度为 0。

var nilSlice []int
var emptySlice = make([]int, 0, 5)
 
Println(nilSlice == nil) // true
Println(emptySlice == nil) // false

JSON 包在编码时会区别对待这两种切片:

m := map[string][]int{
    "nil":   nilSlice,
    "empty": emptySlice,
}
 
b, _ := json.Marshal(m)
fmt.Println(string(b))
// 输出
// {"empty":[],"nil":null}

我们看到空切片被编码为[],而 nil 切片被编码为 null。

3、字节切片可能被编码为 base64 文本

一般情况下,字符串与字节切片的区别在于前者存储的是合法 Unicode 字符的 utf-8 编码,而字节切片中可以存储任意字节序列。因此 json 包在编码时会区别对待这两种类型数据:

func main() {
	m := map[string]interface{}{
		"bytes": []byte{"hello, go"},
		"str":   "hello, go",
	}
	b, _ := json.Marshal(m)
	fmt.Println(string(b))
}
// 输出
// {"bytes":"aGVsbG8sIGdv","str":"hello, go"}

我们看到字节切片被编码为 base64 文本,而字符串则被编码为普通文本。

我们可以使用 echo "aGVsbG8sIGdv" | base64 -d,还原出文本内容。

如果你的字节切片中存储的仅是合法的 utf-8 编码的字符串,那么你可以将其转换为字符串类型,然后再进行编码。

4、当 JSON 文本中的整型数值被解码为 interface 类型时,其底层真实类型为 float64

很多时候 JSON 文本中的字段不确定,我们常用 map[string]interface{} 来存储 JSON 包解码后的数据, 这样 JSON 字段值就会存储在一个 interface{} 变量中,通过类型断言来获取其中存储的整型值,改造后的例子如下:

type User struct {
    Name string
    Age  int
}
 
func main() {
    s := `{"name":"Tom","age":20}`
    var u map[string]interface{}
    json.Unmarshal([]byte(s), &u)
    fmt.Println(u["age"].(int))
}
// 输出
// panic: interface conversion: interface {} is float64, not int

json 包提供了 Number 类型来存储 JSON 文本中的各类数值类型,并可以转换为整型(int64)、浮点型(float64)及字符串。

结合 json.Decoder 来修正上面的问题:

func main() {
    s := `{"name":"Tom","age":20}`
    m := map[string]interface{}{}
 
    d := json.NewDecoder(strings.NewReader(s))
    d.UseNumber()
    d.Decode(&m)
    age, _ := m["age"].(json.Number).Int64()
    fmt.Println(age)
}
// 输出
// 20

net/http 的坑

http 包也是整个标准库使用频率最高的包之一。

1、http 包需要我们手动关闭 Response.Body

通过 http 包我们很容易实现一个 HTTP 客户端,如下:

func main() {
    resp, err := http.Get("https://linpx.com")
    if err != nil {
        fmt.Println(err)
        return
    }
    defer resp.Body.Close()
    body, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println(string(body))
}

仅在作为客户端时,http 包才需要我们手动关闭 Response.Body。而在作为服务端时,http 包会自动处理 Response.Body

2、HTTP 客户端默认不会及时关闭已经用完的 HTTP 连接

Go 的标准库 HTTP 客户端的默认实现并不会及时关闭已经用完的 HTTP 连接(仅当服务端主动关闭或要求关闭时才会关闭),这样一旦连接建立过多又得不到及时释放, 就很可能会出现端口资源或文件描述符资源耗尽的异常。

及时释放 HTTP 的连接有两种方法。

第一种是将 http.Request 中的字段 Close 设置为 true。

func main() {
    req, _ := http.NewRequest("GET", "https://linpx.com", nil)
    req.Close = true
    resp, _ := http.DefaultClient.Do(req)
    defer resp.Body.Close()
    body, _ := ioutil.ReadAll(resp.Body)
    fmt.Println(string(body))
}

该例子没有直接使用 http.Get 函数,而是使用 http.NewRequest 函数创建了一个 http.Request 对象,然后将其 Close 字段设置为 true,最后通过 http.DefaultClient.Do 方法发送请求。 当收到并读取完应答后,http 包就会及时关闭该连接。

第二种方法是通过创建一个 http.Client 新实例来实现。

func main() {
    client := &http.Client{
        Transport: &http.Transport{
            DisableKeepAlives: true,
        },
    }
    resp, _ := client.Get("https://linpx.com")
    defer resp.Body.Close()
    body, _ := ioutil.ReadAll(resp.Body)
    fmt.Println(string(body))
}

在创建 http.Client 实例时,我们通过 http.TransportDisableKeepAlives 字段来关闭 HTTP 连接的复用,即设置了与服务端不保持长连接, 这样使用该 Client 实例与服务端收发数据后及时关闭两种之间的 HTTP 连接。