Go-秘籍-二-

155 阅读16分钟

Go 秘籍(二)

原文:zh.annas-archive.org/md5/d17f8ead62b31a6ec2bbef4005dc3b6d

译者:飞龙

协议:CC BY-NC-SA 4.0

第四章:CSV 配方

4.0 简介

CSV 格式是一种文件格式,可以在文本编辑器中轻松编写和读取表格数据(数字和文本)。CSV 得到广泛支持,大多数电子表格程序,如 Microsoft Excel 和 Apple Numbers,都支持 CSV。因此,许多编程语言,包括 Go,都提供了生成和消耗 CSV 文件数据的库。

或许你会感到惊讶,CSV 已经存在了将近 50 年了。IBM Fortran 编译器在 1972 年的 OS/360 中支持它。如果你对此不是很确定,那么 OS/360 是 IBM 为他们的 System/360 主机计算机开发的批处理操作系统。因此,是的,CSV 最早的用途之一是用于 IBM 主机上的 Fortran 程序。

CSV(逗号分隔值)并没有很好地标准化,也不是所有的 CSV 格式都是用逗号分隔的。有时可能是制表符、分号或其他分隔符。但是,RFC 4180 有一个 CSV 规范,尽管并不是所有人都遵循这个标准。

Go 标准库有一个encoding/csv包,支持 RFC 4180,并帮助我们读写 CSV 文件。

4.1 读取 CSV 文件

问题

你想要将 CSV 文件读入内存以供使用。

解决方案

使用encoding/csv包和csv.ReadAll将 CSV 文件中的所有数据读取到一个二维字符串数组中。

讨论

假设你有这样一个文件:

id,first_name,last_name,email
1,Sausheong,Chang,sausheong@email.com
2,John,Doe,john@email.com

第一行是标题,接下来的 2 行是用户数据。以下是打开文件并将其读入二维字符串数组的代码。

file, err := os.Open("users.csv")
if err != nil {
 log.Println("Cannot open CSV file:", err)
}
defer file.Close()
reader := csv.NewReader(file)
rows, err := reader.ReadAll()
if err != nil {
 log.Println("Cannot read CSV file:", err)
}

首先,我们使用os.Open打开文件。这会创建一个os.File结构体(它是一个io.Reader),我们可以将其作为参数传递给csv.NewReadercsv.NewReader创建一个新的csv.Reader结构体,可以用来从 CSV 文件中读取数据。有了这个 CSV 读取器,我们可以使用ReadAll读取文件中的所有数据,并返回一个二维字符串数组[][]string

一个二维字符串数组?也许你会感到惊讶,如果 CSV 行项是整数?或者布尔值或其他类型?你应该记住 CSV 文件是文本文件,所以除了字符串以外,没有其他方式可以区分一个值是否是其他类型。换句话说,所有的值都被假定为字符串,如果你认为有其他类型,你需要将其强制转换为其他类型。

将 CSV 数据解析到结构体中

问题

你想要将 CSV 数据解析到结构体中,而不是二维字符串数组。

解决方案

首先将 CSV 文件读入一个二维字符串数组,然后将其存储到结构体中。

讨论

对于一些其他格式,比如 JSON 或 XML,从文件(或任何地方)读取数据并将其解析到结构体中是很常见的。尽管在 CSV 中也可以做到这一点,但你需要做更多的工作。

假设你想要将数据放入一个 User 结构体中。

type User struct {
   Id         int
   firstName  string
   lastName   string
   email      string
}

如果你想要将二维字符串数组中的数据解析到 User 结构体中,你需要自己转换每个项目。

var users []User
for _, row := range rows {
 id, _ := strconv.ParseInt(row[0], 0, 0)
 user := User{Id: int(id),
   firstName: row[1],
   lastName:  row[2],
   email:     row[3],
 }
 users = append(users, user)
}

在上面的例子中,因为用户 ID 是整数,我在使用它创建用户结构之前使用了 strconv.ParseInt 将字符串转换为整数。

在 for 循环结束时,你将会有一个 User 结构体的数组。如果你打印出来,你应该会看到这样的结果。

{0  first_name  last_name  email}
{1  Sausheong  Chang  sausheong@email.com}
{2  John  Doe  john@email.com}

4.2 移除标题行

问题

如果你的 CSV 文件有一行标头作为列标签,你也会得到它在返回的二维字符串数组或结构体数组中。你想把它移除。

解决方案

使用 Read 读取第一行,然后继续读取剩下的行。

讨论

当你在读取器上使用 Read 时,你将读取第一行,然后将光标移动到下一行。如果之后使用 ReadAll,你可以读取文件的其余部分到你想要的行中。

file, err := os.Open("users.csv")
if err != nil {
 log.Println("Cannot open CSV file:", err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.Read() // use Read to remove the first line
rows, err := reader.ReadAll()
if err != nil {
 log.Println("Cannot read CSV file:", err)
}

这将给我们带来这样的东西:

{1  Sausheong  Chang  sausheong@email.com}
{2  John  Doe  john@email.com}

4.3 使用不同的分隔符

问题

CSV 并不一定需要使用逗号作为分隔符。你想读取一个 CSV 文件,其分隔符不是逗号。

解决方案

设置 csv.Reader 结构中的 Comma 变量为文件中使用的分隔符,然后像以前一样读取。

讨论

假设我们要读取的文件以分号作为分隔符。

id;first_name;last_name;email
1;Sausheong;Chang;sausheong@email.com
2;John;Doe;john@email.com

我们只需在之前创建的 csv.Reader 结构中设置 Comma,然后像以前一样读取文件。

file, err := os.Open("users2.csv")
if err != nil {
  log.Println("Cannot open CSV file:", err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.Comma = ';' // change Comma to the delimiter in the file
rows, err := reader.ReadAll()
if err != nil {
  log.Println("Cannot read CSV file:", err)
}

4.4 忽略行

问题

当你读取 CSV 文件时,你想忽略某些行。

解决方案

在文件中使用注释来指示要忽略的行。然后在 csv.Reader 中启用编码,并像以前一样读取文件。

讨论

假设你想忽略某些行;你想做的就是简单地注释掉那些行。在 CSV 中你不能这样做,因为注释不是标准。但是使用 Go 的 encoding/csv 包,你可以指定一个注释符号,如果你把它放在行的开头,整行都会被忽略。

所以说你有这个 CSV 文件。

id,first_name,last_name,email
1,Sausheong,Chang,sausheong@email.com
# 2,John,Doe,john@email.com

要启用注释,只需在我们从 csv.NewReader 获取的 csv.Reader 结构中设置 Comment 变量。

file, err := os.Open("users.csv")
if err != nil {
  log.Println("Cannot open CSV file:", err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.Comment = '#' // lines that start with this will be ignored
rows, err := reader.ReadAll()
if err != nil {
  log.Println("Cannot read CSV file:", err)
}

当你运行这个时,你会看到:

{0 first_name last_name email}
{1 Sausheong Chang sausheong@email.com}

4.5 写入 CSV 文件

问题

你想将内存中的数据写入 CSV 文件。

解决方案

使用 encoding/csv 包和 csv.Writer 来写入文件。

讨论

我们乐在读取 CSV 文件,现在我们必须写一个。写入与读取非常相似。首先需要创建一个文件(一个 io.Writer)。

file, err := os.Create("new_users.csv")
if err != nil {
  log.Println("Cannot create CSV file:", err)
}
defer file.Close()

写入文件的数据需要是二维字符串数组。记住,如果数据不是字符串,只需在这之前将其转换为字符串。创建一个带有文件的 csv.Writer 结构。之后,你可以在写入器上调用 WriteAll,文件就会被创建。这将所有数据写入你的二维字符串数组中的文件。

data := [][]string{
 {"id", "first_name", "last_name", "email"},
 {"1", "Sausheong", "Chang", "sausheong@email.com"},
 {"2", "John", "Doe", "john@email.com"},
}
writer := csv.NewWriter(file)
err = writer.WriteAll(data)
if err != nil {
 log.Println("Cannot write to CSV file:", err)
}

4.6 一次写入一行到文件

问题

不要把所有东西写在我们的二维字符串中,我们想一次写入一行到文件中。

解决方案

使用 csv.Writer 上的 Write 方法来写入单行。

讨论

将每一行逐行写入文件几乎相同,只是你需要迭代二维字符串数组以获取每一行,然后调用Write方法传递该行。每当需要将缓冲数据写入Writer(即文件)时,还需要调用Flush方法。在上面的示例中,我在将所有数据写入写入器后调用了Flush,但那是因为我没有很多数据。如果有很多行数据,你可能会想定期将数据刷新到文件中。要检查写入或刷新时是否出现问题,可以调用Error方法。

writer := csv.NewWriter(file)
for _, row := range data {
 err = writer.Write(row)
 if err != nil {
  log.Println("Cannot write to CSV file:", err)
 }
}
writer.Flush()

第五章:JSON 配方

5.0 引言

JSON(JavaScript 对象表示法)是一种轻量级的数据交换文本格式。它旨在供人类阅读,但也易于机器读取,基于 JavaScript 的一个子集。JSON 最初由 Douglas Crockford 定义,但目前由 RFC 7159 和 ECMA-404 描述。JSON 在基于 REST 的 Web 服务中广泛使用,尽管它们不一定需要接受或返回 JSON 数据。

JSON 在 RESTful web 服务中非常流行,但也经常用于配置。在许多 Web 应用程序中,从获取 Web 服务中的数据,到通过第三方身份验证服务验证 Web 应用程序,再到控制其他服务,创建和消费 JSON 是司空见惯的。

Go 使用encoding/json包支持标准库中的 JSON。

5.1 解析 JSON 数据字节数组到结构体

问题

您希望读取 JSON 数据字节数组并将其存储到结构体中。

解决方案

创建结构体以包含 JSON 数据,然后使用encoding/json包中的Unmarshal函数将数据解封装到结构体中。

讨论

使用encoding/json包解析 JSON 非常简单:

  1. 创建结构体以包含 JSON 数据。

  2. 将 JSON 字符串解封装为结构体

让我们来看一个示例 JSON 文件,包含来自 SWAPI(星球大战 API)的卢克·天行者角色数据。您可以直接访问此处的数据 — https://swapi.dev/api/people/1。我已经将数据存储在名为skywalker.json的文件中。

{
	"name": "Luke Skywalker",
	"height": "172",
	"mass": "77",
	"hair_color": "blond",
	"skin_color": "fair",
	"eye_color": "blue",
	"birth_year": "19BBY",
	"gender": "male",
	"homeworld": "https://swapi.dev/api/planets/1/",
	"films": [
		"https://swapi.dev/api/films/1/",
		"https://swapi.dev/api/films/2/",
		"https://swapi.dev/api/films/3/",
		"https://swapi.dev/api/films/6/"
	],
	"species": [],
	"vehicles": [
		"https://swapi.dev/api/vehicles/14/",
		"https://swapi.dev/api/vehicles/30/"
	],
	"starships": [
		"https://swapi.dev/api/starships/12/",
		"https://swapi.dev/api/starships/22/"
	],
	"created": "2014-12-09T13:50:51.644000Z",
	"edited": "2014-12-20T21:17:56.891000Z",
	"url": "https://swapi.dev/api/people/1/"
}

要将数据存储在 JSON 中,我们可以创建这样一个结构体。

type Person struct {
	Name      string        `json:"name"`
	Height    string        `json:"height"`
	Mass      string        `json:"mass"`
	HairColor string        `json:"hair_color"`
	SkinColor string        `json:"skin_color"`
	EyeColor  string        `json:"eye_color"`
	BirthYear string        `json:"birth_year"`
	Gender    string        `json:"gender"`
	Homeworld string        `json:"homeworld"`
	Films     []string      `json:"films"`
	Species   []string      `json:"species"`
	Vehicles  []string      `json:"vehicles"`
	Starships []string      `json:"starships"`
	Created   time.Time     `json:"created"`
	Edited    time.Time     `json:"edited"`
	URL       string        `json:"url"`
}

在结构体定义每个字段后的字符串字面量称为结构标记。Go 使用这些结构标记确定结构字段与 JSON 元素之间的映射。如果映射完全相同,则不需要它们。然而正如你所见,JSON 通常使用蛇形命名法(用下划线替换空格),使用小写字符,而在 Go 中我们使用驼峰命名法(变量无空格,但用一个大写字母表示分隔)。

如您从结构体中看到的,我们可以定义字符串切片以存储 JSON 中的数组,并使用time.Time等数据类型。事实上,我们可以使用大多数 Go 数据类型,甚至是映射(尽管只支持具有字符串键的映射)。

将数据解封装到结构体中只需一个函数调用,使用json.Unmarshal

func unmarshal() (person Person) {
	file, err := os.Open("skywalker.json")
	if err != nil {
		log.Println("Error opening json file:", err)
	}
	defer file.Close()

	data, err := io.ReadAll(file)
	if err != nil {
		log.Println("Error reading json data:", err)
	}

	err = json.Unmarshal(data, &person)
	if err != nil {
		log.Println("Error unmarshalling json data:", err)
	}
	return
}

在上述代码中,从文件读取数据后,我们创建一个Person结构体,然后使用json.Unmarshal将数据解封装到其中。

JSON 数据来自 Star Wars API,所以让我们通过 API 直接获取并稍作乐趣。我们使用http.Get函数传入 URL,但其他一切都一样。

func unmarshalAPI() (person Person) {
	r, err := http.Get("https://swapi.dev/api/people/1")
	if err != nil {
		log.Println("Cannot get from URL", err)
	}
	defer r.Body.Close()

	data, err := io.ReadAll(r.Body)
	if err != nil {
		log.Println("Error reading json data:", err)
	}

	err = json.Unmarshal(data, &person)
	if err != nil {
		log.Println("Error unmarshalling json data:", err)
	}
	return
}

如果你打印Person结构体,这就是我们应该得到的结果(输出已美化)。

json.Person{
    Name:      "Luke Skywalker",
    Height:    "172",
    Mass:      "77",
    HairColor: "blond",
    SkinColor: "fair",
    EyeColor:  "blue",
    BirthYear: "19BBY",
    Gender:    "male",
    Homeworld: "https://swapi.dev/api/planets/1/",
    Films:     {"https://swapi.dev/api/films/1/", "https://swapi.dev/api/films/2/",
    "https://swapi.dev/api/films/3/", "https://swapi.dev/api/films/6/"},
    Species:   {},
    Vehicles:  {"https://swapi.dev/api/vehicles/14/",
      "https://swapi.dev/api/vehicles/30/"},
    Starships: {"https://swapi.dev/api/starships/12/",
      "https://swapi.dev/api/starships/22/"},
    Created:   time.Date(2014, time.December, 9, 13, 50, 51, 644000000, time.UTC),
    Edited:    time.Date(2014, time.December, 20, 21, 17, 56, 891000000, time.UTC),
    URL:       "https://swapi.dev/api/people/1/",
}

5.2 解析非结构化 JSON 数据

问题

您想解析一些 JSON 数据,但不知道 JSON 数据的结构或属性足够提前来构建结构体,或者键到值的映射是动态的。

解决方案

我们使用与之前相同的方法,但是不使用预定义的结构体,而是使用字符串映射到空接口来存储数据。

讨论

明确星球大战 API 的结构。但并非总是如此。有时我们根本不知道结构足够清晰以创建结构体,而且没有可用的文档。此外,有时键到值的映射可能是动态的。看看这个 JSON。

{
    "Luke Skywalker": [
        "https://swapi.dev/api/films/1/",
        "https://swapi.dev/api/films/2/",
        "https://swapi.dev/api/films/3/",
        "https://swapi.dev/api/films/6/"
       ],
    "C-3P0": [
        "https://swapi.dev/api/films/1/",
        "https://swapi.dev/api/films/2/",
        "https://swapi.dev/api/films/3/",
        "https://swapi.dev/api/films/4/",
        "https://swapi.dev/api/films/5/",
        "https://swapi.dev/api/films/6/"
       ],
    "R2D2": [
        "https://swapi.dev/api/films/1/",
        "https://swapi.dev/api/films/2/",
        "https://swapi.dev/api/films/3/",
        "https://swapi.dev/api/films/4/",
        "https://swapi.dev/api/films/5/",
        "https://swapi.dev/api/films/6/"
       ],
    "Darth Vader": [
        "https://swapi.dev/api/films/1/",
        "https://swapi.dev/api/films/2/",
        "https://swapi.dev/api/films/3/",
        "https://swapi.dev/api/films/6/"
       ]
}

显然,从 JSON 中,键不一致,并且可以随着字符的添加而改变。对于这种情况,我们如何解析 JSON 数据?我们可以使用字符串映射到空接口,而不是预定义的结构体。让我们看一下代码。

func unstructured() (output map[string]interface{}) {
	file, err := os.Open("unstructured.json")
	if err != nil {
		log.Println("Error opening json file:", err)
	}
	defer file.Close()

	data, err := io.ReadAll(file)
	if err != nil {
		log.Println("Error reading json data:", err)
	}

	err = json.Unmarshal(data, &output)
	if err != nil {
		log.Println("Error unmarshalling json data:", err)
	}
	return
}

让我们看看输出。

map[string]interface {}{
    "C-3P0": []interface {}{
        "https://swapi.dev/api/films/1/",
        "https://swapi.dev/api/films/2/",
        "https://swapi.dev/api/films/3/",
        "https://swapi.dev/api/films/4/",
        "https://swapi.dev/api/films/5/",
        "https://swapi.dev/api/films/6/",
    },
    "Darth Vader": []interface {}{
        "https://swapi.dev/api/films/1/",
        "https://swapi.dev/api/films/2/",
        "https://swapi.dev/api/films/3/",
        "https://swapi.dev/api/films/6/",
    },
    "Luke Skywalker": []interface {}{
        "https://swapi.dev/api/films/1/",
        "https://swapi.dev/api/films/2/",
        "https://swapi.dev/api/films/3/",
        "https://swapi.dev/api/films/6/",
    },
    "R2D2": []interface {}{
        "https://swapi.dev/api/films/1/",
        "https://swapi.dev/api/films/2/",
        "https://swapi.dev/api/films/3/",
        "https://swapi.dev/api/films/4/",
        "https://swapi.dev/api/films/5/",
        "https://swapi.dev/api/films/6/",
    },
}

让我们尝试将同样的代码应用于早期的卢克·天行者 JSON 数据,并看看输出。

map[string]interface {}{
    "birth_year": "19BBY",
    "created":    "2014-12-09T13:50:51.644000Z",
    "edited":     "2014-12-20T21:17:56.891000Z",
    "eye_color":  "blue",
    "films":      []interface {}{
        "https://swapi.dev/api/films/1/",
        "https://swapi.dev/api/films/2/",
        "https://swapi.dev/api/films/3/",
        "https://swapi.dev/api/films/6/",
    },
    "gender":     "male",
    "hair_color": "blond",
    "height":     "172",
    "homeworld":  "https://swapi.dev/api/planets/1/",
    "mass":       "77",
    "name":       "Luke Skywalker",
    "skin_color": "fair",
    "species":    []interface {}{
    },
    "starships": []interface {}{
        "https://swapi.dev/api/starships/12/",
        "https://swapi.dev/api/starships/22/",
    },
    "url":      "https://swapi.dev/api/people/1/",
    "vehicles": []interface {}{
        "https://swapi.dev/api/vehicles/14/",
        "https://swapi.dev/api/vehicles/30/",
    },
}

您可能认为这比尝试弄清楚结构体要容易和简单得多!而且它更具宽容性和灵活性,为什么不使用这个呢?实际上使用结构体具有其优势。使用空接口基本上使数据结构无类型。结构体可以捕获 JSON 中的错误,而空接口则简单地让其通过。

从结构体中检索数据比从映射中更容易,因为您确切知道哪些字段是可用的。此外,您需要进行类型断言才能从接口中获取数据。例如,假设我们想要获取上面提到的出现达斯·维达的电影,所以您可能认为可以这样做。

unstruct := unstructured()
vader := unstruct["Darth Vader"]
first := vader[0]

你不能 — 你会看到这个错误而不是。

invalid operation: vader[0] (type interface {} does not support indexing)

这是因为变量vader是一个空接口,您必须首先对其进行类型断言,然后才能执行任何操作。

unstruct := unstructured()
vader := unstruct["Darth Vader"].([]interface{})
first := vader[0]

通常情况下,您应该尽量使用结构体,而只有在最后一种情况下才使用映射到空接口。

5.3 将 JSON 数据流解析为结构体

问题

您想从流中解析 JSON 数据。

解决方案

创建结构体以包含 JSON 数据。在encoding/json包中使用NewDecoder创建解码器,然后在解码器上调用Decode将数据解码为结构体。

讨论

对于 JSON 文件或 API 数据,使用Unmarshal简单而直接。但是如果 API 是流式 JSON 数据,会发生什么?在这种情况下,我们不能再使用Unmarshal,因为Unmarshal需要一次性读取整个文件。相反,encoding/json包提供了一个Decoder函数来处理数据。

可能很难理解 JSON 数据和流 JSON 数据之间的区别,所以让我们通过比较两个不同的 JSON 文件来看一下它们之间的区别。

在第一个 JSON 文件中,我们有一个包含 3 个 JSON 对象的数组(我截取了部分数据以便更容易阅读)。

[{
"name": "Luke Skywalker",
"height": "172",
"mass": "77",
"hair_color": "blond",
"skin_color": "fair",
"eye_color": "blue",
"birth_year": "19BBY",
"gender": "male"
},
{
"name": "C-3PO",
"height": "167",
"mass": "75",
"hair_color": "n/a",
"skin_color": "gold",
"eye_color": "yellow",
"birth_year": "112BBY",
"gender": "n/a"
},
{
"name": "R2-D2",
"height": "96",
"mass": "32",
"hair_color": "n/a",
"skin_color": "white, blue",
"eye_color": "red",
"birth_year": "33BBY",
"gender": "n/a"
}]

要读取这个,我们可以使用Unmarshal将其解码为一个Person结构体数组。

func unmarshalStructArray() (people []Person) {
	file, err := os.Open("people.json")
	if err != nil {
		log.Println("Error opening json file:", err)
	}
	defer file.Close()

	data, err := io.ReadAll(file)
	if err != nil {
		log.Println("Error reading json data:", err)
	}

	err = json.Unmarshal(data, &people)
	if err != nil {
		log.Println("Error unmarshalling json data:", err)
	}
	return
}

这将导致如下输出。

[]json.Person{
    {
        Name:      "Luke Skywalker",
        Height:    "172",
        Mass:      "77",
        HairColor: "blond",
        SkinColor: "fair",
        EyeColor:  "blue",
        BirthYear: "19BBY",
        Gender:    "male",
        Homeworld: "",
        Films:     nil,
        Species:   nil,
        Vehicles:  nil,
        Starships: nil,
        Created:   time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
        Edited:    time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
        URL:       "",
    },
    {
        Name:      "C-3PO",
        Height:    "167",
        Mass:      "75",
        HairColor: "n/a",
        SkinColor: "gold",
        EyeColor:  "yellow",
        BirthYear: "112BBY",
        Gender:    "n/a",
        Homeworld: "",
        Films:     nil,
        Species:   nil,
        Vehicles:  nil,
        Starships: nil,
        Created:   time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
        Edited:    time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
        URL:       "",
    },
    {
        Name:      "R2-D2",
        Height:    "96",
        Mass:      "32",
        HairColor: "n/a",
        SkinColor: "white, blue",
        EyeColor:  "red",
        BirthYear: "33BBY",
        Gender:    "n/a",
        Homeworld: "",
        Films:     nil,
        Species:   nil,
        Vehicles:  nil,
        Starships: nil,
        Created:   time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
        Edited:    time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
        URL:       "",
    },
}

这是一个 Person 结构体的数组,在我们解码单个 JSON 数组后得到的结果。但是,当我们得到一系列 JSON 对象的数据流时,这就不再可能了。让我们看看另一个 JSON 文件,这个文件代表了一个 JSON 数据流。

{
"name": "Luke Skywalker",
"height": "172",
"mass": "77",
"hair_color": "blond",
"skin_color": "fair",
"eye_color": "blue",
"birth_year": "19BBY",
"gender": "male"
}
{
"name": "C-3PO",
"height": "167",
"mass": "75",
"hair_color": "n/a",
"skin_color": "gold",
"eye_color": "yellow",
"birth_year": "112BBY",
"gender": "n/a"
}
{
"name": "R2-D2",
"height": "96",
"mass": "32",
"hair_color": "n/a",
"skin_color": "white, blue",
"eye_color": "red",
"birth_year": "33BBY",
"gender": "n/a"
}

请注意,这不是单个 JSON 对象,而是连续的 3 个 JSON 对象。这不再是一个有效的 JSON 文件,而是在读取 http.Response 结构体的 Body 时可能得到的内容。如果尝试使用 Unmarshal 读取这些内容,将会得到一个错误输出。

Error unmarshalling json data: invalid character '{' after top-level value

然而,你可以通过使用 Decoder 来解析它。

func decode(p chan Person) {
	file, err := os.Open("people_stream.json")
	if err != nil {
		log.Println("Error opening json file:", err)
	}
	defer file.Close()

	decoder := json.NewDecoder(file)
	for {
		var person Person
		err = decoder.Decode(&person)
		if err == io.EOF {
			break
		}
		if err != nil {
			log.Println("Error decoding json data:", err)
			break
		}
		p <- person
	}
	close(p)
}

首先,我们使用 json.NewDecoder 创建一个解码器,并将文件作为读取器传递给它。然后在 for 循环中,我们在解码器上调用 Decode,将要存储数据的结构体传递给它。如果一切顺利,每次循环时,都会从数据中创建一个新的 Person 结构体。然后我们可以使用这些数据。如果读取器中没有更多数据了,即我们遇到了 io.EOF,我们将从 for 循环中退出。

在上述代码中,我们传入一个通道,在其中每个循环中存储 Person 结构体。当我们完成读取文件中的所有 JSON 数据后,我们将关闭该通道。

func main() {
	p := make(chan Person)
	go decode(p)
	for {
		person, ok := <-p
		if ok {
            fmt.Printf("%# v\n", pretty.Formatter(person))
		} else {
			break
		}
	}
}

这是上述代码的输出。

json.Person{
    Name:      "Luke Skywalker",
    Height:    "172",
    Mass:      "77",
    HairColor: "blond",
    SkinColor: "fair",
    EyeColor:  "blue",
    BirthYear: "19BBY",
    Gender:    "male",
    Homeworld: "",
    Films:     nil,
    Species:   nil,
    Vehicles:  nil,
    Starships: nil,
    Created:   time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
    Edited:    time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
    URL:       "",
}
json.Person{
    Name:      "C-3PO",
    Height:    "167",
    Mass:      "75",
    HairColor: "n/a",
    SkinColor: "gold",
    EyeColor:  "yellow",
    BirthYear: "112BBY",
    Gender:    "n/a",
    Homeworld: "",
    Films:     nil,
    Species:   nil,
    Vehicles:  nil,
    Starships: nil,
    Created:   time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
    Edited:    time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
    URL:       "",
}
json.Person{
    Name:      "R2-D2",
    Height:    "96",
    Mass:      "32",
    HairColor: "n/a",
    SkinColor: "white, blue",
    EyeColor:  "red",
    BirthYear: "33BBY",
    Gender:    "n/a",
    Homeworld: "",
    Films:     nil,
    Species:   nil,
    Vehicles:  nil,
    Starships: nil,
    Created:   time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
    Edited:    time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
    URL:       "",
}

此处可以看到,这里连续打印了 3 个 Person 结构体,一个接一个地输出,与之前的数组形式不同。

有时候会出现的一个问题是,什么时候应该使用 Unmarshal,什么时候应该使用 Decode

Unmarshal 更适合用于单个 JSON 对象,但当你从读取器中获得一系列 JSON 对象时,它将无法正常工作。此外,它的简单性意味着它不太灵活,你只能一次性获取整个 JSON 数据。

Decode 另一方面,适用于单个 JSON 对象或流式 JSON 数据。此外,使用 Decode,你可以在不需要先完整获取 JSON 数据的情况下,在更细的层次上操作 JSON 数据。这是因为你可以在其传入时检查 JSON,甚至在标记级别上。然而,稍微的缺点是它更冗长。

此外,Decode 速度稍快。让我们对两者进行快速基准测试。

var luke []byte = []byte(
	`{
 "name": "Luke Skywalker",
 "height": "172",
 "mass": "77",
 "hair_color": "blond",
 "skin_color": "fair",
 "eye_color": "blue",
 "birth_year": "19BBY",
 "gender": "male"
}`)

func BenchmarkUnmarshal(b *testing.B) {
	var person Person
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		json.Unmarshal(luke, &person)
	}
}

func BenchmarkDecode(b *testing.B) {
	var person Person
	data := bytes.NewReader(luke)
	decoder := json.NewDecoder(data)
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		decoder.Decode(&person)
		data.Seek(0, 0)
	}
}

这里我们使用标准的 Go 基准测试工具来对 UnmarshalDecode 进行基准测试。为了确保我们进行了正确的基准测试,我们在运行测试 UnmarshalDecode 性能的迭代之前重置计时器。我们在基准测试之前创建解码器,因为我们只需要创建一次解码器,它将包装在流式数据输入的读取器周围。但是因为一旦调用 Decode,我们需要将偏移量移动到下一次基准测试循环的起始位置。

我们在命令行中运行此命令来启动基准测试。

$ go test -bench=. -benchmem

这就是结果。

goos: darwin
goarch: amd64
pkg: github.com/sausheong/gocookbook/ch10_json
cpu: Intel(R) Core(TM) i7-7920HQ CPU @ 3.10GHz
BenchmarkUnmarshal-8   	  437274	  2494 ns/op     272 B/op    12 allocs/op
BenchmarkDecode-8      	  486051	  2368 ns/op      48 B/op     8 allocs/op
PASS
ok  	github.com/sausheong/gocookbook/ch10_json	6.242s

正如你所看到的,Decode 只快了一点点,每次操作花费 2258 纳秒(每操作纳秒),而 Unmarshal 则需花费 2418 纳秒。然而,Decode 每次操作只使用 48 B/op(每次操作字节),比 Unmarshal 的 272 B/op 要少得多。

5.4 从结构体创建 JSON 数据字节数组

问题

您希望从结构体创建 JSON 数据。

解决方案

创建结构体,然后使用 json.Marshal 包将数据编组成 JSON 字节数组。

讨论

创建 JSON 数据本质上是其解析的反向过程:

  1. 创建您将从中编组数据的结构体

  2. 使用 json.Marshaljson.MarshalIndent 将数据编组为 JSON 字符串

我们将重复使用前一个配方中相同的结构体来解析 JSON。我们还将使用用于从 Star Wars API 解析 JSON 数据的函数。

func main() {
	person := get("https://swapi.dev/api/people/14")

	data, err := json.Marshal(&person)
	if err != nil {
		log.Println("Cannot marshal person:", err)
	}
	err = os.WriteFile("han.json", data, 0644)
	if err != nil {
		log.Println("Cannot write to file", err)
	}
}

func get(url string) Person {
	r, err := http.Get(url)
	if err != nil {
		log.Println("Cannot get from URL", err)
	}
	defer r.Body.Close()

	data, err := os.ReadAll(r.Body)
	if err != nil {
		log.Println("Error reading json data:", err)
	}

	var person Person
	json.Unmarshal(data, &person)
	return person
}

get 函数返回一个 Person 结构体,我们可以用它来将数据编组到文件中。json.Marshal 函数将结构体中的数据转换为包含 JSON 字符串的字节数组 data。如果您只希望它作为字符串,可以将其转换为字符串并使用。在这里,我们将其传递给 os.WriteFile 来创建一个新的 JSON 文件。

{"name":"Han Solo","height":"180","mass":"80","hair_color":"brown",
"skin_color":"fair","eye_color":"brown","birth_year":"29BBY","gender":"male",
"homeworld":"https://swapi.dev/api/planets/22/","films":
["https://swapi.dev/api/films/1/","https://swapi.dev/api/films/2/",
"https://swapi.dev/api/films/3/"],"species":[],"vehicles":[],"starships":
["https://swapi.dev/api/starships/10/","https://swapi.dev/api/starships/22/"],
"created":"2014-12-10T16:49:14.582Z","edited":"2014-12-20T21:17:50.334Z",
"url":"https://swapi.dev/api/people/14/"}

这实际上并不是很可读。如果您想要一个更可读的版本,可以改用 json.MarshalIndent。您需要添加两个额外的参数,第一个是前缀,第二个是缩进。通常,如果您想要一个干净的 JSON 输出,前缀就是一个空字符串,而缩进则是一个空格。

data, err := json.MarshalIndent(&person, "", " ")

这将产生一个更可读的版本。

{
 "name": "Han Solo",
 "height": "180",
 "mass": "80",
 "hair_color": "brown",
 "skin_color": "fair",
 "eye_color": "brown",
 "birth_year": "29BBY",
 "gender": "male",
 "homeworld": "https://swapi.dev/api/planets/22/",
 "films": [
  "https://swapi.dev/api/films/1/",
  "https://swapi.dev/api/films/2/",
  "https://swapi.dev/api/films/3/"
 ],
 "species": [],
 "vehicles": [],
 "starships": [
  "https://swapi.dev/api/starships/10/",
  "https://swapi.dev/api/starships/22/"
 ],
 "created": "2014-12-10T16:49:14.582Z",
 "edited": "2014-12-20T21:17:50.334Z",
 "url": "https://swapi.dev/api/people/14/"
}

5.5 从结构体创建 JSON 数据流

问题

您想要从结构体创建流式 JSON 数据。

解决方案

encoding/json 包中使用 NewEncoder 创建一个编码器,传递一个 io.Writer。然后在编码器上调用 Encode 来将结构体数据编码到流中。

讨论

io.Writer 接口有一个 Write 方法,用于向底层数据流写入字节。我们使用 NewEncoder 创建一个围绕写入器的编码器。当我们在编码器上调用 Encode 时,它将将 JSON 结构写入写入器。

为了正确显示这一点,我们需要有一些 JSON 结构体。我将使用与之前相同的 Star Wars 人物 API 来创建这些结构体。

func get(url string) (person Person) {
	r, err := http.Get(r, err := http.Get("https://swapi.dev/api/people/" +
      strconv.Itoa(n)))
	if err != nil {
		log.Println("Cannot get from URL", err)
	}
	defer r.Body.Close()

	data, err := os.ReadAll(r.Body)
	if err != nil {
		log.Println("Error reading json data:", err)
	}

	json.Unmarshal(data, &person)
	return
}

get 函数将调用 API 并返回请求的 Person 结构体。接下来我们将需要使用这个 Person 结构体。

func main() {
	encoder := json.NewEncoder(os.Stdout)
	for i := 1; i < 4; i++ { // we're just retrieving 3 records
		person := get(i)
		encoder.Encode(person)
	}
}

正如您所看到的,我们将 os.Stdout 用作写入器。实际上,os.Stdout 是一个 os.File 结构体,但 File 也是一个写入器,所以这没问题。它的作用是一次将编码写入 os.Stdout。首先,我们使用 json.NewEncoder 创建一个编码器,将 os.Stdout 作为写入器传递。接下来,在循环中获取一个 Person 结构体,并将其传递给 Encode 以写入 os.Stdout

当你运行程序时,应该会看到类似这样的东西,但每个 JSON 编码将依次出现。

{"name":"Luke Skywalker","height":"172","mass":"77","hair_color":"blond",
"skin_color":"fair","eye_color":"blue","birth_year":"19BBY","gender":"male",
"homeworld":"https://swapi.dev/api/planets/1/","films":
["https://swapi.dev/api/films/1/","https://swapi.dev/api/films/2/",
"https://swapi.dev/api/films/3/","https://swapi.dev/api/films/6/"],
"species":[],"vehicles":["https://swapi.dev/api/vehicles/14/",
"https://swapi.dev/api/vehicles/30/"],"starships":
["https://swapi.dev/api/starships/12/","https://swapi.dev/api/starships/22/"],
"created":"2014-12-09T13:50:51.644Z","edited":"2014-12-20T21:17:56.891Z",
"url":"https://swapi.dev/api/people/1/"}
{"name":"C-3PO","height":"167","mass":"75","hair_color":"n/a","skin_color":
"gold","eye_color":"yellow","birth_year":"112BBY","gender":"n/a","homeworld":
"https://swapi.dev/api/planets/1/","films":["https://swapi.dev/api/films/1/",
"https://swapi.dev/api/films/2/","https://swapi.dev/api/films/3/",
"https://swapi.dev/api/films/4/","https://swapi.dev/api/films/5/",
"https://swapi.dev/api/films/6/"],"species":["https://swapi.dev/api/species/2/"],
"vehicles":[],"starships":[],"created":"2014-12-10T15:10:51.357Z","edited":
"2014-12-20T21:17:50.309Z","url":"https://swapi.dev/api/people/2/"}
{"name":"R2-D2","height":"96","mass":"32","hair_color":"n/a","skin_color":
"white, blue","eye_color":"red","birth_year":"33BBY","gender":"n/a",
"homeworld":"https://swapi.dev/api/planets/8/","films":
["https://swapi.dev/api/films/1/","https://swapi.dev/api/films/2/",
"https://swapi.dev/api/films/3/","https://swapi.dev/api/films/4/",
"https://swapi.dev/api/films/5/","https://swapi.dev/api/films/6/"],
"species":["https://swapi.dev/api/species/2/"],"vehicles":[],"starships":[],
"created":"2014-12-10T15:11:50.376Z","edited":"2014-12-20T21:17:50.311Z",
"url":"https://swapi.dev/api/people/3/"}

如果您对此处的凌乱输出感到恼火,并且想知道是否有类似于MarshalIndent的等效方法,答案是肯定的。只需像这样设置编码器,使用SetIndent,您就可以开始了。

encoder.SetIndent("", " ")

如果您想知道这与Marshal的区别是什么——使用Marshal您无法做到以上的操作。要使用Marshal,您需要将所有内容放入一个对象中,并一次性将其全部编组为 JSON — 您无法逐个流式传输 JSON 编码。

换句话说,如果有 JSON 结构数据传入,您不知道全部数据什么时候会完全传入,或者如果您想要先写出 JSON 编码,那么您需要使用Encode。只有当您拥有所有 JSON 数据时,才能使用Marshal

当然,Encode也比Marshal快。让我们再来看看一些基准测试。

var jsonBytes []byte = []byte(jsonString)
var person Person

func BenchmarkMarshal(b *testing.B) {
    json.Unmarshal(jsonBytes, &person)
    b.ResetTimer()
	for i := 0; i < b.N; i++ {
		data, _ := json.Marshal(person)
		io.Discard.Write(data)
	}
}

func BenchmarkEncoder(b *testing.B) {
	json.Unmarshal(jsonBytes, &person)
	b.ResetTimer()
    encoder := json.NewEncoder(io.Discard)
	for i := 0; i < b.N; i++ {
		encoder.Encode(person)
	}
}

在测试之前,我们需要准备好 JSON 结构,因此我在运行基准测试循环之前将数据解组为Person结构。我还使用io.Discard作为写入器。io.Discard是一个写入器,其所有写入调用都将成功,并且在这里使用是最方便的。

要对Marshal进行基准测试,我将Person结构编组为 JSON,然后将其写入io.Discard。要对Encode进行基准测试,我创建了一个编码器,它包裹在io.Discard周围,然后将Person结构编码到其中。与解码器的基准测试一样,我在迭代之前放置了编码器的创建,因为我们只需要创建一次。

这是基准测试的结果。

goos: darwin
goarch: amd64
pkg: github.com/sausheong/go-recipes/io/json
cpu: Intel(R) Core(TM) i7-7920HQ CPU @ 3.10GHz
BenchmarkMarshal-8   	 1983175     614.6 ns/op     288 B/op     2 allocs/op
BenchmarkEncoder-8   	 2284209     500.3 ns/op     128 B/op     1 allocs/op
PASS
ok  	github.com/sausheong/go-recipes/io/json	3.852s

与以前一样,Encode更快,而且内存使用更少,大约为 128 B/op,而Marshal则为 288 B/op。

5.6 在结构体中省略字段

问题

当将 JSON 结构编组为 JSON 编码时,有时候某些结构变量根本就没有数据。我们希望创建的 JSON 编码会省略掉那些没有任何数据的变量。

解决方案

使用omitempty标签来定义在编组时可以省略的结构变量。

讨论

让我们再来看看Person结构。

type Person struct {
	Name      string        `json:"name"`
	Height    string        `json:"height"`
	Mass      string        `json:"mass"`
	HairColor string        `json:"hair_color"`
	SkinColor string        `json:"skin_color"`
	EyeColor  string        `json:"eye_color"`
	BirthYear string        `json:"birth_year"`
	Gender    string        `json:"gender"`
	Homeworld string        `json:"homeworld"`
	Films     []string      `json:"films"`
	Species   []string      `json:"species"`
	Vehicles  []string      `json:"vehicles"`
	Starships []string      `json:"starships"`
	Created   time.Time     `json:"created"`
	Edited    time.Time     `json:"edited"`
	URL       string        `json:"url"`
}

您可能会注意到,当人物是人类时,API 没有指定物种,也有许多人物没有交通工具或星际飞船的标签。因此,当我们将结构编组时,它将作为空数组输出。

{
 "name": "Owen Lars",
 "height": "178",
 "mass": "120",
 "hair_color": "brown, grey",
 "skin_color": "light",
 "eye_color": "blue",
 "birth_year": "52BBY",
 "gender": "male",
 "homeworld": "https://swapi.dev/api/planets/1/",
 "films": [
  "https://swapi.dev/api/films/1/",
  "https://swapi.dev/api/films/5/",
  "https://swapi.dev/api/films/6/"
 ],
 "species": [],
 "vehicles": [],
 "starships": [],
 "created": "2014-12-10T15:52:14.024Z",
 "edited": "2014-12-20T21:17:50.317Z",
 "url": "https://swapi.dev/api/people/6/"
}

如果您不想显示物种、交通工具或星际飞船,您可以在 JSON 结构标签上使用omitempty标签。

Species   []string      `json:"species,omitempty"`
Vehicles  []string      `json:"vehicles,omitempty"`
Starships []string      `json:"starships,omitempty"`

当您再次运行相同的代码时,输出中将不再有它们。

{
 "name": "Owen Lars",
 "height": "178",
 "mass": "120",
 "hair_color": "brown, grey",
 "skin_color": "light",
 "eye_color": "blue",
 "birth_year": "52BBY",
 "gender": "male",
 "homeworld": "https://swapi.dev/api/planets/1/",
 "films": [
  "https://swapi.dev/api/films/1/",
  "https://swapi.dev/api/films/5/",
  "https://swapi.dev/api/films/6/"
 ],
 "created": "2014-12-10T15:52:14.024Z",
 "edited": "2014-12-20T21:17:50.317Z",
 "url": "https://swapi.dev/api/people/6/"
}

您为什么要这样做呢?在此 API 中,身高和体重是字符串。但是,如果它们是整数,并且您不知道它们的身高或体重,那么默认值将是 0。在这种情况下,这些值是错误的,有时可能是正确的(例如,绝地武士力量的幽灵既没有身高也没有体重),但您无法分辨哪个是正确的。在这种情况下,根本不显示它可能是更好的选择。

第六章:数据结构实例

6.0 简介

Go 语言有 4 种基本的数据结构 — 数组、切片、映射和结构体。我们单独有一章讨论结构体,所以我们将分开讨论它。在本章中,我们只讨论数组、切片和映射。我们将先讲解它们的背景信息,然后再深入讨论它们的具体用法。

数组

数组是表示相同类型元素的有序序列的数据结构。数组的大小是静态的,在定义数组时设置,之后无法更改。数组是值类型。这是一个重要的区别,因为在某些语言中,数组类似于指向数组中第一个项的指针。这意味着如果我们将数组传递给函数,我们将传递数组的副本,这可能会很昂贵。

切片

切片也是表示有序元素序列的数据结构。事实上,切片是建立在数组之上的,并且由于其灵活性,比数组更常用。切片没有固定的长度。在内部,切片是一个结构体,包含一个指向数组的指针,数组段的长度以及底层数组的容量。

映射

映射是一种将一个类型的值(称为)与另一个类型的值(称为)相关联的数据结构。这样的数据结构在许多其他编程语言中很常见,有不同的名称,如哈希表、哈希映射和字典。在内部,映射是一个指向runtime.hmap结构的指针。

理解这 3 种数据结构很重要,它们是所有其他数据结构的基础构建模块,但它们彼此之间也有根本性的不同。简而言之,数组是固定长度的有序列表,是值类型。切片是一个结构体,其第一个元素是指向数组的指针。映射是指向内部哈希映射结构的指针。

6.1 创建数组或切片

问题

你想要创建数组或切片。

解决方案

创建数组或切片的方法有很多,包括直接使用文字、从另一个数组创建,或使用make函数。

讨论

数组和切片在概念上有很大的差异,但在概念上它们非常相似。因此,创建数组和切片也非常相似。

定义数组

你可以通过在方括号中声明数组的大小,然后跟着元素的数据类型来定义数组。数组和切片只能包含相同类型的元素。你也可以在声明时用花括号初始化数组。

var numbers [10]int
fmt.Println(numbers)
rhyme := [4]string{"twinkle", "twinkle", "little", "star"}
fmt.Println(rhyme)

如果你运行上面的代码片段,你将会看到这个结果。

[0 0 0 0 0 0 0 0 0 0]
[twinkle twinkle little star]

int 或 float 数组的默认值为 0。请注意,一旦创建数组,数组的大小就不能再改变,但元素可以改变。

定义切片

切片是建立在数组之上的构造。大多数情况下,当需要处理有序列表时,通常会使用切片,因为它们更灵活,而且如果底层数组很大,使用起来也更便宜。

切片的定义方式完全相同,只是不提供切片的大小。

var integers []int
fmt.Println(integers)
var sheep = []string{"baa", "baa", "black", "sheep"}
fmt.Println(sheep)

如果你运行上述代码片段,你将看到这个。

[]
[baa baa black sheep]

我们还可以通过make函数创建切片。

var integers = make([]int, 10)
fmt.Println(integers)

如果使用make,需要提供类型、长度和可选的容量。如果不提供容量,默认为给定的长度。如果你运行上述片段,你将看到这个。

[0 0 0 0 0 0 0 0 0 0]

正如你所看到的,make也初始化了切片。

要找出数组或切片的长度,可以使用len函数。要找出数组或切片的容量,可以使用cap函数。

integers = make([]int, 10, 15)
fmt.Println(integers)
fmt.Println("length:", len(integers))
fmt.Println("capacity:", cap(integers))

上面的make函数分配了一个包含 15 个整数的数组,然后创建了一个长度为 10、容量为 15 的切片,指向数组的前 10 个元素。

如果你运行上述代码,这就是你会得到的结果。

[0 0 0 0 0 0 0 0 0 0]
length: 10
capacity: 15

我们还可以用new方法创建新的切片。

var ints *[]int = new([]int)
fmt.Println(ints)

new方法不直接返回切片,它只返回一个指向切片的指针。它也不初始化切片,而是将其置零。运行上述代码时,你会看到得到什么。

&[]

我们不能使用make函数创建新数组,但可以使用new函数创建新数组。

var ints *[10]int = new([10]int)
fmt.Println(ints)

我们得到的是一个指向数组的指针如下。

&[0 0 0 0 0 0 0 0 0 0]

6.2 访问数组或切片

问题

你想访问数组或切片中的元素。

解决方案

有几种方法可以访问数组或切片中的元素。数组和切片是有序列表,因此可以通过它们的索引访问元素。可以通过单个索引或索引范围访问元素。还可以通过迭代元素访问它们。

讨论

访问数组和切片几乎是相同的。由于它们是有序列表,我们可以通过索引访问数组或切片的元素。

numbers := []int{3, 14, 159, 26, 53, 59}

给定上面的切片,给定索引 3(从 0 开始),切片中的第 4 个元素是 25,并可以使用变量名,后跟方括号和方括号内的索引访问。

numbers[3] // 26

我们还可以通过使用起始索引,后跟冒号:和结束索引来访问一系列数字。结束索引不包括在内,这导致一个切片(当然)。

numbers[2:4] // [159, 26]

如果没有起始索引,切片将从 0 开始。

numbers[:4] // [3 14 159 26]

如果没有结束索引,切片将以原始切片(或数组)的最后一个元素结束。

numbers[2:] // [159 265 53 59]

毫无疑问,如果你没有起始索引或结束索引,将返回整个原始切片。虽然这听起来很愚蠢,但这确实有其有效用途——它简单地将一个数组转换为切片。

numbers := [6]int{3, 14, 159, 26, 53, 59} // an array
numbers[:] // this is a slice

我们还可以通过迭代数组或切片来访问数组或切片中的元素。

for i := 0; i < len(numbers); i++ {
    fmt.Println(numbers[i])
}

这使用了一个普通的for循环,迭代切片的长度,每次循环增加计数。得到的输出如下。

3
14
159
25
53
59

这使用了一个for …​ range循环,并返回索引i和值v

for i, v := range numbers {
    fmt.Printf("i: %d, v: %d\n", i, v)
}

得到的输出如下

i: 0, v: 3
i: 1, v: 14
i: 2, v: 159
i: 3, v: 25
i: 4, v: 53
i: 5, v: 59

6.3 修改数组或切片

问题

您想要在数组或切片中添加、插入或删除元素。

解决方案

有几种方法可以修改数组或切片中的元素。元素可以附加到切片的末尾,插入到特定索引位置,删除或修改。

讨论

除了访问数组或切片中的元素外,您还可能希望在切片中添加、修改或删除元素。虽然无法在数组中添加或删除元素,但您始终可以修改其元素。

numbers := []int{3, 14, 159, 26, 53, 58}
numbers[2] = 1000

当您修改给定索引处的元素时,它将相应地更改数组或切片。在这种情况下,当您运行代码时,将得到这个结果。

[3 14 1000 26 53 58 97]

附加

数组无法改变其大小,因此无法向数组附加或添加元素。但是向切片附加元素非常简单。我们只需使用append函数,将其传递给切片和新元素,我们将得到一个具有附加元素的新切片。

numbers := []int{3, 14, 159, 26, 53, 58}
numbers = append(numbers, 97)
fmt.Println(numbers)

如果您运行上述代码,您将得到这个结果。

[3 14 159 26 53 58 97]

您不能将不同类型的元素附加到切片中。但是您可以向切片附加多个项目。

numbers = append(numbers, 97, 932, 38, 4, 626)

这意味着您实际上可以使用切片解包符号…​将一个切片(或数组)附加到另一个切片中。

nums := []int{97, 932, 38, 4, 626}
numbers = append(numbers, nums...)

但是同时附加一个元素和一个解包的切片是不允许的。您只能选择附加多个元素或一个解包的切片,但不能同时进行。

numbers = append(numbers, 1, nums...) // this will produce an error

插入

虽然附加会向切片的末尾添加一个元素,但插入意味着在切片中的元素之间的任何位置添加一个元素。再次强调,这仅适用于切片,因为数组的大小是固定的。

append不同,插入没有内置函数,但我们仍然可以使用append来完成任务。假设我们想在索引 2 和 3 之间的元素之间插入数字 1000。

numbers := []int{3, 14, 159, 26, 53, 58}
numbers = append(numbers[:2+1], numbers[2:]...)
numbers[2] = 1000

首先,我们需要从原始切片的开头创建一个切片,到索引 2 加 1。这将为我们希望添加的新元素保留空间。接下来,我们将这个切片附加到另一个从索引 2 到原始切片末尾的切片中,使用解包符号。通过这种方式,我们在索引 2 和 3 之间创建了一个新元素。最后,我们在索引 2 处设置新元素 1000。

结果我们将得到这个新的切片。

[3 14 1000 159 26 53 58]

如果我们想要在切片的开头添加一个元素怎么办?假设我们想要将整数 2000 添加到切片的开头。这很简单,我们只需将值以切片的形式附加到原始切片的解包值中。

numbers = append([]int{2000}, numbers...)

这是在插入单个元素时的情况。如果我们想在另一个切片中间插入另一个切片怎么办?假设我们想在我们的numbers切片中间插入切片[]int{1000, 2000, 3000, 4000}

有几种方法可以做到这一点,但我们将坚持使用append,这是最简洁的方法之一。

numbers = []int{3, 14, 159, 26, 53, 58}
inserted := []int{1000, 2000, 3000, 4000}

tail := append([]int{}, numbers[2:]...)
numbers = append(numbers[:2], inserted...)
numbers = append(numbers, tail...)

fmt.Println(numbers)

首先,我们需要创建另一个切片,tail,来存储原始切片的部分。我们不能简单地对其进行切片并存储到另一个变量中(这称为浅复制),因为请记住——切片不是数组,它们是数组的一部分和其长度的指针。如果我们对numbers进行切片并存储到tail中,当我们改变numbers时,tail也会改变,这不是我们想要的。相反,我们希望通过将其附加到一个空的int切片来创建一个新的切片。

现在我们已经将tail放在一边,我们将numbers的头部附加到未打包的inserted中。最后,我们附加numbers(现在由原始切片的头部和inserted组成)和tail。这是我们应该得到的结果。

[3 14 1000 2000 3000 4000 159 26 53 58]

删除

从切片中删除元素非常容易。如果它位于切片的开头或结尾,您只需相应地重新切片即可删除切片的开头或结尾。

让我们先取出切片的第一个元素。

numbers := []int{3, 14, 159, 26, 53, 58}
numbers = numbers[1:] // remove element 0
fmt.Println(numbers)

当您运行上述代码时,您将得到这个结果。

[14 159 26 53 58]

现在让我们先取出切片的最后一个元素。

numbers := []int{3, 14, 159, 26, 53, 58}
numbers = numbers[:len(numbers)-1] // remove last element
fmt.Println(numbers)

当您运行上述代码时,您将得到这个结果。

[3 14 159 26 53]

中间删除元素也很简单。您只需将原始切片的头部与原始切片的尾部附加在一起,移除其中的任何内容。在这种情况下,我们想要删除索引为 2 的元素。

numbers := []int{3, 14, 159, 26, 53, 58}
numbers = append(numbers[:2], numbers[3:]...)
fmt.Println(numbers)

当我们运行上述代码时,我们得到了这个结果。

[3 14 26 53 58]

6.4 使数组和切片在并发使用时安全

问题

您希望通过多个 goroutine 安全地使用数组和切片。

解决方案

使用sync库中的互斥体(mutex)来保护数组或切片。在修改数组或切片之前对其进行锁定,并在修改完成后解锁。

讨论

数组和切片在并发使用时不安全。如果要在多个 goroutine 之间共享一个切片或数组,则需要使其免受竞争条件的影响。Go 语言提供了一个sync包,特别是Mutex

首先看一下竞争条件是如何产生的。竞争条件发生在多个 goroutine 试图同时访问共享资源时。

var shared []int = []int{1, 2, 3, 4, 5, 6}

// increase each element by 1
func increase(num int) {
	fmt.Printf("[+%d a] : %v\n", num, shared)
	for i := 0; i < len(shared); i++ {
		time.Sleep(20 * time.Microsecond)
		shared[i] = shared[i] + 1
	}
	fmt.Printf("[+%d b] : %v\n", num, shared)
}

// decrease each element by 1
func decrease(num int) {
	fmt.Printf("[-%d a] : %v\n", num, shared)
	for i := 0; i < len(shared); i++ {
		time.Sleep(10 * time.Microsecond)
		shared[i] = shared[i] - 1
	}
	fmt.Printf("[-%d b] : %v\n", num, shared)
}

在上面的示例中,我们有一个名为shared的整数切片,被两个名为increasedecrease的函数使用。这两个函数简单地逐个获取共享切片中的元素并分别增加或减少 1。但在增加或减少元素之前,我们等待了一个非常短的时间,其中increase函数等待的时间更长。这模拟了多个 goroutine 之间时间差异的情况。我们在修改共享元素之前打印出shared切片的状态,并在修改后再次打印出来以展示其状态变化。

我们从+main调用increasedecrease函数,并且每次函数调用都作为一个单独的 goroutine。程序结束时,我们稍等片刻以确保所有的 goroutine 都完成(否则所有的 goroutine 将在程序结束时结束)。

func main() {
	for i := 0; i < 5; i++ {
		go increase(i)
	}
	for i := 0; i < 5; i++ {
		go decrease(i)
	}
	time.Sleep(2 * time.Second)
}

当我们运行程序时,你会看到类似这样的输出。

[-4 a] : [1 2 3 4 5 6]
[-1 a] : [0 2 3 4 5 6]
[-2 a] : [0 1 3 4 5 6]
[-3 a] : [0 1 2 4 5 6]
[+0 a] : [-2 1 2 3 5 6]
[+1 a] : [-3 -1 2 3 4 6]
[-4 b] : [-2 -2 1 3 4 5]
[+3 a] : [-2 -2 0 3 4 5]
[+4 a] : [-1 -1 -1 1 4 5]
[-1 b] : [1 0 0 0 1 4]
[-2 b] : [1 0 0 0 1 3]
[-3 b] : [1 0 0 0 1 2]
[+2 a] : [1 0 0 0 1 2]
[-0 a] : [2 2 1 1 1 2]
[+0 b] : [1 2 3 2 1 3]
[-0 b] : [1 2 3 3 2 2]
[+1 b] : [1 2 3 4 4 3]
[+3 b] : [1 2 3 4 4 4]
[+4 b] : [1 2 3 4 4 5]
[+2 b] : [1 2 3 4 5 6]

如果多次运行它,每次的结果可能会略有不同。你会注意到即使我们按顺序启动 goroutine(每次将顺序号发送给modify),实际执行的顺序是随机的,这是预期的行为。但我们不希望看到的是 goroutine 彼此重叠,共享切片根据哪个 goroutine 先访问而递增或递减。

例如,如果我们查看输出的第一行[-4 a] : [1 2 3 4 5 6],会发现在调用减少每个元素的循环之前打印出来了。然后,在循环之后打印的行是[-4 b] : [-2 -2 1 3 4 5],可以看到前 3 个元素并不符合预期!

同样,你会意识到即使在增加或减少元素的循环内部,重叠也会发生。

如何防止这种竞争条件?Go 语言在标准库中提供了sync包,它为我们提供了mutex或互斥锁。

var shared []int = []int{1, 2, 3, 4, 5, 6}
var mutex sync.Mutex

// increase each element by 1
func increaseWithMutex(num int) {
	mutex.Lock()
	fmt.Printf("[+%d a] : %v\n", num, shared)
	for i := 0; i < len(shared); i++ {
		time.Sleep(20 * time.Microsecond)
		shared[i] = shared[i] + 1
	}
	fmt.Printf("[+%d b] : %v\n", num, shared)
	mutex.Unlock()
}

// decrease each element by 1
func decreaseWithMutex(num int) {
	mutex.Lock()
	fmt.Printf("[-%d a] : %v\n", num, shared)
	for i := 0; i < len(shared); i++ {
		time.Sleep(10 * time.Microsecond)
		shared[i] = shared[i] - 1
	}
	fmt.Printf("[-%d b] : %v\n", num, shared)
	mutex.Unlock()
}
}
Using it is quite simple. Firstly we need to declare a mutex. Then, we call +Lock+ on the mutex before we start modifying the shared slice. This will lock up the shared slice such that nothing else can use it. When we're done, we call +Unlock+ to unlock the mutex.

如果像以前一样从main中调用这些函数,这里是输出结果。

[-4 a] : [1 2 3 4 5 6]
[-4 b] : [0 1 2 3 4 5]
[+0 a] : [0 1 2 3 4 5]
[+0 b] : [1 2 3 4 5 6]
[+1 a] : [1 2 3 4 5 6]
[+1 b] : [2 3 4 5 6 7]
[+2 a] : [2 3 4 5 6 7]
[+2 b] : [3 4 5 6 7 8]
[+3 a] : [3 4 5 6 7 8]
[+3 b] : [4 5 6 7 8 9]
[+4 a] : [4 5 6 7 8 9]
[+4 b] : [5 6 7 8 9 10]
[-0 a] : [5 6 7 8 9 10]
[-0 b] : [4 5 6 7 8 9]
[-1 a] : [4 5 6 7 8 9]
[-1 b] : [3 4 5 6 7 8]
[-2 a] : [3 4 5 6 7 8]
[-2 b] : [2 3 4 5 6 7]
[-3 a] : [2 3 4 5 6 7]
[-3 b] : [1 2 3 4 5 6]

结果更加有条理。goroutine 不再重叠,元素的增加和减少有序而一致。

6.5 对切片数组进行排序

问题

你想要对数组或切片中的元素进行排序。

解决方案

对于intfloat64string数组或切片,你可以使用sort.Intssort.Float64ssort.Strings。你也可以通过使用sort.Slice来使用自定义比较器。对于结构体,你可以通过实现sort.Interface接口来创建一个可排序的接口,然后使用sort.Sort来对数组或切片进行排序。

讨论

数组和切片是有序元素序列。然而,这并不意味着它们以任何方式排序,只是表示元素始终以相同的顺序排列。要对数组或切片进行排序,我们可以使用sort包中的各种函数。

对于intfloat64string,我们可以使用相应的sort.Intssort.Float64ssort.Strings函数。

integers := []int{3, 14, 159, 26, 53}
floats := []float64{3.14, 1.41, 1.73, 2.72, 4.53}
strings := []string{"the", "quick", "brown", "fox", "jumped"}

sort.Ints(integers)
sort.Float64s(floats)
sort.Strings(strings)

fmt.Println(integers)
fmt.Println(floats)
fmt.Println(strings)

如果我们运行上述代码,这就是我们将看到的。

[3 14 26 53 159]
[1.41 1.73 2.72 3.14 4.53]
[brown fox jumped quick the]

这是按升序排序的。如果我们想要按降序排序怎么办?目前没有现成的函数可以按降序排序,但我们可以简单地使用一个 for 循环来反转排序后的切片。

for i := len(integers)/2 - 1; i >= 0; i-- {
    opp := len(integers) - 1 - i
    integers[i], integers[opp] = integers[opp], integers[i]
}

fmt.Println(integers)

我们简单地找到切片的中间部分,然后使用循环,从中间开始,将元素与它们的对侧交换。如果我们运行上面的片段,你将会得到这样的结果。

[159 53 26 14 3]

我们还可以使用 sort.Slice 函数,传入我们自己的 less 函数。

sort.Slice(floats, func(i, j int) bool {
    return floats[i] > floats[j]
})
fmt.Println(floats)

这将产生输出。

[4.53 3.14 2.72 1.73 1.41]

less 函数是 sort.Slice 函数的第二个参数,接受两个参数 ij,是切片连续元素的索引。它的作用是在排序时,如果 i 处的元素小于 j 处的元素则返回 true。

如果元素相同怎么办?使用 sort.Slice 意味着元素的顺序可能与它们的原始顺序相反(或保持不变)。如果希望顺序始终与原始顺序一致,可以使用 sort.SliceStable

sort.Slice 函数可以处理任何类型的切片,这意味着你也可以对自定义结构体进行排序。

people := []Person{
	{"Alice", 22},
	{"Bob", 18},
	{"Charlie", 23},
	{"Dave", 27},
	{"Eve", 31},
}
sort.Slice(people, func(i, j int) bool {
	return people[i].Age < people[j].Age
})
fmt.Println(people)

如果你运行上述代码,你将会看到下面的输出,people 切片按照人们的年龄排序。

[{Bob 18} {Alice 22} {Charlie 23} {Dave 27} {Eve 31}]

另一种排序结构体的方法是实现 sort.Interface。让我们看看如何为 Person 结构体做这个操作。

type Person struct {
	Name string
	Age  int
}

type ByAge []Person

func (a ByAge) Len() int           { return len(a) }
func (a ByAge) Less(i, j int) bool { return a[i].Age < a[j].Age }
func (a ByAge) Swap(i, j int)      { a[i], a[j] = a[j], a[i] }

我们想要对结构体切片进行排序,所以我们需要将接口函数关联到切片,而不是结构体。我们创建一个名为 ByAge 的类型,它是 Person 结构体的切片。接下来,我们将 LenLessSwap 函数关联到 ByAge,使其成为一个实现了 sort.Interface 的结构体。这里的 Less 方法与我们在上面 sort.Slice 函数中使用的方法相同。

使用这个非常简单。我们将 people 强制转换为 ByAge,然后将其传入 sort.Sort

people := []Person{
	{"Alice", 22},
	{"Bob", 18},
	{"Charlie", 23},
	{"Dave", 27},
	{"Eve", 31},
}

sort.Sort(ByAge(people))
fmt.Println(people)

如果你运行上面的代码,你将会看到与下面相同的结果。

[{Bob 18} {Alice 22} {Charlie 23} {Dave 27} {Eve 31}]

实现 sort.Interface 有点冗长,但显然有一些优势。首先,我们可以使用 sort.Reverse 按降序排序。

sort.Sort(sort.Reverse(ByAge(people)))
fmt.Println(people)

这将产生以下输出。

[{Eve 31} {Dave 27} {Charlie 23} {Alice 22} {Bob 18}]

你还可以使用 sort.IsSorted 函数来检查切片是否已经排序。

sort.IsSorted(ByAge(people)) // true if it's sorted

最大的优势在于,使用 sort.Interface 比使用 sort.Slice 更高效。让我们做一个简单的基准测试。

func BenchmarkSortSlice(b *testing.B) {
	f := func(i, j int) bool {
		return people[i].Age < people[j].Age
	}
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		sort.Slice(people, f)
	}
}

func BenchmarkSortInterface(b *testing.B) {
	for i := 0; i < b.N; i++ {
		sort.Sort(ByAge(people))
	}
}

这是基准测试的结果。

$ go test -bench=BenchmarkSort
goos: darwin
goarch: arm64
pkg: github.com/sausheong/gocookbook/ch14_data_structures
BenchmarkSortSlice-10     	 9376766	       108.9 ns/op
BenchmarkSortInterface-10  	26790697	        44.33 ns/op
PASS
ok  	github.com/sausheong/gocookbook/ch14_data_structures	2.901s

正如你所见,使用 sort.Interface 更有效率。这是因为 sort.Slice 使用 interface{} 作为第一个参数。这意味着它可以接受任何结构体但效率较低。

6.6 创建地图

问题

你想创建新的地图。

解决方案

使用 map 关键字声明它,然后使用 make 函数进行初始化。在使用之前必须先初始化地图。

讨论

要创建地图,我们可以使用 map 关键字。

var people map[string]int

上面的片段声明了一个名为people的映射,将一个字符串键映射到一个整数值。目前不能使用people映射,因为它的零值是 nil。要使用它,我们需要用make方法初始化它。

people = make(map[string]int)

如果你觉得在声明和初始化中都要重复使用map[string]int看起来有些傻,你应该考虑同时做这两件事。

people := make(map[string]int)

这将创建一个空映射。要填充映射,你可以将一个字符串映射到一个整数。

people["Alice"] = 22

你也可以用这种方式初始化映射。

people := map[string]int{
	"Alice":   22,
	"Bob":     18,
	"Charlie": 23,
	"Dave":    27,
	"Eve":     31,
}

如果你打印映射,它将如此显示。

map[Alice:22 Bob:18 Charlie:23 Dave:27 Eve:31]

6.7 访问映射

问题

你想要访问映射中的键和值。

解决方案

使用方括号内的键来访问映射中的值。你也可以使用for …​ range循环来遍历映射。

讨论

访问给定键的值是直截了当的。只需使用方括号内的键来访问值。

people := map[string]int{
	"Alice":   22,
	"Bob":     18,
	"Charlie": 23,
	"Dave":    27,
	"Eve":     31,
}

people["Alice"] // 22

如果键不存在会怎么样?什么都不会发生,Go 会简单地返回值类型的零值。在我们的例子中,整数的零值是 0,所以如果我们这样做。

people["Nemo"] // 0

它将简单地返回 0。这可能不是我们要找的(特别是如果 0 是一个有效的响应),因此有一个机制可以检查键是否存在。

age, ok := people["Nemo"]
if ok {
	// do whatever you want if the value exists
}

逗号, ok模式在许多情况下都很常见,也可以用来检查映射中是否存在键。使用方式很明显,如果键存在,ok就会变成 true,否则就是 false。尽管ok不是关键字,你可以使用任何变量名,它使用了多值赋值。尽管值仍然被返回,但由于你知道键不存在并且它只是一个零值,你可能不会使用它。

我们还可以使用for …​ range循环来遍历映射,就像我们处理数组和切片时所做的那样,不同之处在于,这里我们获取键和值。

for k, v := range people {
	fmt.Println(k, v)
}

运行上面的代码将给我们这个输出。

Alice 22
Bob 18
Charlie 23
Dave 27
Eve 31

如果只想要键,你可以省略从 range 中获取的第二个值。

for k := range people {
	fmt.Println(k)
}

你将得到这个输出。

Alice
Bob
Charlie
Dave
Eve

如果我们只想要值怎么办?没有特别的方法可以直接获取值,你必须使用相同的机制,并将它们放在一个切片中。

var values []int
for _, v := range people {
	values = append(values, v)
}
fmt.Println(values)

你将得到这个输出。

[22 18 23 27 31]

6.8 修改映射

问题

你想要修改或删除映射中的元素。

解决方案

使用delete函数从映射中删除键值对。要修改值,只需重新赋值。

讨论

修改值就是简单地覆盖现有的值。

people["Alice"] = 23

people["Alice"]的值将变成 23。

要删除键,Go 提供了一个名为delete的内置函数。

delete(people, "Alice")
fmt.Println(people)

这将是输出结果。

map[Bob:18 Charlie:23 Dave:27 Eve:31]

如果尝试删除一个不存在的键会发生什么?什么都不会发生。

6.9 排序映射

问题

你想要按键排序映射。

解决方案

将映射的键获取到一个切片中并对该切片进行排序。然后使用排序后的键切片,再次遍历映射。

讨论

映射是无序的。这意味着每次遍历映射时,键值对的顺序可能与上次不同。那么我们如何确保每次都是相同的顺序呢?

首先,我们将键提取到一个切片中。

var keys []string
for k := range people {
	keys = append(keys, k)
}

然后,我们根据需要对键进行排序。在这种情况下,我们要按照降序排序。

// sort keys by descending order
for i := len(keys)/2 - 1; i >= 0; i-- {
	opp := len(keys) - 1 - i
	keys[i], keys[opp] = keys[opp], keys[i]
}

最后,我们可以按键的降序访问映射。

for _, key := range keys {
	fmt.Println(key, people[key])
}

运行代码时,我们将看到这个结果。

Eve 31
Dave 27
Charlie 23
Bob 18
Alice 22

作者简介

张守祥在软件开发行业已超过 27 年,并参与了多个行业的软件产品构建,使用了多种技术。他是 Java、Ruby 社区的活跃成员,现在主要专注于 Go 语言,在全球各地的会议上组织和发表演讲。他还主办着 GopherCon 新加坡,这是东南亚最大的社区主导的开发者大会之一,自 2017 年以来一直如此。张守祥已经写了 4 本编程书籍,其中 3 本是关于 Ruby,最后一本是关于 Go 的。