Go 语言简单、高效,具备很强的语言表达能力,支持静态类型安全,同时提供动态语言的特性。不仅如此,它还支持自动垃圾回收,能够有效防止内存泄漏,并从底层支持协程并发,充分利用计算机的硬件性能。基于以上种种优势,Go 目前在软件行业发挥着重要作用,不少优秀的开源软件都是基于 Go 进行开发的,包括 Docker、Etcd 和 Kubernetes 等。

虽说近几年来 Go 语言发展比较迅猛,但是总体而言,它还是属于新生代语言。鉴于我们课程接下来的实践都是围绕着 Go 来展开的,所以在介绍微服务各个组件的详情之前,非常有必要对 Go 的语法补充一些前置知识。

那本课时我就带你回顾 Go 语言开发的一些基础知识,主要包括 Go 语言的基础语法、数据结构和流程控制。

基础语法

Go 的语法与 C 语言相似,但显得更加精炼。下面我们通过一个简单的小程序来熟悉下 Go 的基础语法,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// fileName: simple.go
package main
import (
	"fmt"
	"sync"
)
func input(ch chan string)  {
	defer wg.Done()
	defer close(ch)
	var input string
	fmt.Println("Enter 'EOF' to shut down: ")
	for {
		_, err := fmt.Scanf("%s", &input)
		if err != nil{
			fmt.Println("Read input err: ", err.Error())
			break
		}
		if input == "EOF"{
			fmt.Println("Bye!")
			break
		}
		ch <- input
	}
}
func output(ch chan string)  {
	defer wg.Done()
	for value := range ch{
		fmt.Println("Your input: ", value)
	}
}
var wg sync.WaitGroup
func main() {
	ch := make(chan string)
	wg.Add(2)
	go input(ch) // 读取输入
	go output(ch) // 输出到命令行
	wg.Wait()
	fmt.Println("Exit!")
}

上述示例代码的功能为:从命令行接受用户的输入并输出到命令行中,可在文件所在命令行下执行 go run simple.go 启动程序。代码中使用 go 关键字启动了两个协程分别处理读取输入和输出到命令行,协程为 Go 中执行代码片段的用户级轻量线程,协程之间通过 chan 进行数据传输,chan 中的数据传输遵循先进先出的顺序,并保证每次只能有一个协程发送或者接收数据。

每一个 Go 文件都需要在文件开头标注所属的包,Go 程序是由包组成的,上述代码中的 Go 文件位于 main 包下。Go 中规定可执行程序必须具备 main 包,并且在 main 包下具备可执行的 main 函数。通过 import 关键字就可以导入其他包中的代码进行使用。

从上述代码中我们可以了解到 Go 的部分语法特点:

变量声明时,变量类型位于变量名后;

对于 if 和 for 等语句的子句条件,无须使用 “()” 包起来;

语句结束无须使用“;”之类的分隔符,通过换行区分;

“{” 必须紧跟在对应语句的后面,不可另起一行。

为了使 Go 程序运行起来,接下来我们就介绍一些简单的 Go 编译工具和命令。

第一个,go run 命令。该命令将直接编译和执行源码中的 main 函数,你可以在命令后添加参数,这部分参数会作为代码可以接受的命令行输入提供给程序。以上面的 simple.go 小程序为例子,进入到 simple.go 文件的目录下,执行如下命令即可运行 simple.go 小程序:

1
go run simple.go

第二个,go build 命令。该命令通过 Go 的并发特性对代码进行函数粒度的并发编译,它会将源码编译为可执行文件,默认将编译该目录下的所有源码。也可以在命令后添加多个文件名,go build 将编译这些源码,输出可执行文件。

比如,进入到我们上面 simple.go 文件的目录下,执行如下命令:

1
go build -o simple // -o 用于指定生成可执行文件名称

或者

1
go build simple.go

都将在当前目录下生成一个 simple 的可执行文件,可双击直接运行。

接下来我们就详细讲解一下 Go 语言中的函数声明、变量的声明与初始化、指针、struct 等基础语法。

1. 函数声明

Go 中使用关键字 func 来声明函数,声明形式如下:

1
2
3
func funcName(params)(return params){
	function body
}

在同一个包内,函数名不可重名。如果函数希望能在包外被访问,则需要将函数名的首字母大写,表示该函数是包外公开的。

函数可以接受 0 个或者以上参数,在声明参数列表时,需要注意参数名在前、类型在后。对于连续类型相同的多个参数,可以省略参数类型,在最后一个参数中保留类型即可。Go 的函数支持多返回值和命名返回值,但是命名返回值和非命名返回值不能混合使用。一个综合的函数声明示例如下:

1
2
3
4
5
func Div(dividend, divisor int)(quotient, remainder int) {
    quotient = dividend/divisor
    remainder = dividend%divisor
    return
}

上述代码中声明了一个包外公开的 Div 函数,接受 dividend、divisor 两个参数,并返回quotient、remainder 两个命名返回值,它们可以在函数体中被直接赋值使用。这里需要注意的是,在使用命名返回值时,我们需要在函数结束时显式使用 return 语句进行返回。

2. 变量的声明与初始化

Go 中使用关键字 var 声明变量,变量名在前,类型在最后,声明形式如下:

1
var name T

在 Go 中,声明的变量都必须被使用,否则会编译失败。变量声明之后,会被默认初始化为初值。当然,我们也可以在声明时直接进行初始化,使用赋值符号 “=”,形式如下:

1
var name T = expression

Go 中支持类型推荐,在变量声明和初始化可以省略类型,由编译器根据 “=” 右边的表达式推导变量的类型。

1
var a = 100

a 中的类型会被编译器推导为 int。Go 中还支持短变量声明和初始化,形式更加精简:

1
name := expression

该种形式需要 “:=” 的左值存在没有定义过的变量,且无法在函数外使用。短变量形式支持多个变量的声明和初始化,如:

1
value, _ := fmt.Println()

在多个短变量的声明和初始化中,需要保证左值最少有一个新变量,否则编译会失败。对于不需要使用的变量,可以使用匿名变量 “” 来处理,如上述例子中使用 “” 来接受函数调用返回的 error,表示赋予该标识符的值将被直接舍弃,无法在后续代码中使用。匿名变量可以在代码中多次声明使用。

3. 指针

Go 支持指针操作。指针使得开发人员可以直接操作内存空间,能够有效地提升程序的执行性能,但是传统的指针容易带来指针偏移错误、忘记释放内存等问题,大大提升了指针编程的门槛。Go 移除了指针的运算能力,并引入了自动垃圾回收机制,使得 Go 中的指针在具备高效的内存访问效率同时,不会出现非法修改越界数据和指针占用内存忘记回收等问题。

Go 中的指针使用方式与 C 类似:“&” 为取址符号,“*” 为取值符号。声明一个指针类型如下:

1
var name *T

这里 *T 为指向 T 类型的指针。我们可以通过指针直接读取和修改变量的值,如下面例子所示:

1
2
3
4
5
6
7
8
9
package main
import "fmt"
func main()  {
	name := "aoho"
	p := &name
	fmt.Println("name is", *p)
	*p = "zhu"
	fmt.Println("name is", name)
}

上述代码中使用了 “&” 获取了 name 变量的指针,并通过该指针读取和修改了 name 变量的值。

4. struct

Go 中不存在类,但是存在与 C 类似的 struct 类型。struct 作为一种复合类型,由一组字段组成,每一个字段都有自己的类型和名称。struct 的定义需要结合 type 和 struct 关键字,形式如下:

1
2
3
4
5
type structName struct{
    value1 T1
    value2 T2
    ...
}

和函数声明类似,struct 名在同一个包内不能重复,将 struct 名的首字母大写表示该 struct 可以在包外被访问。struct 中的字段如果希望包外公开,同样需要将字段名的首字母大写。一个简单的结构体定义如下:

1
2
3
4
5
type Student struct {
	StudentID int64
	Name string
	birth string
}

在上述声明的 Student struct 中,StudentID 和 Name 字段在包外是可访问的,birth 字段只能在包内访问。struct 可以通过多种形式实例化一个新的结构体,struct 中的字段可以通过 “.” 进行读取和修改,如下面代码所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main
import "fmt"
func main()  {
	s0 := Student{}
   // Key:Value
	s1 := Student{
		StudentID:1,
		Name:"s1",
		birth:"19900101",
	}
   // 字段赋值顺序与结构体字段定义顺序一致
	s2 := Student{
		2,
		"s2",
		"19900102",
	}
   // 获取指针
	s3 := &Student{
		StudentID:3,
		Name:"s3",
		birth:"19900103",
	}
    fmt.Println(s0, s1, s2, s3)
    fmt.Println(s0.Name, s1.Name, s2.Name, s3.Name)
}

数据结构

在日常开发中,除了掌握基本的语法外,语言的一些基本数据结构也必须要掌握,合理使用数据结构能够带来更高的执行效率。Go 中常使用的数据结构有 array(数组)、slice(切片)和map(字典)。下面让我们来一一回顾它们的用法。

1. array(数组)

数组为一段连续的内存空间,存储了固定数量和固定类型的数据,数组的大小在声明的时候就已经固定下来。数组的声明样式如下:

1
var name [size]T

这表示声明了一个长度为 size、存储类型为 T 的数组。一个简单初始化和使用数组的例子如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package main
import "fmt"
func main()  {
	var numList1 [3]int
	numList1[0] = 0
	numList1[1] = 1
	numList1[2] = 2
	numList2 := [3]int{0, 1, 2};
	fmt.Println(numList1, numList2)
}

在上述代码中,我们可以在声明数组后,使用下标的方式对数组成员进行访问和赋值,如 numList1 ;也可以在声明数组时直接初始化数组内的数据,如 numeList2 。

2. slice(切片)

数组的大小是固定的,在使用时难免会遇到扩容的情况。切片作为数组一个连续片段的引用,它的大小动态可变,我们可以简单将切片理解为动态数组。切片底层由数组实现,在添加数据时,如果切片对应的底层数组不足以容纳新的成员时,该切片将会进行扩容:重新申请一段连续的内存空间作为新数组,通常为原有数组的 2 倍,然后将原有切片的数据复制到新数组中,把新的成员添加新的数组中,创建新的切片,并指向新数组,最后返回新的切片;如果当前切片对应的数组可以容纳更多的数据,添加的操作将在原有数组上进行,这将会覆盖掉原有数组的值,接着创建新的切片,并指向原数组,最后返回新的切片。

切片和数组的关系可以通过下图理解:

切片的组成

切片中持有指向底层存储数据数组的指针,长度指当前切片中存储数据的长度,容量指当前切片的容量,即当前切片从它的第一个数据到其对应数组末尾的长度,可以简单理解为切片在其对应数组中可使用的长度。

切片可以从数组中直接生成,它将直接关联原有数组,生成样式如下:

1
slice := source[begin:end]

它从数组中选择一个半开区间,包含 begin 位置的数据,但不包含 end 位置的数据。也可以使用 make 函数创建一个新的切片,样式如下所示:

1
slice := make([]T, size, cap)

在创建时可以指定当前切片的长度和容量。一个简单使用切片的例子如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package main
import "fmt"
func main()  {
	arr1 := [...]int{0,1,2,3}
	slice1 := arr1[0:4]
	slice2 := make([]int, 4, 4)
	for i := 0; i< 4 ;i ++{
		slice2[i] = i
	}
	slice3 := append(slice1, 5)
	slice4 := []int{0, 1, 2, 3}
	fmt.Println(slice1, slice2, slice3)
}

访问和修改切片中的数据与数组一致,通过下标即可实现。在上述代码中,对 slice1 添加新的数据时,由于 slice1 的容量不足以容纳新的数据,它将发生扩容操作,生成一个新切片 slice3,它和 slice1 对应的底层数组不同,slice1 对应数组中的数据不会发生变化。还有一个需要注意的问题是,由于 append 函数每次都会返回新的切片,为了避免数据的丢失,在每次使用 append 函数添加新的数据后,都要保证后续使用的切片为 append 函数最新返回的切片。最后在声明和初始化 slice4 时,只要不指定 “[]” 中的大小,即可声明和初始化一个切片,否则将会声明和初始化一个固定长度的数组。

3. map(字典)

map 为 Go 提供的映射关系容器,它将键映射到对应的值。声明一个 map 的样式如下:

1
var name map[keyType]valueType

这其中,keyType 即键类型,valueType 即键映射的值类型。map 的初值为 nil,无法直接使用,可以使用 make 函数进行初始化,样式如下:

1
2
var name map[keyType]valueType
name = make(map[keyType]valueType)

一个简单的使用 map 的例子如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package main
import "fmt"
func main()  {
	map1 := make(map[int]string)
	map1[1] = "01"
	map1[2] = "02"
	map1[3] = "03"
	map2 := map[int]string{
		1: "01",
		2: "02",
		3: "03",
	}
	fmt.Println(map1[0])
	value,ok := map1[1];if ok{
		fmt.Println("1 is", value)
	}else {
		fmt.Println("1 is not existed!")
	}
	fmt.Println(map2)
}

在初始化 map 后,可以直接为 map 添加 key-value 映射关系,如 map1 ;也可以在初始化时直接使用 key-value 对为 map 添加映射关系,如 map2 。判断一个键是否在 map 中存在,可以使用以下句式:

1
value,ok := map[key]

如果键存在于 map 中,ok 将会返回 true。如果直接查询 map 中不存在的键,将会返回值类型的初值,如上述例子中查询 map1[0],将会返回 int 类型的初值 0。

流程控制

逻辑判断对于日常开发来说必不可少,接下来我们就来了解 Go 中常用的流程控制语句: for、if-else、switch 和 defer。

1. for 循环

Go 中循环语句仅提供 for 循环结构,基本形式如下:

1
2
3
for init;condition;end{
    circle body
}

其中, init 为初始化语句,仅在第一次循环前执行;condition 为条件表达式,在每次循环中判断是否满足执行条件;end 为结束语句,在每次循环结束时执行。上述三者都可以缺省,此时 for 将变成一个无限循环语句。如果缺省初始化语句和结束语句,for 将变得类似 while 语句,而 Go 中不存在 while 关键字。一个简单的 for 循环使用例子如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package main
import "fmt"
func main()  {
	sum1 := 0
	for i := 0 ; i < 10 ; i++{
		sum1 += i
	}
	sum2 := 1
	for sum2 < 100{
		sum2 *= 2
	}
	sum3 := 1
	for {
		if sum3 < 100{
			sum3 *= 2
		}else {
			break
		}
	}
	fmt.Println(sum1, sum2, sum3)
}

在 for 循环中可以使用 break 关键字跳出当前循环,或者使用 continue 关键字跳到下一个循环。

2. 分支控制

Go 中提供两种分支控制语句,分别为 if-else 语句和 switch 语句。if-else 语句用于进行条件分支控制,简单的表达式如下:

1
2
3
4
5
6
7
if condition1 {
	branch1
} else if condition2 {
	branch2
} else {
	branch3
}

switch 是比 if-else 更为简便的用于编写大量条件分支的方法。Go 中的 switch 与其他编程语言类似,但存在不同之处:Go 中的 switch 只执行匹配 case 后面的代码块,无须使用 break 关键字跳出 switch 选择体。除非明确使用 fallthrough 关键字对上下两个 case 进行连接,否则 switch 执行完匹配 case 后面的代码块后将退出 switch。

一个简单的 switch 例子如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main
import (
"fmt"
"time"
)
func main()  {
	nowTime := time.Now()
	
	switch nowTime.Weekday(){
	case time.Saturday:
		fmt.Println("take a rest")
	case time.Sunday:
		fmt.Println("take a rest")
	default:
		fmt.Println("you need to work")
	}
	switch  {
	case nowTime.Weekday() >= time.Monday && nowTime.Weekday() <= time.Friday:
		fmt.Println("you need to work")
	default:
		fmt.Println("take a rest")
	}
}

当 switch 后没有携带需要判断的条件时,就可以在 case 后面使用判断表达式,如上述代码所示,这种写法就与 if-else 语句十分类似,但显得更为清晰。

3. defer 延迟执行

Go 中提供 defer 关键字来延迟执行函数,被 defer 延迟执行的函数会在 return 返回前执行,所以一般用来进行资源释放等清理工作。

多个被 defer 的函数会按照先进后出的顺序被调用,类似栈数据结构的使用。我们通过一个简单的例子来理解 defer 关键字的使用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package main
import "fmt"
func add(a, b int) int {
	return a + b
}
func main()  {
	a := 1
	b := 2
	defer fmt.Println("front result: ", add(a, b)) // 3
	a = 3
	b = 4
	defer fmt.Println("last result: ", add(a, b)) // 7
	a = 5
	b = 6
	fmt.Println(add(a, b)) // 11
}

按照 defer 先进后出的执行顺序,预期的执行结果为:

1
2
3
11
7
3

在上述例子中还需要注意的是,传递给 defer 执行的延迟函数的参数会被立即解析,而非等待到正式执行时才被解析。

小结

无可否认,Go 是一门优秀的服务端开发语言,具备语法简单、性能优越、静态类型安全、自动垃圾回收等诸多优点。

本课时我们对 Go 语言开发进行了快速回顾,主要介绍了:

Go 基本语法,包括函数声明、变量声明与初始化、指针和 struct;

数据结构,包括数组、切片和字典;

流程控制,包括 for 循环、if-else 与 switch 分支控制、defer 延迟执行。

由于课程接下来的项目实践是围绕着 Go 进行开发的,因此具备基本的 Go 开发能力是必不可少的。希望通过本课时的学习,你能够掌握编写简单 Go 程序的能力,为后面使用 Go 开发微服务应用打下坚实的基础。

最后,关于 Go 语言开发你有什么问题或想法,欢迎在留言区和我交流分享。

-– ### 精选评论 ##### **强: > 介绍的不错,期待更多的编码~ ##### **升: > 打卡。想做容器相关的开发 ##### *磊: > 例子和总结的都很好