1. 1. 一、Go 语法说明
    1. 1.1. 1-1 关键字
      1. 1.1.1. 1-1.1 GO 的关键字
      2. 1.1.2. 1-1.2 关键字说明
      3. 1.1.3. 1-1.3 Go 程序设计规则
    2. 1.2. 1-2 数据类型的定义
      1. 1.2.1. 1-2.1 定义变量
      2. 1.2.2. 1-2.2 常量
      3. 1.2.3. 1-2.3 内置基础类型
        1. 1.2.3.1. 1-2.3.1 Boolean
        2. 1.2.3.2. 1-2.3.2 数值类型
        3. 1.2.3.3. 1-2.3.3 字符串
      4. 1.2.4. 1-2.4 错误类型
      5. 1.2.5. 1-2.5 分组声明
      6. 1.2.6. 1-2.6 iota 枚举
      7. 1.2.7. 1-2.7 array、slice、map
        1. 1.2.7.1. 1-2.7.1 array
        2. 1.2.7.2. 1-2.7.2 slice
        3. 1.2.7.3. 1-2.7.3 map
      8. 1.2.8. 1-2.8 make、new 操作
      9. 1.2.9. 1-2.9 零值
      10. 1.2.10. 1-2.10 值类型和引用类型
    3. 1.3. 1-3 流程控制
      1. 1.3.1. 1-3.1 if
      2. 1.3.2. 1-3.2 goto
      3. 1.3.3. 1-3.3 for
      4. 1.3.4. 1-3.4 switch
    4. 1.4. 1-4 函数
      1. 1.4.1. 1-4.1 函数的定义
      2. 1.4.2. 1-4.2 多个返回值
      3. 1.4.3. 1-4.3 变参
      4. 1.4.4. 1-4.4 传值与传指针
      5. 1.4.5. 1-4.5 defer
      6. 1.4.6. 1-4.6 函数作为值、类型*
      7. 1.4.7. 1-4.7 Panic 和 Recover
      8. 1.4.8. 1-4.8 main 函数和 init 函数
      9. 1.4.9. 1-4.9 import
    5. 1.5. 1-5 struct 类型
      1. 1.5.1. 1-5.1 struct 类型的声明
      2. 1.5.2. 1-5.2 struct 的匿名字段
    6. 1.6. 1-6 method
      1. 1.6.1. 1-6.1 method
      2. 1.6.2. 1-6.2 指针作为 receiver
      3. 1.6.3. 1-6.3 method 继承
      4. 1.6.4. 1-6.4 method 重写
      5. 1.6.5. 1-6.5 工厂模式
      6. 1.6.6. 1-6.6 面向过程->面向对象
      7. 1.6.7. 1-6.7 案例:GitHub 标签爬虫
    7. 1.7. 1-7 interface
      1. 1.7.1. 1-7.1 什么是 interface
      2. 1.7.2. 1-7.2 interface 类型
      3. 1.7.3. 1-7.3 interface 值
      4. 1.7.4. 1-7.4 空 interface
      5. 1.7.5. 1-7.5 interface 函数参数
      6. 1.7.6. 1-7.6 interface 存储类型
      7. 1.7.7. 1-7.7 嵌入 interface
      8. 1.7.8. 1-7.8 案例:USB 接口
    8. 1.8. 1-8 反射
    9. 1.9. 1-9 错误处理
      1. 1.9.1. 1-9.1 Error 类型
      2. 1.9.2. 1-9.2 自定义 Error
      3. 1.9.3. 1-9.3 错误处理
  2. 2. 二、常用方法
    1. 2.1. 2-1 网络编程
      1. 2.1.1. 2-1.1 HTTP请求
      2. 2.1.2. 2-1.2 简单的 Web 服务
    2. 2.2. 2-2 文件操作
      1. 2.2.1. 2-2.1 文件读取
      2. 2.2.2. 2-2.2 文件写入
    3. 2.3. 2-3 正则
    4. 2.4. 2-4 命令行参数
    5. 2.5. 2-5 并发
      1. 2.5.1. 2-5.1 进程、线程、协程
      2. 2.5.2. 2-5.2 goroutine
      3. 2.5.3. 2-5.3 channels
      4. 2.5.4. 2-5.4 Buffered Channels
      5. 2.5.5. 2-5.5 Range 和 Close
      6. 2.5.6. 2-5.6 Select
      7. 2.5.7. 2-5.7 超时
      8. 2.5.8. 2-5.8 runtime goroutine
  3. 3. 三、Go 工具使用
    1. 3.1. 3-1 Module
      1. 3.1.1. 3-1.1 go mod 简介
      2. 3.1.2. 3-1.2 使用 go mod 管理包
    2. 3.2. 3-2 go build
      1. 3.2.1. 3-2.1 常见的编译参数
      2. 3.2.2. 3-2.2 跨平台编译
        1. 3.2.2.1. 3-2.2.1 Windows 编译
        2. 3.2.2.2. 3-2.2.2 Mac 编译
        3. 3.2.2.3. 3-2.2.3 Linux 编译
    3. 3.3. 3-3 go get
  4. 4. 四、编码规范
    1. 4.1. 4-1 命名规范
      1. 4.1.1. 4-1.1 文件命名
      2. 4.1.2. 4-1.2 包命名
      3. 4.1.3. 4-1.3 结构体命名
      4. 4.1.4. 4-1.4 接口命名
      5. 4.1.5. 4-1.5 函数命名
      6. 4.1.6. 4-1.6 常量命名
      7. 4.1.7. 4-1.7 变量命名
    2. 4.2. 4-2 注释规范
      1. 4.2.1. 4-2.1 包注释
      2. 4.2.2. 4-2.2 结构体/接口注释
      3. 4.2.3. 4-2.3 函数/方法注释
      4. 4.2.4. 4-2.4 代码逻辑注释
      5. 4.2.5. 4-2.5 bug 注释
      6. 4.2.6. 4-2.6 注释风格
    3. 4.3. 4-3 项目开发规范
      1. 4.3.1. 4-3.1 项目结构
      2. 4.3.2. 4-3.2 包管理

Go 学习记录

一、Go 语法说明

1-1 关键字

Go 语言设计的关键字,了解这些关键字有助于命名变量的冲突避免

1-1.1 GO 的关键字

1
2
3
4
5
break    default      func    interface    select
case defer go map struct
chan else goto package switch
const fallthrough if range type
continue for import return var

1-1.2 关键字说明

  • varconst 是 Go 语言基础里面的变量和常量声明

  • packageimport 用于分包和导入

  • func 用于定义函数和方法

  • return 用于从函数返回

  • defer 用于在任务结束时关闭句柄

  • go 用于并发

  • select 用于选择不同类型的通讯

  • interface 用于定义接口

  • struct 用于定义抽象数据类型,结构体

  • breakcasecontinueforfallthroughelseifswitchgotodefault 用于流程控制

  • chan 用于 channel 通讯

  • type 用于声明自定义类型

  • map 用于声明 map 类型数据

  • range 用于循环读取 slice、map、channel 数据

1-1.3 Go 程序设计规则

Go 之所以会那么简洁,是因为它有一些默认的行为:

  • 大写字母开头的变量是可导出的,也就是其它包可以读取的,是公有变量;小写字母开头的就是不可导出的,是私有变量。
  • 大写字母开头的函数也是一样,相当于 class 中的带 public 关键词的公有函数;小写字母开头的就是有 private 关键词的私有函数。
1
2
3
4
5
6
7
8
9
10
11
12
// 这个包定义了一个简单的数学库
package math

// Add 是一个可导出的函数,可以被其他包调用
func Add(a, b int) int {
return a + b
}

// subtract 是一个不可导出的函数,只能在 math 包内部调用
func subtract(a, b int) int {
return a - b
}

1-2 数据类型的定义

1-2.1 定义变量

Go 语言里面定义变量有多种方式。

使用 var 关键字是 Go 最基本的定义变量方式,与 C 语言不同的是 Go 把变量类型放在变量名后面:

1
2
//定义一个名称为“variableName”,类型为"type"的变量
var variableName type

定义多个变量

1
2
//定义三个类型都是“type”的变量
var vname1, vname2, vname3 type

定义变量并初始化值

1
2
//初始化“variableName”的变量为“value”值,类型是“type”
var variableName type = value

同时初始化多个变量

1
2
3
4
5
/*
定义三个类型都是"type"的变量,并且分别初始化为相应的值
vname1为v1,vname2为v2,vname3为v3
*/
var vname1, vname2, vname3 type= v1, v2, v3

是不是觉得上面这样的定义有点繁琐?有一种写法可以让它变得简单一点。可以直接忽略类型声明,那么上面的代码变成这样了:

1
2
3
4
5
6
/*
定义三个变量,它们分别初始化为相应的值
vname1为v1,vname2为v2,vname3为v3
然后Go会根据其相应值的类型来初始化它们
*/
var vname1, vname2, vname3 = v1, v2, v3

觉得上面的还是有些繁琐,继续简化:

1
2
3
4
5
6
/*
定义三个变量,它们分别初始化为相应的值
vname1为v1,vname2为v2,vname3为v3
编译器会根据初始化的值自动推导出相应的类型
*/
vname1, vname2, vname3 := v1, v2, v3

:= 这个符号直接取代了 vartype,这种形式叫做简短声明。不过它有一个限制,那就是它只能用在函数内部;在函数外部使用则会无法编译通过,所以一般用 var 方式来定义全局变量。

_(下划线)是个特殊的变量名,任何赋予它的值都会被丢弃。在这个例子中,将值 35 赋予 b,并同时丢弃 34

1
_, b := 34, 35

Go 对于已声明但未使用的变量会在编译阶段报错,比如下面的代码就会产生一个错误:声明了 i 但未使用。

1
2
3
4
package main
func main() {
var i int
}

1-2.2 常量

所谓常量,也就是在程序编译阶段就确定下来的值,而程序在运行时无法改变该值。在 Go 程序中,常量可定义为数值、布尔值或字符串等类型。

它的语法如下:

1
2
3
const constantName = value
//如果需要,也可以明确指定常量的类型:
const Pi float32 = 3.1415926

下面是一些常量声明的例子:

1
2
3
4
const Pi = 3.1415926
const i = 10000
const MaxThread = 10
const prefix = "astaxie_"

Go 常量和一般程序语言不同的是,可以指定相当多的小数位数(例如200位),若指定给 float32 自动缩短为 32bit,指定给 float64 自动缩短为 64bit,详情参考 http://golang.org/ref/spec#Constants

1-2.3 内置基础类型

1-2.3.1 Boolean

在Go中,布尔值的类型为 bool,值是 truefalse,默认为 false

1
2
3
4
5
6
7
8
//示例代码
var isActive bool // 全局变量声明
var enabled, disabled = true, false // 忽略类型的声明
func test() {
var available bool // 一般声明
valid := false // 简短声明
available = true // 赋值操作
}
1-2.3.2 数值类型

整数类型有无符号和带符号两种。Go同时支持 intuint,这两种类型的长度相同,但具体长度取决于不同编译器的实现。

Go里面也有直接定义好位数的类型:rune, int8, int16, int32, int64byte, uint8, uint16, uint32, uint64。其中 runeint32 的别称,byteuint8 的别称。

需要注意的一点是,这些类型的变量之间不允许互相赋值或操作,不然会在编译时引起编译器报错。

如下的代码会产生错误:invalid operation: a + b (mismatched types int8 and int32)

1
2
3
4
5
var a int8

var b int32

c:=a + b

另外,尽管int的长度是32 bit, 但int 与 int32并不可以互用。

浮点数的类型有 float32float64 两种(没有 float 类型),默认是 float64

1-2.3.3 字符串

Go 中的字符串都是采用 UTF-8 字符集编码。字符串是用一对双引号("")或反引号(` `)括起来定义,它的类型是 string。记住是双引号!

1
2
3
4
5
6
7
8
//示例代码
var frenchHello string // 声明变量为字符串的一般方法
var emptyString string = "" // 声明了一个字符串变量,初始化为空字符串
func test() {
no, yes, maybe := "no", "yes", "maybe" // 简短声明,同时声明多个变量
japaneseHello := "Konichiwa" // 同上
frenchHello = "Bonjour" // 常规赋值
}

Go 中字符串是不可变的,例如下面的代码编译时会报错:cannot assign to s[0]

1
2
var s string = "hello"
s[0] = 'c'

但如果真的想要修改,下面的代码可以实现:

1
2
3
4
5
s := "hello"
c := []byte(s) // 将字符串 s 转换为 []byte 类型
c[0] = 'c'
s2 := string(c) // 再转换回 string 类型
fmt.Printf("%s\n", s2)

Go中可以使用 + 操作符来连接两个字符串:

1
2
3
4
s := "hello,"
m := " world"
a := s + m
fmt.Printf("%s\n", a)

修改字符串也可写为:

1
2
3
s := "hello"
s = "c" + s[1:] // 字符串虽不能更改,但可进行切片操作
fmt.Printf("%s\n", s)

如果要声明一个多行的字符可以通过 ``` 来声明:

1
2
m := `hello
world`

反引号括起的字符串为 Raw 字符串,即字符串在代码中的形式就是打印时的形式,它没有字符转义,换行也将原样输出。例如本例中会输出:

1
2
hello
world

1-2.4 错误类型

Go 内置有一个 error 类型,专门用来处理错误信息,Go 的 package 里面还专门有一个包 errors 来处理错误:

1
2
3
4
err := errors.New("emit macho dwarf: elf header corrupted")
if err != nil {
fmt.Print(err)
}

1-2.5 分组声明

Go 语言中,同时声明多个常量、变量,或者导入多个包时,可采用分组的方式进行声明,会显得比较直观。

例如下面的代码:

1
2
3
4
5
6
7
8
import "fmt"
import "os"
const i = 100
const pi = 3.1415
const prefix = "Go_"
var i int
var pi float32
var prefix string

可以分组写成如下形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import(
"fmt"
"os"
)
const(
i = 100
pi = 3.1415
prefix = "Go_"
)
var(
i int
pi float32
prefix string
)

1-2.6 iota 枚举

Go里面有一个关键字 iota,这个关键字用来声明 enum 的时候采用,它默认开始值是0,const 中每增加一行加1,同一行的值相同。

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"
)
const (
x = iota // x == 0
y = iota // y == 1
z = iota // z == 2
w // 常量声明省略值时,默认和之前一个值的字面相同。这里隐式地说w = iota,因此w == 3。其实上面y和z可同样不用"= iota"
)
const v = iota // 每遇到一个const关键字,iota就会重置,此时v == 0
const (
h, i, j = iota, iota, iota //h=0,i=0,j=0 iota在同一行值相同
)
const (
a = iota //a=0
b = "B"
c = iota //c=2
d, e, f = iota, iota, iota //d=3,e=3,f=3
g = iota //g = 4
)
func main() {
fmt.Println(a, b, c, d, e, f, g, h, i, j, x, y, z, w, v)
}
// 0 B 2 3 3 3 4 0 0 0 0 1 2 3 0

除非被显式设置为其它值或 iota,每个 const 分组的第一个常量被默认设置为它的0值,第二及后续的常量被默认设置为它前面那个常量的值,如果前面那个常量的值是 iota,则它也被设置为 iota

1-2.7 arrayslicemap

1-2.7.1 array

array 就是数组,它的定义方式如下:

1
var arr [n]type

[n]type 中,n 表示数组的长度,type 表示存储元素的类型。对数组的操作和其它语言类似,都是通过 [] 进行读取或赋值:

1
2
3
4
5
var arr [10]int  // 声明了一个int类型的数组
arr[0] = 42 // 数组下标是从0开始的
arr[1] = 13 // 赋值操作
fmt.Printf("The first element is %d\n", arr[0]) // 获取数据,返回42
fmt.Printf("The last element is %d\n", arr[9]) //返回未赋值的最后一个元素,默认返回0

由于长度也是数组类型的一部分,因此 [3]int[4]int 是不同的类型,数组也就不能改变长度。

数组之间的赋值是值的赋值,即当把一个数组作为参数传入函数的时候,传入的其实是该数组的副本,而不是它的指针。如果要使用指针,那么就需要用到后面介绍的slice 类型。

数组可以使用另一种 := 来声明。

1
2
3
a := [3]int{1, 2, 3} // 声明了一个长度为3的int数组
b := [10]int{1, 2, 3} // 声明了一个长度为10的int数组,其中前三个元素初始化为1、2、3,其它默认为0
c := [...]int{4, 5, 6} // 可以省略长度而采用`...`的方式,Go会自动根据元素个数来计算长度

Go 支持嵌套数组,即多维数组。比如下面的代码就声明了一个二维数组:

1
2
3
4
// 声明了一个二维数组,该数组以两个数组作为元素,其中每个数组中又有4个int类型的元素
doubleArray := [2][4]int{[4]int{1, 2, 3, 4}, [4]int{5, 6, 7, 8}}
// 上面的声明可以简化,直接忽略内部的类型
easyArray := [2][4]int{{1, 2, 3, 4}, {5, 6, 7, 8}}
1-2.7.2 slice

在很多应用场景中,数组并不能满足需求。在初始定义数组时,并不知道需要多大的数组,因此就需要“动态数组”。在 Go 里面这种数据结构叫 slice(切片)

slice 并不是真正意义上的动态数组,而是一个引用类型。slice 总是指向一个底层 arrayslice 的声明也可以像 array 一样,只是不需要长度。

1
2
// 和声明array一样,只是少了长度
var fslice []int

接下来可以声明一个slice,并初始化数据,如下所示:

1
slice := []byte {'a', 'b', 'c', 'd'}

slice 可以从一个数组或一个已经存在的 slice 中再次声明。slice 通过 array[i:j] 来获取,其中 i 是数组的开始位置,j 是结束位置,但不包含 array[j],它的长度是 j-i。(左闭右开区间)

1
2
3
4
5
6
7
8
9
10
// 声明一个含有10个元素元素类型为byte的数组
var ar = [10]byte {'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'}
// 声明两个含有byte的slice
var a, b []byte
// a指向数组的第3个元素开始,并到第五个元素结束,
a = ar[2:5]
//现在a含有的元素: ar[2]、ar[3]和ar[4]
// b是数组ar的另一个slice
b = ar[3:5]
// b的元素是:ar[3]和ar[4]

注意 slice 和数组在声明时的区别:声明数组时,方括号内写明了数组的长度或使用 ... 自动计算长度,而声明 slice 时,方括号内没有任何字符。

slice 有一些简便的操作

  • slice 的默认开始位置是0,ar[:n] 等价于 ar[0:n]

  • slice 的第二个序列默认是数组的长度,ar[n:] 等价于 ar[n:len(ar)]

  • 如果从一个数组里面直接获取 slice,可以这样 ar[:],因为默认第一个序列是0,第二个是数组的长度,即等价于 ar[0:len(ar)]

下面这个例子展示了更多关于 slice 的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 声明一个数组
var array = [10]byte{'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'}
// 声明两个slice
var aSlice, bSlice []byte
// 演示一些简便操作
aSlice = array[:3] // 等价于aSlice = array[0:3] aSlice包含元素: a,b,c
aSlice = array[5:] // 等价于aSlice = array[5:10] aSlice包含元素: f,g,h,i,j
aSlice = array[:] // 等价于aSlice = array[0:10] 这样aSlice包含了全部的元素
// 从slice中获取slice
aSlice = array[3:7] // aSlice包含元素: d,e,f,g,len=4,cap=7
bSlice = aSlice[1:3] // bSlice 包含aSlice[1], aSlice[2] 也就是含有: e,f
bSlice = aSlice[:3] // bSlice 包含 aSlice[0], aSlice[1], aSlice[2] 也就是含有: d,e,f
bSlice = aSlice[0:5] // 对slice的slice可以在cap范围内扩展,此时bSlice包含:d,e,f,g,h
bSlice = aSlice[:] // bSlice包含所有aSlice的元素: d,e,f,g

slice 是引用类型,所以当引用改变其中元素的值时,其它的所有引用都会改变该值,例如上面的 aSlicebSlice,如果修改了 aSlice 中元素的值,那么 bSlice 相对应的值也会改变。

从概念上面来说 slice 像一个结构体,这个结构体包含了三个元素:

  • 一个指针,指向数组中 slice 指定的开始位置
  • 长度,即 slice 的长度
  • 最大长度,也就是 slice 开始位置到数组的最后位置的长度
1
2
Array_a := [10]byte{'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'}
Slice_a := Array_a[2:5]

slice 有几个有用的内置函数

  • len 获取 slice 的长度
  • cap 获取 slice 的最大容量
  • appendslice 里面追加一个或者多个元素,然后返回一个和 slice 一样类型的 slice
  • copy 函数 copy 从源 slicesrc 中复制元素到目标 dst,并且返回复制的元素的个数

注:append 函数会改变 slice 所引用的数组的内容,从而影响到引用同一数组的其它 slice

但当 slice 中没有剩余空间(即 (cap-len) == 0 )时,此时将动态分配新的数组空间。返回的 slice 数组指针将指向这个空间,而原数组的内容将保持不变;其它引用此数组的 slice 则不受影响。

从 Go1.2 开始 slice 支持了三个参数的 slice,之前一直采用这种方式在 slice 或者 array 基础上来获取一个 slice

1
2
var array [10]int
slice := array[2:4]

这个例子里面slice的容量是8,新版本里面可以指定这个容量

1
slice = array[2:4:7]

上面这个的容量就是 7-2,即 5。这样这个产生的新的 slice 就没办法访问最后的三个元素。

如果 slice 是这样的形式 array[:i:j],即第一个参数为空,默认值就是 0。

1-2.7.3 map

map也就是Python中字典的概念,它的格式为 map[keyType]valueType

看下面的代码,map 的读取和设置也类似 slice 一样,通过 key 来操作,只是 sliceindex 只能是 int 类型,而 map 多了很多类型,可以是 int,可以是 string 及所有完全定义了 ==!= 操作的类型。

1
2
3
4
5
6
7
8
9
10
// 声明一个key是字符串,值为int的字典,这种方式的声明需要在使用之前使用make初始化
var numbers map[string]int

// 另一种map的声明方式
numbers := make(map[string]int)
numbers["one"] = 1 //赋值
numbers["ten"] = 10 //赋值
numbers["three"] = 3
fmt.Println("第三个数字是: ", numbers["three"]) // 读取数据
// 打印出来如:第三个数字是: 3

使用 map 过程中需要注意的几点:

  • map 是无序的,每次打印出来的 map 都会不一样,它不能通过 index 获取,而必须通过 key 获取
  • map 的长度是不固定的,也就是和 slice 一样,也是一种引用类型
  • 内置的 len 函数同样适用于 map,返回 map 拥有的 key 的数量
  • map 的值可以很方便的修改,通过 numbers["one"]=11 可以很容易的把key为 one 的字典值改为 11
  • map 和其他基本型别不同,它不是 thread-safe,在多个 go-routine 存取时,必须使用 mutex lock 机制

map 的初始化可以通过 key:val 的方式初始化值,同时 map 内置有判断是否存在 key 的方式

通过 delete 删除 map 的元素:

1
2
3
4
5
6
7
8
9
10
// 初始化一个字典
rating := map[string]float32{"C":5, "Go":4.5, "Python":4.5, "C++":2 }
// map有两个返回值,第二个返回值,如果不存在key,那么ok为false,如果存在ok为true
csharpRating, ok := rating["C#"]
if ok {
fmt.Println("C# is in the map and its rating is ", csharpRating)
} else {
fmt.Println("We have no rating associated with C# in the map")
}
delete(rating, "C") // 删除key为C的元素

上面说过了,map 也是一种引用类型,如果两个 map 同时指向一个底层,那么一个改变,另一个也相应的改变:

1
2
3
4
m := make(map[string]string)
m["Hello"] = "Bonjour"
m1 := m
m1["Hello"] = "Salut" // 现在m["hello"]的值已经是Salut了

1-2.8 makenew 操作

make 用于内建类型(mapslicechannel)的内存分配。new 用于各种类型的内存分配。

内建函数 new 本质上说跟其它语言中的同名函数功能一样:new(T) 分配了零值填充的 T 类型的内存空间,并且返回其地址,即一个 *T 类型的值。用 Go 的术语说,它返回了一个指针,指向新分配的类型 T 的零值。有一点非常重要:

new 返回指针。

内建函数 make(T, args)new(T) 有着不同的功能,make 只能创建 slicemapchannel,并且返回一个有初始值(非零)的 T 类型,而不是 *T。本质来讲,导致这三个类型有所不同的原因是指向数据结构的引用在使用前必须被初始化。例如,一个 slice,是一个包含指向数据(内部array)的指针、长度和容量的三项描述符;在这些项目被初始化之前,slicenil。对于 slicemapchannel 来说,make 初始化了内部的数据结构,填充适当的值。

make 返回初始化后的(非零)值。

举一个例子,在创建 map 的时候,如果使用映射字面量的时候,系统会为其分配内存。

映射字面量(map literal)是一种在 Go 语言中创建和初始化映射的快捷方式。它允许你在一行代码中定义一个映射,并且可以立即填充一些初始的键值对。映射字面量是由大括号包围的键值对列表,其中每个键后面跟着一个冒号和对应的值。

1
rating := map[string]float32{"C": 5, "Go": 4.5, "Python": 4.5, "C++": 2}

但如果需要先创建一个空的 map 然后添加内容,就需要使用 make 创建内存。

1
2
3
4
5
6
7
8
9
package main

import "fmt"

func main() {
nums := make(map[string]int) // 使用 := 来声明并初始化 nums
nums["one"] = 1
fmt.Println(nums)
}

new 需要通过指针访问

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import "fmt"

func main() {
// 创建一个int类型的指针,指向一个零值的int
p := new(int)
fmt.Println(*p) // 输出: 0

// 分配并返回一个指向新分配的切片的指针,切片的长度和容量都是0
s := new([]int)
fmt.Println(*s) // 输出: []

// 分配并返回一个指向新分配的映射的指针,映射为空
m := new(map[string]int)
fmt.Println(*m) // 输出: map[]
}

1-2.9 零值

关于“零值”,所指并非是空值,而是一种“变量未填充前”的默认值,通常为0。

此处罗列部分类型的“零值”

1
2
3
4
5
6
7
8
9
10
11
int     0
int8 0
int32 0
int64 0
uint 0x0
rune 0 //rune的实际类型是 int32
byte 0x0 // byte的实际类型是 uint8
float32 0 //长度为 4 byte
float64 0 //长度为 8 byte
bool false
string ""

1-2.10 值类型和引用类型

在 Go 语言中,值类型和引用类型是数据类型的分类方式。

值类型(Value Types):

  • 值类型的变量直接存储值,变量之间传递时是值的副本。
  • Go 中的值类型包括:
    • 基本类型:int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, complex64, complex128, bool, string, rune (等同于int32, 表示一个Unicode码点)。
    • 复合类型:struct, array (数组)。

引用类型(Reference Types):

  • 引用类型的变量存储的是一个地址,这个地址指向内存中的实际数据。
  • Go 中的引用类型包括:
    • 指针类型(Pointers):*T,其中 T 是任意类型。
    • slice 切片:[]T,其中 T 是任意类型。
    • map 映射:map[K]V,其中 K 和 V 分别是键和值的类型。
    • channel 通道:chan T,其中 T 是任意类型。
    • 接口(Interfaces):interface{},接口可以包含任何类型的数据。

虽然 slice、map 和 channel 是引用类型,但它们本身在内存中的表示(即 slice、map 和 channel 的结构体)是值类型的,它们包含一个指向底层数据的指针。因此,当你复制一个 slice、map 或 channel 时,你实际上是在复制它们的头部信息,包括指向底层数据的指针,所以复制后的变量仍然指向同一块数据。

值类型在栈,引用类型在堆中:

  • 这是由 Go 语言的内存分配机制决定的。Go 语言的内存分配既高效又自动,它使用垃圾回收(GC)来管理内存。
  • 栈上的内存分配通常非常快,因为它们是顺序分配的,且栈上的内存不需要 GC,因为栈的内存管理是自动的,随着函数调用的结束,栈上的内存会自动释放。
  • 堆上的内存分配给引用类型提供了更大的灵活性,允许它们动态地改变大小,并且可以在它们的生命周期内被多个变量共享。堆上的内存需要GC来回收不再使用的内存。

1-3 流程控制

Go中流程控制分三大类:条件判断,循环控制和无条件跳转。

1-3.1 if

Go 里面if条件判断语句中不需要括号,如下代码所示

1
2
3
4
5
if x > 10 {
fmt.Println("x is greater than 10")
} else {
fmt.Println("x is less than 10")
}

Go 的 if 还有一个强大的地方就是条件判断语句里面允许声明一个变量,这个变量的作用域只能在该条件逻辑块内,其他地方就不起作用了,如下所示

1
2
3
4
5
6
// 计算获取值x,然后根据x返回的大小,判断是否大于10。
if x := computedValue(); x > 10 {
fmt.Println("x is greater than 10")
} else {
fmt.Println("x is less than 10")
}

多个条件的时候如下所示:

1
2
3
4
5
6
7
if integer == 3 {
fmt.Println("The integer is equal to 3")
} else if integer < 3 {
fmt.Println("The integer is less than 3")
} else {
fmt.Println("The integer is greater than 3")
}

1-3.2 goto

Go 有 goto 语句——请明智地使用它。goto 语句会无条件的跳转到一个标签,通常都会搭配标签使用。用 goto 跳转到必须在当前函数内定义的标签。例如假设这样一个循环:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import "fmt"

func main() {
i := 0
LOOP: // 这是我们定义的标签
for i < 10 {
if i == 5 {
i++ // 避免无限循环
goto LOOP // 跳回LOOP标签处
}
fmt.Printf("i: %d\n", i)
i++
}
}

在这个例子中,当 i 等于5时,goto 语句会导致程序跳回到 LOOP 标签的位置,而不是继续执行循环的下一部分。这会导致 i 为5的那一次迭代被跳过。

虽然 goto 在 Go 语言中是合法的,但它通常不推荐使用,因为滥用 goto 会导致代码结构混乱,难以阅读和维护。

1-3.3 for

Go里面最强大的一个控制逻辑就是 for,它既可以用来循环读取数据,又可以当作 while 来控制逻辑,还能迭代操作。它的语法如下:

1
2
3
for expression1; expression2; expression3 {
//...
}

expression1expression2expression3 都是表达式,其中 expression1expression3 是变量声明或者函数调用返回值之类的,expression2 是用来条件判断,expression1 在循环开始之前调用,expression3 在每轮循环结束之时调用。

看看下面的例子吧:有点像C。

1
2
3
4
5
6
7
8
9
10
package main
import "fmt"
func main(){
sum := 0;
for index:=0; index < 10 ; index++ {
sum += index
}
fmt.Println("sum is equal to ", sum)
}
// 输出:sum is equal to 45

有些时候需要进行多个赋值操作,由于 Go 里面没有,操作符,那么可以使用平行赋值 i, j = i+1, j-1

有些时候如果忽略 expression1expression3

1
2
3
4
sum := 1
for ; sum < 1000; {
sum += sum
}

其中 ; 也可以省略,那么就变成如下的代码了,这就是 while 的功能。

1
2
3
4
sum := 1
for sum < 1000 {
sum += sum
}

在循环里面有两个关键操作 breakcontinue ,break 操作是跳出当前循环,continue 是跳过本次循环。当嵌套过深的时候,break 可以配合标签使用,即跳转至标签所指定的位置,详细参考如下例子:

1
2
3
4
5
6
7
8
for index := 10; index>0; index-- {
if index == 5{
break // 或者continue
}
fmt.Println(index)
}
// break打印出来10、9、8、7、6
// continue打印出来10、9、8、7、6、4、3、2、1

breakcontinue 还可以跟着标号,用来跳到多重循环中的外层循环

for 配合 range 可以用于读取 slicemap 的数据:

1
2
3
4
for k, v:=range map {
fmt.Println("map's key:",k)
fmt.Println("map's val:",v)
}

由于 Go 支持 “多值返回”, 而对于“声明而未被调用”的变量, 编译器会报错, 在这种情况下, 可以使用 _ 来丢弃不需要的返回值

例如

1
2
3
for _, v := range map{
fmt.Println("map's val:", v)
}

1-3.4 switch

有些时候需要写很多的 if-else 来实现一些逻辑处理,这个时候代码看上去就很丑很冗长,而且也不易于以后的维护,这个时候 switch 就能很好的解决这个问题。它的语法如下

1
2
3
4
5
6
7
8
9
10
switch sExpr {
case expr1:
some instructions
case expr2:
some other instructions
case expr3:
some other instructions
default:
other code
}

sExpr expr1expr2expr3 的类型必须一致。Go 的 switch 非常灵活,表达式不必是常量或整数,执行的过程从上至下,直到找到匹配项;而如果 switch 没有表达式,它会匹配true

1
2
3
4
5
6
7
8
9
10
11
i := 10
switch i {
case 1:
fmt.Println("i is equal to 1")
case 2, 3, 4:
fmt.Println("i is equal to 2, 3 or 4")
case 10:
fmt.Println("i is equal to 10")
default:
fmt.Println("All I know is that i is an integer")
}

在第5行中,把很多值聚合在了一个 case 里面,同时,Go 里面 switch 默认相当于每个 case 最后带有 break,匹配成功后不会自动向下执行其他case,而是跳出整个 switch, 但是可以使用 fallthrough 强制执行后面的 case 代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
integer := 6
switch integer {
case 4:
fmt.Println("The integer was <= 4")
fallthrough
case 5:
fmt.Println("The integer was <= 5")
fallthrough
case 6:
fmt.Println("The integer was <= 6")
fallthrough
case 7:
fmt.Println("The integer was <= 7")
fallthrough
case 8:
fmt.Println("The integer was <= 8")
fallthrough
default:
fmt.Println("default case")
}

上面的程序将输出

1
2
3
4
The integer was <= 6
The integer was <= 7
The integer was <= 8
default case

1-4 函数

1-4.1 函数的定义

函数是 Go 里面的核心设计,它通过关键字 func 来声明,它的格式如下:

1
2
3
4
5
func funcName(input1 type1, input2 type2) (output1 type1, output2 type2) {
//这里是处理逻辑代码
//返回多个值
return value1, value2
}

上面的代码可以看出

  • 关键字 func 用来声明一个函数 funcName
  • 函数可以有一个或者多个参数,每个参数后面带有类型,通过 , 分隔
  • 函数可以返回多个值
  • 上面返回值声明了两个变量 output1output2,如果不想声明也可以,直接就两个类型
  • 如果只有一个返回值且不声明返回值变量,那么可以省略 包括返回值的括号
  • 如果没有返回值,那么就直接省略最后的返回信息
  • 如果有返回值, 那么必须在函数的外层添加 return 语句

下面来看一个实际应用函数的例子(用来计算Max值)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main
import "fmt"
// 返回a、b中最大值.
func max(a, b int) int {
if a > b {
return a
}
return b
}
func main() {
x := 3
y := 4
z := 5
max_xy := max(x, y) //调用函数max(x, y)
max_xz := max(x, z) //调用函数max(x, z)
fmt.Printf("max(%d, %d) = %d\n", x, y, max_xy)
fmt.Printf("max(%d, %d) = %d\n", x, z, max_xz)
fmt.Printf("max(%d, %d) = %d\n", y, z, max(y,z)) // 也可在这直接调用它
}

上面这个里面可以看到 max 函数有两个参数,它们的类型都是 int,那么第一个变量的类型可以省略(即 a,b int,而非 a int, b int),默认为离它最近的类型,同理多于2个同类型的变量或者返回值。同时注意到它的返回值就是一个类型,这个就是省略写法。

1-4.2 多个返回值

直接看例子

1
2
3
4
5
6
7
8
9
10
11
12
13
package main
import "fmt"
//返回 A+B 和 A*B
func SumAndProduct(A, B int) (int, int) {
return A+B, A*B
}
func main() {
x := 3
y := 4
xPLUSy, xTIMESy := SumAndProduct(x, y)
fmt.Printf("%d + %d = %d\n", x, y, xPLUSy)
fmt.Printf("%d * %d = %d\n", x, y, xTIMESy)
}

上面的例子可以看到直接返回了两个参数,当然也可以命名返回参数的变量,这个例子里面只是用了两个类型,也可以改成如下这样的定义,然后返回的时候不用带上变量名,因为直接在函数里面初始化了。

但如果函数是导出的(首字母大写),官方建议:最好命名返回值,因为不命名返回值,虽然使得代码更加简洁了,但是会造成生成的文档可读性差。

1
2
3
4
5
func SumAndProduct(A, B int) (add int, Multiplied int) {
add = A+B
Multiplied = A*B
return
}

1-4.3 变参

Go函数支持变参。接受变参的函数是有着不定数量的参数的。为了做到这点,首先需要定义函数使其接受变参:

1
func myfunc(arg ...int) {}

arg ...int 告诉Go这个函数接受不定数量的参数。注意,这些参数的类型全部是 int。在函数体中,变量 arg 是一个 intslice

1
2
3
for _, n := range arg {
fmt.Printf("And the number is: %d\n", n)
}

编写一个函数,使用变参并返回相加的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import "fmt"

// sum 接收可变数量的 int 参数
func sum(numbers ...int) int {
total := 0
for _, number := range numbers {
total += number
}
return total
}

func main() {
// 调用 sum 函数,传入不同的 int 参数
result1 := sum(1, 2, 3, 4, 5)
result2 := sum(10, 20, 30)
result3 := sum() // 传入0个参数

// 打印结果
fmt.Println("Result 1:", result1) // 输出: Result 1: 15
fmt.Println("Result 2:", result2) // 输出: Result 2: 60
fmt.Println("Result 3:", result3) // 输出: Result 3: 0
}

1-4.4 传值与传指针

传一个参数值到被调用函数里面时,实际上是传了这个值的一份 copy,当在被调用函数中修改参数值的时候,调用函数中相应实参不会发生任何变化,因为数值变化只作用在 copy 上。

为了验证上面的说法,来看一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main
import "fmt"
//简单的一个函数,实现了参数+1的操作
func add1(a int) int {
a = a+1 // 改变了a的值
return a //返回一个新值
}
func main() {
x := 3
fmt.Println("x = ", x) // 应该输出 "x = 3"
x1 := add1(x) //调用add1(x)
fmt.Println("x+1 = ", x1) // 应该输出"x+1 = 4"
fmt.Println("x = ", x) // 应该输出"x = 3"
}

虽然调用了 add1 函数,并且在 add1 中执行 a = a+1 操作,但是上面例子中 x 变量的值没有发生变化

如果真的需要传这个x本身,该怎么办呢?

这就牵扯到了所谓的指针。变量在内存中是存放于一定地址上的,修改变量实际是修改变量地址处的内存。只有 add1 函数知道 x 变量所在的地址,才能修改 x 变量的值。所以需要将 x 所在地址 &x 传入函数,并将函数的参数的类型由 int 改为 *int,即改为指针类型,才能在函数中修改 x 变量的值。此时参数仍然是按 copy 传递的,只是 copy 的是一个指针。请看下面的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main
import "fmt"
//简单的一个函数,实现了参数+1的操作
func add1(a *int) int { // 请注意,
*a = *a+1 // 修改了a的值
return *a // 返回新值
}
func main() {
x := 3
fmt.Println("x = ", x) // 应该输出 "x = 3"
x1 := add1(&x) // 调用 add1(&x) 传x的地址
fmt.Println("x+1 = ", x1) // 应该输出 "x+1 = 4"
fmt.Println("x = ", x) // 应该输出 "x = 4"
}

这样,就达到了修改 x 的目的。那么到底传指针有什么好处呢?

  • 传指针使得多个函数能操作同一个对象。

  • 传指针比较轻量级 (8bytes),只是传内存地址,可以用指针传递体积大的结构体。如果用参数值传递的话, 在每次 copy 上面就会花费相对较多的系统开销(内存和时间)。所以当要传递大的结构体的时候,用指针是一个明智的选择。

  • Go 语言中 channelslicemap 这三种类型的实现机制类似指针,所以可以直接传递,而不用取地址后传递指针。(注:若函数需改变 slice 的长度,则仍需要取地址传递指针)

1-4.5 defer

Go 语言中延迟(defer)语句,可以在函数中添加多个 defer 语句。当函数执行到最后时,这些 defer 语句会按照逆序执行,最后该函数返回。特别是当进行一些打开资源的操作时,遇到错误需要提前返回,在返回前需要关闭相应的资源,不然很容易造成资源泄露等问题。如下代码所示,一般写打开一个资源是这样操作的:

这样就可以在开启一个服务的时候,立即使用 defer 关闭服务,这样在程序结束的时候就会逆序自动关闭。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func ReadWrite() bool {
file.Open("file")
// 做一些工作
if failureX {
file.Close()
return false
}
if failureY {
file.Close()
return false
}
file.Close()
return true
}

发送 HTTP 请求后,可以直接使用 defer 关闭 body。

1
2
3
4
5
6
7
8
9
10
11
req, err := http.NewRequest("GET", url, nil)
if err != nil {
log.Error(err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
log.Error(err)
}
defer resp.Body.Close()

上面有很多重复的代码,Go的 defer 有效解决了这个问题。使用它后,不但代码量减少了很多,而且程序变得更优雅。在 defer 后指定的函数会在函数退出前调用。

1
2
3
4
5
6
7
8
9
10
11
func ReadWrite() bool {
file.Open("file")
defer file.Close()
if failureX {
return false
}
if failureY {
return false
}
return true
}

如果有很多调用 defer,那么 defer 是采用后进先出模式,所以如下代码会输出 4 3 2 1 0

1
2
3
for i := 0; i < 5; i++ {
defer fmt.Printf("%d ", i)
}

通常来说,defer 会用在释放数据库连接,关闭文件等需要在函数结束时处理的操作。

1-4.6 函数作为值、类型*

在 Go 中函数也是一种变量,可以通过 type 来定义它,它的类型就是所有拥有相同的参数,相同的返回值的一种类型。

1
type typeName func(input1 inputType1 , input2 inputType2 [, ...]) (result1 resultType1 [, ...])

函数作为类型到底有什么好处呢?那就是可以把这个类型的函数当做值来传递。

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
package main
import "fmt"
type testInt func(int) bool // 声明了一个函数类型
func isOdd(integer int) bool {
if integer%2 == 0 {
return false
}
return true
}
func isEven(integer int) bool {
if integer%2 == 0 {
return true
}
return false
}
// 声明的函数类型在这个地方当做了一个参数
func filter(slice []int, f testInt) []int {
var result []int
for _, value := range slice {
if f(value) {
result = append(result, value)
}
}
return result
}
func main(){
slice := []int {1, 2, 3, 4, 5, 7}
fmt.Println("slice = ", slice)
odd := filter(slice, isOdd) // 函数当做值来传递了
fmt.Println("Odd elements of slice are: ", odd)
even := filter(slice, isEven) // 函数当做值来传递了
fmt.Println("Even elements of slice are: ", even)
}

函数当做值和类型在写一些通用接口的时候非常有用,通过上面例子看到 testInt 这个类型是一个函数类型,然后两个 filter 函数的参数和返回值与 testInt 类型是一样的,但是可以实现很多种的逻辑,这样使得程序变得非常的灵活。

1-4.7 Panic 和 Recover

Go 没有像 Java 那样的异常机制,它不能抛出异常,而是使用了 panicrecover 机制。一定要记住,应当把它作为最后的手段来使用,也就是说,代码中应当没有,或者很少有 panic 的东西。这是个强大的工具,请明智地使用它。

Panic

是一个内建函数,可以中断原有的控制流程,进入一个 panic 状态中。当函数 F 调用 panic,函数F的执行被中断,但是 F 中的延迟函数会正常执行,然后F返回到调用它的地方。在调用的地方,F 的行为就像调用了 panic。这一过程继续向上,直到发生 panicgoroutine 中所有调用的函数返回,此时程序退出。panic 可以直接调用 panic 产生。也可以由运行时错误产生,例如访问越界的数组。

Recover

是一个内建的函数,可以让进入 panic 状态的 goroutine 恢复过来。recover 仅在延迟函数中有效。在正常的执行过程中,调用 recover 会返回nil,并且没有其它任何效果。如果当前的 goroutine 陷入 panic 状态,调用 recover 可以捕获到 panic 的输入值,并且恢复正常的执行。

下面这个函数演示了如何在过程中使用 panic

1
2
3
4
5
6
var user = os.Getenv("USER")
func init() {
if user == "" {
panic("no value for $USER")
}
}

下面这个函数检查作为其参数的函数在执行时是否会产生 panic

1
2
3
4
5
6
7
8
9
func throwsPanic(f func()) (b bool) {
defer func() {
if x := recover(); x != nil {
b = true
}
}()
f() //执行函数f,如果f中出现了panic,那么就可以恢复回来
return
}

注意:

defer 必须在 panic 语句之前。

recover 必须配合 defer 使用。

1-4.8 main 函数和 init 函数

Go里面有两个保留的函数:init 函数(能够应用于所有的 package)和 main 函数(只能应用于 package main)。这两个函数在定义时不能有任何的参数和返回值。虽然一个 package 里面可以写任意多个 init 函数,但这无论是对于可读性还是以后的可维护性来说,强烈建议用户在一个 package 中每个文件只写一个init函数。

Go程序会自动调用 init()main(),所以不需要在任何地方调用这两个函数。每个 package 中的 init 函数都是可选的,但 package main 就必须包含一个 main 函数。

程序的初始化和执行都起始于 main 包。如果 main 包还导入了其它的包,那么就会在编译时将它们依次导入。有时一个包会被多个包同时导入,那么它只会被导入一次(例如很多包可能都会用到 fmt 包,但它只会被导入一次,因为没有必要导入多次)。当一个包被导入时,如果该包还导入了其它的包,那么会先将其它包导入进来,然后再对这些包中的包级常量和变量进行初始化,接着执行 init 函数(如果有的话),依次类推。等所有被导入的包都加载完毕了,就会开始对 main 包中的包级常量和变量进行初始化,然后执行 main 包中的 init 函数(如果存在的话),最后执行 main 函数。

1-4.9 import

在写Go代码的时候经常用到 import 这个命令用来导入包文件,经常看到的方式参考如下:

1
2
3
import(
"fmt"
)

然后代码里面可以通过如下的方式调用

1
fmt.Println("hello world")

上面这个 fmt 是 Go 语言的标准库,其实是去 GOROOT 环境变量指定目录下去加载该模块,当然 Go 的 import 还支持如下两种方式来加载自己写的模块:

1、相对路径

1
import "./model" //当前文件同一目录的model目录,但是不建议这种方式来import

2、绝对路径

1
import "shorturl/model" //加载gopath/src/shorturl/model模块

上面展示了一些 import 常用的几种方式,但是还有一些

特殊的 import

1、点操作

有时候会看到如下的方式导入包

1
2
3
import(
. "fmt"
)

这个点操作的含义就是这个包导入之后在调用这个包的函数时,可以省略前缀的包名,也就是前面调用的 fmt.Println(“hello world”) 可以省略的写成Println("hello world")

2、别名操作

别名操作顾名思义可以把包命名成另一个用起来容易记忆的名字

1
2
3
import(
f "fmt"
)

别名操作的话调用包函数时前缀变成了前缀,即 f.Println("hello world")

3、_操作

这个操作经常是让很多人费解的一个操作符,请看下面这个 import

1
2
3
4
import (
"database/sql"
_ "github.com/ziutek/mymysql/godrv"
)

_ 操作其实是引入该包,而不直接使用包里面的函数,而是调用了该包里面的 init函数

Go 1.11 版本引入了模块(module)系统,可以查看十二章,使用 go mod 管理包。

1-5 struct 类型

1-5.1 struct 类型的声明

Go 语言中,也和 C 或者其他语言一样,可以声明新的类型,作为其它类型的属性或字段的容器。例如,可以创建一个自定义类型 person 代表一个人的实体。这个实体拥有属性:姓名和年龄。这样的类型称之 struct。如下代码所示:

1
2
3
4
type person struct {
name string
age int
}

声明一个 struct 如此简单,上面的类型包含有两个字段

  • 一个 string 类型的字段 name,用来保存用户名称这个属性
  • 一个 int 类型的字段 age,用来保存用户年龄这个属性

使用 struct 看下面的代码

1
2
3
4
5
6
7
8
type person struct {
name string
age int
}
var P person // P 现在就是 person 类型的变量了
P.name = "Astaxie" // 赋值"Astaxie"给 P 的 name 属性.
P.age = 25 // 赋值"25"给变量 P 的 age 属性
fmt.Printf("The person's name is %s", P.name) // 访问 P 的 name 属性.

除了上面这种 P 的声明使用之外,还有另外几种声明使用方式:

  1. 按照顺序提供初始化值
    P := person{"Tom", 25}

  2. 通过field:value的方式初始化,这样可以任意顺序
    P := person{age:24, name:"Tom"}

  3. 当然也可以通过new函数分配一个指针,此处P的类型为 *person
    P := new(person)

看一个完整的使用 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
26
27
28
29
30
31
32
33
package main
import "fmt"
// 声明一个新的类型
type person struct {
name string
age int
}
// 比较两个人的年龄,返回年龄大的那个人,并且返回年龄差
// struct也是传值的
func Older(p1, p2 person) (person, int) {
if p1.age>p2.age { // 比较p1和p2这两个人的年龄
return p1, p1.age-p2.age
}
return p2, p2.age-p1.age
}
func main() {
var tom person
// 赋值初始化
tom.name, tom.age = "Tom", 18
// 两个字段都写清楚的初始化
bob := person{age:25, name:"Bob"}
// 按照struct定义顺序初始化值
paul := person{"Paul", 43}
tb_Older, tb_diff := Older(tom, bob)
tp_Older, tp_diff := Older(tom, paul)
bp_Older, bp_diff := Older(bob, paul)
fmt.Printf("Of %s and %s, %s is older by %d years\n",
tom.name, bob.name, tb_Older.name, tb_diff)
fmt.Printf("Of %s and %s, %s is older by %d years\n",
tom.name, paul.name, tp_Older.name, tp_diff)
fmt.Printf("Of %s and %s, %s is older by %d years\n",
bob.name, paul.name, bp_Older.name, bp_diff)
}

1-5.2 struct 的匿名字段

定义的时候是字段名与其类型一一对应,实际上Go支持只提供类型,而不写字段名的方式,也就是匿名字段,也称为嵌入字段。

当匿名字段是一个 struct 的时候,那么这个 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
26
27
28
29
30
31
32
package main
import "fmt"
type Human struct {
name string
age int
weight int
}
type Student struct {
Human // 匿名字段,那么默认Student就包含了Human的所有字段
speciality string
}
func main() {
// 初始化一个学生
mark := Student{Human{"Mark", 25, 120}, "Computer Science"}
// 访问相应的字段
fmt.Println("His name is ", mark.name)
fmt.Println("His age is ", mark.age)
fmt.Println("His weight is ", mark.weight)
fmt.Println("His speciality is ", mark.speciality)
// 修改对应的备注信息
mark.speciality = "AI"
fmt.Println("Mark changed his speciality")
fmt.Println("His speciality is ", mark.speciality)
// 修改他的年龄信息
fmt.Println("Mark become old")
mark.age = 46
fmt.Println("His age is", mark.age)
// 修改他的体重信息
fmt.Println("Mark is not an athlet anymore")
mark.weight += 60
fmt.Println("His weight is", mark.weight)
}

看到Student访问属性age和name的时候,就像访问自己所有用的字段一样,匿名字段就是这样,能够实现字段的继承。student还能访问Human这个字段作为字段名。请看下面的代码。

1
2
mark.Human = Human{"Marcus", 55, 220}
mark.Human.age -= 1

通过匿名访问和修改字段相当的有用,但是不仅仅是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
26
27
28
29
30
31
32
package main
import "fmt"
type Skills []string
type Human struct {
name string
age int
weight int
}
type Student struct {
Human // 匿名字段,struct
Skills // 匿名字段,自定义的类型string slice
int // 内置类型作为匿名字段
speciality string
}
func main() {
// 初始化学生Jane
jane := Student{Human:Human{"Jane", 35, 100}, speciality:"Biology"}
// 现在访问相应的字段
fmt.Println("Her name is ", jane.name)
fmt.Println("Her age is ", jane.age)
fmt.Println("Her weight is ", jane.weight)
fmt.Println("Her speciality is ", jane.speciality)
// 修改他的skill技能字段
jane.Skills = []string{"anatomy"}
fmt.Println("Her skills are ", jane.Skills)
fmt.Println("She acquired two new ones ")
jane.Skills = append(jane.Skills, "physics", "golang")
fmt.Println("Her skills now are ", jane.Skills)
// 修改匿名内置类型字段
jane.int = 3
fmt.Println("Her preferred number is", jane.int)
}

从上面例子看出来 struct 不仅仅能够将 struct 作为匿名字段,自定义类型、内置类型都可以作为匿名字段,而且可以在相应的字段上面进行函数操作(如例子中的append)。

这里有一个问题:如果 human 里面有一个字段叫做 phone,而 student 也有一个字段叫做 phone,那么该怎么办呢?

Go 里面很简单的解决了这个问题,最外层的优先访问,也就是当通过 student.phone 访问的时候,是访问 student 里面的字段,而不是 human 里面的字段。

这样就允许去重载通过匿名字段继承的一些字段,当然如果想访问重载后对应匿名类型里面的字段,可以通过匿名字段名来访问。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main
import "fmt"
type Human struct {
name string
age int
phone string // Human类型拥有的字段
}
type Employee struct {
Human // 匿名字段Human
speciality string
phone string // 雇员的phone字段
}
func main() {
Bob := Employee{Human{"Bob", 34, "777-444-XXXX"}, "Designer", "333-222"}
fmt.Println("Bob's work phone is:", Bob.phone)
// 如果要访问Human的phone字段
fmt.Println("Bob's personal phone is:", Bob.Human.phone)
}

1-6 method

函数的另一种形态,带有接收者的函数,称为 method

1-6.1 method

现在假设有这么一个场景,定义了一个 struct 叫做长方形,现在想要计算他的面积,那么按照一般的思路应该会用下面的方式来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main
import "fmt"
type Rectangle struct {
width, height float64
}
func area(r Rectangle) float64 {
return r.width*r.height
}
func main() {
r1 := Rectangle{12, 2}
r2 := Rectangle{9, 4}
fmt.Println("Area of r1 is: ", area(r1))
fmt.Println("Area of r2 is: ", area(r2))
}

这段代码可以计算出来长方形的面积,但是 area() 不是作为 Rectangle 的方法实现的(类似面向对象里面的方法),而是将 Rectangle 的对象(如r1,r2)作为参数传入函数计算面积的。

这样实现当然没有问题,但是当需要增加圆形、正方形、五边形甚至其它多边形的时候,想计算他们的面积的时候怎么办?那就只能增加新的函数,但是函数名就必须要跟着换了,变成 area_rectangle, area_circle, area_triangle...

椭圆代表函数, 而这些函数并不从属于 struct(或者以面向对象的术语来说,并不属于class),他们是单独存在于 struct 外围,而非在概念上属于某个 struct的。

很显然,这样的实现并不优雅,并且从概念上来说”面积”是”形状”的一个属性,它是属于这个特定的形状的,就像长方形的长和宽一样。

基于上面的原因所以就有了 method 的概念,method 是附属在一个给定的类型上的,他的语法和函数的声明语法几乎一样,只是在 func 后面增加了一个 receiver (也就是 method 所依从的主体)。

用上面提到的形状的例子来说,method area() 是依赖于某个形状(比如说 Rectangle )来发生作用的。Rectangle.area() 的发出者是 Rectangle, area()是属于 Rectangle 的方法,而非一个外围函数。

更具体地说,Rectangle 存在字段 height 和 width, 同时存在方法 area(), 这些字段和方法都属于 Rectangle。

method的语法如下:

1
func (r ReceiverType) funcName(parameters) (results)

下面用最开始的例子用method来实现:

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
package main
import (
"fmt"
"math"
)
type Rectangle struct {
width, height float64
}
type Circle struct {
radius float64
}
func (r Rectangle) area() float64 {
return r.width*r.height
}
func (c Circle) area() float64 {
return c.radius * c.radius * math.Pi
}
func main() {
r1 := Rectangle{12, 2}
r2 := Rectangle{9, 4}
c1 := Circle{10}
c2 := Circle{25}
fmt.Println("Area of r1 is: ", r1.area())
fmt.Println("Area of r2 is: ", r2.area())
fmt.Println("Area of c1 is: ", c1.area())
fmt.Println("Area of c2 is: ", c2.area())
}

在使用 method 的时候重要注意几点

  • 虽然 method 的名字一模一样,但是如果接收者不一样,那么 method 就不一样。

  • method 里面可以访问接收者的字段。

  • 调用 method 通过.访问,就像 struct 里面访问字段一样。

在上例,method area() 分别属于 Rectangle 和 Circle, 于是他们的 Receiver 就变成了 Rectangle 和 Circle, 或者说,这个 area() 方法 是由 Rectangle/Circle 发出的。

那是不是 method 只能作用在 struct 上面呢?当然不是,他可以定义在任何自定义的类型、内置类型、struct 等各种类型上面。

1
type typeName typeLiteral

下面是一个申明自定义类型的代码

1
2
3
4
5
6
7
8
9
type ages int
type money float32
type months map[string]int
m := months {
"January":31,
"February":28,
...
"December":31,
}

这样就可以在自己的代码里面定义有意义的类型了,实际上只是一个定义了一个别名,有点类似于 c 中的 typedef,例如上面 ages 替代了 int,回到 method 可以在任何的自定义类型中定义任意多的 method

一个更加简单的例子,不同对应的接收对象会执行不同的内容。更像 Cat 是一个类,Say 像是类里面的方法。

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"

type Cat struct{}

func (c Cat) Say() string { return "喵喵喵" }

type Dog struct{}

func (d Dog) Say() string { return "汪汪汪" }

func main() {
c := Cat{}
fmt.Println("猫:", c.Say())
d := Dog{}
fmt.Println("狗:", d.Say())
}

//猫: 喵喵喵
//狗: 汪汪汪

接下来让看一个复杂一点的例子

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
package main

import "fmt"

const (
WHITE = iota
BLACK
BLUE
RED
YELLOW
)

type Color byte

type Box struct {
width, height, depth float64
color Color
}

type BoxList []Box //a slice of boxes

func (b Box) Volume() float64 {
return b.width * b.height * b.depth
}

func (b *Box) SetColor(c Color) {
b.color = c
}

func (bl BoxList) BiggestColor() Color {
v := 0.00
k := Color(WHITE)
for _, b := range bl {
if bv := b.Volume(); bv > v {
v = bv
k = b.color
}
}
return k
}

func (bl BoxList) PaintItBlack() {
for i := range bl {
bl[i].SetColor(BLACK)
}
}

func (c Color) String() string {
strings := []string{"WHITE", "BLACK", "BLUE", "RED", "YELLOW"}
return strings[c]
}

func main() {
boxes := BoxList{
Box{4, 4, 4, RED},
Box{10, 10, 1, YELLOW},
Box{1, 1, 20, BLACK},
Box{10, 10, 1, BLUE},
Box{10, 30, 1, WHITE},
Box{20, 20, 20, YELLOW},
}
fmt.Printf("We have %d boxes in our set\n", len(boxes))
fmt.Println("The volume of the first one is", boxes[0].Volume(), "cm³")
fmt.Println("The color of the last one is", boxes[len(boxes)-1].color.String())
fmt.Println("The biggest one is", boxes.BiggestColor().String())
fmt.Println("Let's paint them all black")
boxes.PaintItBlack()
fmt.Println("The color of the second one is", boxes[1].color.String())
fmt.Println("Obviously, now, the biggest one is", boxes.BiggestColor().String())
}

//We have 6 boxes in our set
//The volume of the first one is 64 cm³
//The color of the last one is YELLOW
//The biggest one is YELLOW
//Let's paint them all black
//The color of the second one is BLACK
//Obviously, now, the biggest one is BLACK

上面的代码通过 const 定义了一些常量,然后定义了一些自定义类型

  • Color 作为 byte 的别名。

  • 定义了一个 struct:Box,含有三个长宽高字段和一个颜色属性。

  • 定义了一个 slice:BoxList,含有 Box。

然后以上面的自定义类型为接收者定义了一些 method

  • Volume() 定义了接收者为 Box,返回 Box 的容量。

  • SetColor(c Color),把 Box 的颜色改为 c。

  • BiggestColor() 定在在 BoxList 上面,返回 list 里面容量最大的颜色。

  • PaintItBlack() 把 BoxList 里面所有 Box 的颜色全部变成黑色。

  • String()定义在 Color 上面,返回 Color 的具体颜色(字符串格式)。

1-6.2 指针作为 receiver

现在让回过头来看看 SetColor 这个 method,它的 receiver 是一个指向 Box 的指针,可以使用 *Box

1
2
3
func (b *Box) SetColor(c Color) {
b.color = c
}

定义 SetColo r的真正目的是想改变这个 Box 的颜色,如果不传 Box 的指针,那么 SetColor 接受的其实是 Box 的一个 copy,也就是说 method 内对于颜色值的修改,其实只作用于 Box 的 copy,而不是真正的 Box。所以需要传入指针。

这里也许会问 SetColor 函数里面应该这样定义 *b.Color=c,而不是 b.Color=c,需要读取到指针相应的值。

其实 Go 里面这两种方式都是正确的,当用指针去访问相应的字段时(虽然指针没有任何的字段),Go 知道要通过指针去获取这个值。PaintItBlack 里面调用 SetColor 的时候是不是应该写成 (&bl[i]).SetColor(BLACK),因为 SetColor 的 receiver 是 *Box,而不是 Box。这两种方式都可以,因为 Go 知道 receiver 是指针,自动转换了。

也就是说:

如果一个 method 的 receiver 是 *T,可以在一个 T 类型的实例变量 V 上面调用这个 method,而不需要 &V 去调用这个 method

类似的

如果一个 method 的 receiver 是 T,可以在一个 *T 类型的变量 P 上面调用这个 method,而不需要 *P 去调用这个 method。

所以不用担心是调用的指针的 method 还是不是指针的 method。

1-6.3 method 继承

通过字段的继承的学习,发现 Go 的一个神奇之处,method 也是可以继承的。如果匿名字段实现了一个 method,那么包含这个匿名字段的 struct 也能调用该 method。来看下面这个例子,对更底层的 Human 的方法,也可以用于在其上层的结构体中。(人会说话,学生也会说话)

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
package main
import "fmt"
type Human struct {
name string
age int
phone string
}
type Student struct {
Human //匿名字段
school string
}
type Employee struct {
Human //匿名字段
company string
}
//在human上面定义了一个method
func (h *Human) SayHi() {
fmt.Printf("Hi, I am %s you can call me on %s\n", h.name, h.phone)
}

func main() {
mark := Student{Human{"Mark", 25, "222-222-YYYY"}, "MIT"}
sam := Employee{Human{"Sam", 45, "111-888-XXXX"}, "Golang Inc"}
mark.SayHi()
sam.SayHi()
}

1-6.4 method 重写

上面的例子中,如果 Employee 想要实现自己的 SayHi,怎么办?和匿名字段冲突一样的道理,可以在 Employee 上面定义一个 method,重写了匿名字段的方法。请看下面的例子。(人会说话,员工也会说话,虽然员工也是人,但是他自己的属性优先级更高)

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
package main
import "fmt"
type Human struct {
name string
age int
phone string
}
type Student struct {
Human //匿名字段
school string
}
type Employee struct {
Human //匿名字段
company string
}
//Human 定义 method
func (h *Human) SayHi() {
fmt.Printf("Hi, I am %s you can call me on %s\n", h.name, h.phone)
}
//Employee 的 method 重写 Human的method
func (e *Employee) SayHi() {
fmt.Printf("Hi, I am %s, I work at %s. Call me on %s\n", e.name,
e.company, e.phone) //Yes you can split into 2 lines here.
}
func main() {
mark := Student{Human{"Mark", 25, "222-222-YYYY"}, "MIT"}
sam := Employee{Human{"Sam", 45, "111-888-XXXX"}, "Golang Inc"}
mark.SayHi()
sam.SayHi()
}

通过这些内容,可以设计出基本的面向对象的程序了,但是 Go 里面的面向对象是如此的简单,没有任何的私有、公有关键字,通过大小写来实现(大写开头的为公有,小写开头的为私有),方法也同样适用这个原则。

1-6.5 工厂模式

在 Go 语言中,工厂模式是一种创建型设计模式,用于处理对象的创建逻辑。工厂模式允许你创建对象而不必指定创建对象的具体类,这样可以通过使用一个共同的接口来解耦对象的创建者和使用者。

在 Go 语言中,构造函数的概念与传统的面向对象语言略有不同。Go 使用结构体(struct)和接口(interface)来实现面向对象的概念,但它没有显式的构造函数语法。因此就可以通过工厂模式来解决这个问题,也相当于构造函数的功能。

如果在某个包中的结构体是小写开头(不允许直接调用),但又希望创建一个实例,可以通过该实例调用这个私有的结构体,就可以使用工厂模式。

这里定义了一个结构体 student,但是首字母小写代表不允许从外部直接调用,因此编写工厂模型函数 NewStudent 接收对应参数 n 和 s,并返回一个 student的指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
package model

type student struct {
Name string
Score float64
}

func NewStudent(n string, s float64) *student {
return &student{
Name: n,
Score: s,
}
}

在 main.go 中调用并打印

1
2
3
4
5
6
7
8
9
10
11
package main
import (
"fmt"
"xxxxx/model"
)

func main() {
var stu = model.NewStuident("tom", 88.8)
fmt.Println(stu.Name)
fmt.Println(stu.Score)
}

1-6.6 面向过程->面向对象

这部分将设计一个简单的面向过程的记账程序

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
package main

import "fmt"

func main() {
// 声明一个变量,保存用户的输入
key := ""
// 声明一个变量,控制循环是否结束
loop := true
// 定义账户余额
balance := 10000.0
// 每次收支的金额
money := 0.0
// 每次收支的说明
note := ""
// 是否有收支行为
flag := false
// 收支的详情,当有收支的时候,只需要对 detail 进行拼接即可
detail := "收支\t账户金额\t收支金额\t说明"
// 显示主菜单
for {
fmt.Println("\n-------------------家庭收支记账软件-------------------\n")
fmt.Println("1.收支明细")
fmt.Println("2.登记收入")
fmt.Println("3.登记支出")
fmt.Println("4.查询余额")
fmt.Println("5.退出软件")

fmt.Println("请选择 1-5")
// 接收参数 key
fmt.Scanln(&key)

switch key {
case "1":
fmt.Println("当前收支明细")
if flag {
fmt.Println(detail)
} else {
fmt.Println("当前没有收支记录")
}
case "2":
fmt.Println("开始登记收入")
fmt.Println("本次收入金额:")
fmt.Scanln(&money)
balance += money
fmt.Println("本次收入说明:")
fmt.Scanln(&note)
detail += fmt.Sprintf("\n收入\t%v\t%v\t%v", balance, money, note)
flag = true
case "3":
fmt.Println("开始登记支出")
fmt.Println("本次支出金额:")
fmt.Scanln(&money)
if balance > money {
balance -= money
fmt.Println("本次支出说明:")
fmt.Scanln(&note)
detail += fmt.Sprintf("\n支出\t%v\t%v\t%v", balance, money, note)
flag = true
} else {
fmt.Println("余额不足")
}
case "4":
fmt.Printf("当前余额%v:", balance)

case "5":
choice := ""
fmt.Println("你确定退出吗?(y/n)")
fmt.Scanln(&choice)
for {
if choice == "y" || choice == "n" {
break
} else {
fmt.Println("你的输入有误,请重新输入")
}
}
if choice == "y" {
fmt.Println("退出成功")
loop = false
}
default:
fmt.Println("请输入正确的选项")
}

if !loop {
break
}
}
}

现在利用前面的结构体和面向对象的思想修改为面向对象的结构。

首先创建一个新的 package 叫做 utils,将对应的功能封装到函数中并定义一个结构体,再定义一个工厂函数方便外部调用。

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
package utils

import "fmt"

// 定义一个结构体
type FamilyAccount struct {
// 声明必要的字段
// 声明一个变量,保存用户的输入
key string
// 声明一个变量,控制循环是否结束
loop bool
// 定义账户余额
balance float64
// 每次收支的金额
money float64
// 每次收支的说明
note string
// 是否有收支行为
flag bool
// 收支的详情,当有收支的时候,只需要对 detail 进行拼接即可
detail string
}

// 编写一个工厂模式构造方法,返回一个 *FamilyAccount 实例
func NewFamilyAccount() *FamilyAccount {
return &FamilyAccount{
key: "",
loop: true,
balance: 10000.0,
money: 0.0,
note: "",
flag: false,
detail: "收支\t账户金额\t收支金额\t说明",
}
}

// 将显示明细封装到方法中
func (this *FamilyAccount) showDetails() {
fmt.Println("当前收支明细")
if this.flag {
fmt.Println(this.detail)
} else {
fmt.Println("当前没有收支记录")
}
}

// 将登记收入绑定方法
func (this *FamilyAccount) income() {
fmt.Println("开始登记收入")
fmt.Println("本次收入金额:")
fmt.Scanln(&this.money)
this.balance += this.money
fmt.Println("本次收入说明:")
fmt.Scanln(&this.note)
this.detail += fmt.Sprintf("\n收入\t%v\t%v\t%v", this.balance, this.money, this.note)
this.flag = true
}

// 将登记支出绑定方法
func (this *FamilyAccount) pay() {
fmt.Println("开始登记支出")
fmt.Println("本次支出金额:")
fmt.Scanln(&this.money)
if this.balance > this.money {
this.balance -= this.money
fmt.Println("本次支出说明:")
fmt.Scanln(&this.note)
this.detail += fmt.Sprintf("\n支出\t%v\t%v\t%v", this.balance, this.money, this.note)
this.flag = true
} else {
fmt.Println("余额不足")
}
}

func (this *FamilyAccount) selectBalance() {
fmt.Printf("当前余额%v:", this.balance)
}

func (this *FamilyAccount) exit() {
choice := ""
fmt.Println("你确定退出吗?(y/n)")
fmt.Scanln(&choice)
for {
if choice == "y" || choice == "n" {
break
} else {
fmt.Println("你的输入有误,请重新输入")
}
}
if choice == "y" {
fmt.Println("退出成功")
this.loop = false
}
}

// 给结构体绑定方法
func (this *FamilyAccount) MainMenu() {
for {
fmt.Println("\n-------------------家庭收支记账软件-------------------\n")
fmt.Println("1.收支明细")
fmt.Println("2.登记收入")
fmt.Println("3.登记支出")
fmt.Println("4.查询余额")
fmt.Println("5.退出软件")

fmt.Println("请选择 1-5")
// 接收参数 key
fmt.Scanln(&this.key)

switch this.key {
case "1":
this.showDetails()
case "2":
this.income()
case "3":
this.pay()
case "4":
this.selectBalance()
case "5":
this.exit()
default:
fmt.Println("请输入正确的选项")
}

if !this.loop {
break
}
}
}

此时 main.go 只需要导入 utils 包,通过工厂函数返回了一个 FamilyAccount 指针,然后再使用对应的 MainMenu 函数。至此完成面向过程结构改编面向对象结构。

1
2
3
4
5
6
7
8
9
10
11
12
package main

import (
"fmt"
"myAccount/utils"
)

func main() {
fmt.Println("面向对象版本")
utils.NewFamilyAccount().MainMenu()
}

1-6.7 案例:GitHub 标签爬虫

如果想知道一个在 GitHub 的开源工具有多少标签,如果一个个翻页会很麻烦。因此编写爬虫,由于 spider.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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
package spider_tool

import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
)

type SpiderTags struct {
url string
appName string
}

func NewSpiderTags(url string, nowAppName string) *SpiderTags {
return &SpiderTags{
url: url,
appName: nowAppName,
}
}

func (this *SpiderTags) tagsSpider(version string) {
path := fmt.Sprintf("?after=%s", version)
url := this.url + path
this.SpiderGithubTags(url)
}

func (this *SpiderTags) SpiderGithubTags(url string) {
if url != "" {
url = url
} else {
url = this.url
}
resp, err := http.Get(url)

if err != nil {
fmt.Println("访问错误:", err)
return
}
defer resp.Body.Close()

if resp.StatusCode == http.StatusOK {
//fmt.Println("Fetched URL successfully:", url)
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Println("读取响应包错误:", err)
return
}
body := string(bodyBytes)
// Github Tags 匹配特征
pattern := `class="Link--primary Link">(.*?)<`
re := regexp.MustCompile(pattern)
matcher := re.FindAllStringSubmatch(body, -1)
if matcher != nil {
for _, version := range matcher {
if !containsSpecialVersion(version[1]) {
// 使用正则提取对应的版本信息
versionName := extractVersion(version[1])
if versionName != "" {
fmt.Println(versionName)
err := writeVersion(versionName, this.appName)
if err != nil {
fmt.Println("写入错误:", err)
}
} else {
// 处理版本号为空的情况
fmt.Println("未匹配到正确的版本号")
}
}
}
if len(matcher) > 0 {
this.tagsSpider(matcher[len(matcher)-1][1])
}
}
} else {
fmt.Println("响应码非200:", resp.StatusCode)
}
}

// 去除非正式版本
func containsSpecialVersion(version string) bool {
specialVersions := []string{"rc", "beta", "alpha", "rc", "dev", "@"}
versionLower := strings.ToLower(version)
for _, sv := range specialVersions {
if strings.Contains(versionLower, strings.ToLower(sv)) {
return true
}
}
return false
}

func extractVersion(s string) string {
// 使用正则表达式匹配版本号
re := regexp.MustCompile(`\d+(\.\d+)+`)
match := re.FindString(s)
if match != "" {
return match
}
return ""
}

func writeVersion(versionNumber string, appName string) error {
// 获取当前工作目录
currentDir, err := os.Getwd()
if err != nil {
return err
}

// 创建绝对路径
absolutePath := filepath.Join(currentDir, "version.txt")

// 以追加模式打开文件
file, err := os.OpenFile(absolutePath, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644)
if err != nil {
return err
}
defer file.Close()

// 写入版本信息
_, err = file.WriteString(fmt.Sprintf("%s %s\n", appName, versionNumber))
if err != nil {
return err
}

return nil
}

在 main.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
package main

import (
"flag"
"fmt"
"spider_demo/spider_tool"
)

func main() {
// 定义命令行标志
url := flag.String("u", "https://github.com/pallets/flask/tags", "GitHub仓库的URL")
app := flag.String("n", "Flask", "应用程序名称")

// 解析命令行参数
flag.Parse()

// 使用NewSpiderTags创建SpiderTags实例,并传递URL参数
spiderTags := spider_tool.NewSpiderTags(*url, *app)

// 调用SpiderGithubTags方法
spiderTags.SpiderGithubTags("")
fmt.Scan()
}

通过命令爬取对应内容

1
2
3
4
# 编译
go build -o spider
# 运行爬虫
./spider -u "https://github.com/pallets/flask/tags" -n "Flask"

1-7 interface

Go 语言里面设计最精妙的应该算 interface,它让面向对象,内容组织实现非常的方便。

1-7.1 什么是 interface

简单的说,interface 是一组 method 签名的组合,通过 interface 来定义对象的一组行为。

前面例子中 StudentEmployee 都能 SayHi,虽然他们的内部实现不一样,但是那不重要,重要的是他们都能 say hi

继续做更多的扩展,StudentEmployee 实现另一个方法 Sing,然后 Student 实现方法 BorrowMoneyEmployee 实现 SpendSalary

这样 Student 实现了三个方法:SayHiSingBorrowMoney;而 Employee 实现了 SayHiSingSpendSalary

上面这些方法的组合称为 interface(被对象 StudentEmployee 实现)。例如 StudentEmployee 都实现了 interfaceSayHiSing,也就是这两个对象是该 interface 类型。而 Employee 没有实现这个 interface:SayHi、SingBorrowMoney,因为 Employee 没有实现 BorrowMoney 这个方法。

可以说大部分情况下,GO 是基于接口进行编程的。

1-7.2 interface 类型

interface 类型定义了一组方法,如果某个对象实现了某个接口的所有方法,则此对象就实现了此接口。详细的语法参考下面这个例子

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
package main

import "fmt"

type Human struct {
name string
age int
phone string
}
type Student struct {
Human //匿名字段 Human
school string
loan float32
}
type Employee struct {
Human //匿名字段 Human
company string
money float32
}

// Human 对象实现 Sayhi 方法
func (h *Human) SayHi() {
fmt.Printf("Hi, I am %s you can call me on %s\n", h.name, h.phone)
}

// Human 对象实现 Sing 方法
func (h *Human) Sing(lyrics string) {
fmt.Println("La la, la la la, la la la la la...", lyrics)
}

// Human 对象实现 Guzzle 方法
func (h *Human) Guzzle(beerStein string) {
fmt.Println("Guzzle Guzzle Guzzle...", beerStein)
}

// Employee 重载 Human 的 Sayhi 方法
func (e *Employee) SayHi() {
fmt.Printf("Hi, I am %s, I work at %s. Call me on %s\n", e.name,
e.company, e.phone) //此句可以分成多行
}

// Student 实现 BorrowMoney 方法
func (s *Student) BorrowMoney(amount float32) {
s.loan += amount // (again and again and...)
}

// Employee 实现 SpendSalary 方法
func (e *Employee) SpendSalary(amount float32) {
e.money -= amount // More vodka please!!! Get me through the day!
}

// 定义 interface
type Men interface {
SayHi()
Sing(lyrics string)
Guzzle(beerStein string)
}

type YoungChap interface {
SayHi()
Sing(song string)
BorrowMoney(amount float32)
}

type ElderlyGent interface {
SayHi()
Sing(song string)
SpendSalary(amount float32)
}

通过上面的代码可以知道,interface 可以被任意的对象实现。看到上面的 Men interface 被 Human、Student 和 Employee 实现。同理,一个对象可以实现任意多个 interface,例如上面的 Student 实现了 Men 和 YoungChap 两个 interface。

最后,任意的类型都实现了空 interface(这样定义:interface{}),也就是包含 0 个 method 的 interface。

1-7.3 interface 值

如果定义了一个 interface 的变量,那么这个变量里面可以存实现这个 interface 的任意类型的对象。例如上面例子中,定义了一个 Men interface 类型的变量 m,那么 m 里面可以存 Human、Student 或者 Employee 值。

因为 m 能够持有这三种类型的对象,所以可以定义一个包含 Men 类型元素的 slice,这个 slice 可以被赋予实现了 Men 接口的任意结构的对象,这个和传统意义上面的 slice 有所不同。

来看一下下面这个例子:

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
package main
import "fmt"
type Human struct {
name string
age int
phone string
}
type Student struct {
Human //匿名字段
school string
loan float32
}
type Employee struct {
Human //匿名字段
company string
money float32
}
//Human实现SayHi方法
func (h Human) SayHi() {
fmt.Printf("Hi, I am %s you can call me on %s\n", h.name, h.phone)
}
//Human实现Sing方法
func (h Human) Sing(lyrics string) {
fmt.Println("La la la la...", lyrics)
}
//Employee重载Human的SayHi方法
func (e Employee) SayHi() {
fmt.Printf("Hi, I am %s, I work at %s. Call me on %s\n", e.name,
e.company, e.phone)
}
// Interface Men被Human,Student和Employee实现
// 因为这三个类型都实现了这两个方法
type Men interface {
SayHi()
Sing(lyrics string)
}
func main() {
mike := Student{Human{"Mike", 25, "222-222-XXX"}, "MIT", 0.00}
paul := Student{Human{"Paul", 26, "111-222-XXX"}, "Harvard", 100}
sam := Employee{Human{"Sam", 36, "444-222-XXX"}, "Golang Inc.", 1000}
tom := Employee{Human{"Tom", 37, "222-444-XXX"}, "Things Ltd.", 5000}
//定义Men类型的变量i
var i Men
//i能存储Student
i = mike
fmt.Println("This is Mike, a Student:")
i.SayHi()
i.Sing("November rain")
//i也能存储Employee
i = tom
fmt.Println("This is tom, an Employee:")
i.SayHi()
i.Sing("Born to be wild")
//定义了slice Men
fmt.Println("Let's use a slice of Men and see what happens")
x := make([]Men, 3)
//这三个都是不同类型的元素,但是他们实现了interface同一个接口
x[0], x[1], x[2] = paul, sam, mike
for _, value := range x{
value.SayHi()
}
}

通过上面的代码,发现 interface 就是一组抽象方法的集合,它必须由其他非interface类型实现,而不能自我实现, Go 通过 interface 实现了 duck-typing :即”当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子”。

1-7.4 空 interface

空 interface(interface{}) 不包含任何的 method,正因为如此,所有的类型都实现了空 interface。空 interface 对于描述起不到任何的作用(因为它不包含任何的 method),但是空 interface 需要存储任意类型的数值的时候相当有用,因为它可以存储任意类型的数值。

1
2
3
4
5
6
7
// 定义a为空接口
var a interface{}
var i int = 5
s := "Hello world"
// a可以存储任意类型的数值
a = i
a = s

一个函数把 interface{} 作为参数,那么他可以接受任意类型的值作为参数,如果一个函数返回 interface{},那么也就可以返回任意类型的值。

1-7.5 interface 函数参数

interface 的变量可以持有任意实现该 interface 类型的对象,这给编写函数(包括 method)提供了一些额外的思考,是不是可以通过定义 interface 参数,让函数接受各种类型的参数。

举个例子:fmt.Println 是常用的一个函数,是否注意到它可以接受任意类型的数据。打开 fmt 的源码文件,会看到这样一个定义:

1
2
3
type Stringer interface {
String() string
}

也就是说,任何实现了 String 方法的类型都能作为参数被 fmt.Println 调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main
import (
"fmt"
"strconv"
)
type Human struct {
name string
age int
phone string
}
// 通过这个方法 Human 实现了 fmt.Stringer
func (h Human) String() string {
return "❰"+h.name+" - "+strconv.Itoa(h.age)+" years - ✆ " +h.phone+"❱"
}
func main() {
Bob := Human{"Bob", 39, "000-7777-XXX"}
fmt.Println("This Human is : ", Bob)
}

现在再回顾一下前面的 Box 示例,发现 Color 结构也定义了一个 method:String。其实这也是实现了 fmt.Stringer 这个 interface,即如果需要某个类型能被 fmt 包以特殊的格式输出,就必须实现 Stringer 这个接口。如果没有实现这个接口,fmt 将以默认的方式输出。

1
2
3
//实现同样的功能
fmt.Println("The biggest one is", boxes.BiggestsColor().String())
fmt.Println("The biggest one is", boxes.BiggestsColor())

注:实现了 error 接口的对象(即实现了Error() string的对象),使用 fmt 输出时,会调用 Error() 方法,因此不必再定义 String() 方法了。

1-7.6 interface 存储类型

interface 的变量里面可以存储任意类型的数值(该类型实现了 interface)。那么怎么反向知道这个变量里面实际保存了的是哪个类型的对象呢?目前常用的有两种方法:

  • Comma-ok 断言

Go 语言里面有一个语法,可以直接判断是否是该类型的变量: value, ok = element.(T),这里 value 就是变量的值,ok 是一个bool 类型,element 是 interface 变量,T 是断言的类型。

如果 element 里面确实存储了 T 类型的数值,那么 ok 返回 true,否则返回 false。

通过一个例子来更加深入的理解。

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
package main
import (
"fmt"
"strconv"
)
type Element interface{}
type List [] Element
type Person struct {
name string
age int
}
//定义了String方法,实现了fmt.Stringer
func (p Person) String() string {
return "(name: " + p.name + " - age: "+strconv.Itoa(p.age)+ " years)"
}
func main() {
list := make(List, 3)
list[0] = 1 // an int
list[1] = "Hello" // a string
list[2] = Person{"Dennis", 70}
for index, element := range list {
if value, ok := element.(int); ok {
fmt.Printf("list[%d] is an int and its value is %d\n", index, value)
} else if value, ok := element.(string); ok {
fmt.Printf("list[%d] is a string and its value is %s\n", index, value)
} else if value, ok := element.(Person); ok {
fmt.Printf("list[%d] is a Person and its value is %s\n", index, value)
} else {
fmt.Printf("list[%d] is of a different type\n", index)
}
}
}

是否注意到了多个 if 里面,if 里面允许初始化变量。断言的类型越多,那么 if else 也就越多,所以才引出了下面要介绍的 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
24
25
26
27
28
29
30
31
32
33
package main
import (
"fmt"
"strconv"
)
type Element interface{}
type List [] Element
type Person struct {
name string
age int
}
//打印
func (p Person) String() string {
return "(name: " + p.name + " - age: "+strconv.Itoa(p.age)+ " years)"
}
func main() {
list := make(List, 3)
list[0] = 1 //an int
list[1] = "Hello" //a string
list[2] = Person{"Dennis", 70}
for index, element := range list{
switch value := element.(type) {
case int:
fmt.Printf("list[%d] is an int and its value is %d\n", index, value)
case string:
fmt.Printf("list[%d] is a string and its value is %s\n", index, value)
case Person:
fmt.Printf("list[%d] is a Person and its value is %s\n", index, value)
default:
fmt.Println("list[%d] is of a different type", index)
}
}
}

这里有一点需要强调的是:element.(type) 语法不能在 switch 外的任何逻辑里面使用,如果要在 switch 外面判断一个类型就使用 comma-ok

1-7.7 嵌入 interface

Go 里面真正吸引人的是它内置的逻辑语法,就像在学习 Struct 时学习的匿名字段,那么相同的逻辑引入到 interface 里面,更加完美了。如果一个 interface1 作为 interface2 的一个嵌入字段,那么 interface2 隐式的包含了 interface1 里面的 method。

可以看到源码包 container/heap 里面有这样的一个定义

1
2
3
4
5
type Interface interface {
sort.Interface //嵌入字段sort.Interface
Push(x interface{}) //a Push method to push elements into the heap
Pop() interface{} //a Pop elements that pops elements from the heap
}

看到 sort.Interface 其实就是嵌入字段,把 sort.Interface 的所有 method 给隐式的包含进来了。也就是下面三个方法:

1
2
3
4
5
6
7
8
9
type Interface interface {
// Len is the number of elements in the collection.
Len() int
// Less returns whether the element with index i should sort
// before the element with index j.
Less(i, j int) bool
// Swap swaps the elements with indexes i and j.
Swap(i, j int)
}

另一个例子就是 io 包下面的 io.ReadWriter ,它包含了io包下面的 ReaderWriter 两个 interface

1
2
3
4
5
// io.ReadWriter
type ReadWriter interface {
Reader
Writer
}

1-7.8 案例:USB 接口

接口的使用非常像 USB 接口,我们只需要插入 USB 接口即可,不需要关注内部的构造。接口会通过识别插入的设备类型,适配不同的驱动。

通过不同的类型输出不同内容,但都是同一个 USB 接口。

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
40
41
42
43
44
45
46
47
48
package main

import "fmt"

type Usb interface {
Start()
Stop()
}

type Phone struct {
}

func (p Phone) Start() {
fmt.Println("手机启动")
}

func (p Phone) Stop() {
fmt.Println("手机关闭")
}

type Camera struct {
}

func (c Camera) Start() {
fmt.Println("相机启动")
}

func (c Camera) Stop() {
fmt.Println("相机关闭")
}

type Computer struct {
}

func (c Computer) Working(usb Usb) {
usb.Start()
usb.Stop()
}

func main() {
computer := Computer{}
phone := Phone{}
camera := Camera{}

computer.Working(phone)
computer.Working(camera)
}

1-8 反射

Go 语言实现了反射,所谓反射就是能检查程序在运行时的状态。一般用到的包是 reflect 包。如何运用 reflect 包,官方的这篇文章详细的讲解了 reflect 包的实现原理,laws of reflection 链接地址为 http://golang.org/doc/articles/laws_of_reflection.html

使用 reflect 一般分成三步,下面简要的讲解一下:要去反射是一个类型的值(这些值都实现了 空interface),首先需要把它转化成 reflect 对象(reflect.Type 或者 reflect.Value,根据不同的情况调用不同的函数)。这两种获取方式如下:

1
2
t := reflect.TypeOf(i)    //得到类型的元数据,通过 t 能获取类型定义里面的所有元素。
v := reflect.ValueOf(i) //得到实际的值,通过 v 获取存储在里面的值,还可以去改变值。

转化为 reflect 对象之后就可以进行一些操作了,也就是将 reflect 对象转化成相应的值,例如

1
2
tag := t.Elem().Field(0).Tag  //获取定义在struct里面的标签
name := v.Elem().Field(0).String() //获取存储在第一个字段里面的值

获取反射值能返回相应的类型和数值。

1
2
3
4
5
var x float64 = 3.4
v := reflect.ValueOf(x)
fmt.Println("type:", v.Type())
fmt.Println("kind is float64:", v.Kind() == reflect.Float64)
fmt.Println("value:", v.Float())

最后,反射的话,那么反射的字段必须是可修改的,前面学习过传值和传引用,这个里面也是一样的道理。反射的字段必须是可读写的意思是,如果下面这样写,那么会发生错误。

1
2
3
var x float64 = 3.4
v := reflect.ValueOf(x)
v.SetFloat(7.1)

如果要修改相应的值,必须这样写。

1
2
3
4
var x float64 = 3.4
p := reflect.ValueOf(&x)
v := p.Elem()
v.SetFloat(7.1)

1-9 错误处理

Go 语言主要的设计准则是:简洁、明白,简洁是指语法和 C 类似,相当的简单,明白是指任何语句都是很明显的,不含有任何隐含的东西,在错误处理方案的设计中也贯彻了这一思想。

在 C 语言里面是通过返回 -1 或者 NULL 之类的信息来表示错误,但是对于使用者来说,不查看相应的 API 说明文档,根本搞不清楚这个返回值究竟代表什么意思,比如:返回 0 是成功,还是失败,而 Go 定义了一个叫做 error 的类型,来显式表达错误。在使用时,通过把返回的 error 变量与 nil 的比较,来判定操作是否成功。例如 os.Open 函数在打开文件失败时将返回一个不为 nilerror 变量。

1
func Open(name string) (file *File, err error)

下面这个例子通过调用 os.Open 打开一个文件,如果出现错误,那么就会调用 log.Fatal 来输出错误信息:

1
2
3
4
f, err := os.Open("filename.ext")
if err != nil {
log.Fatal(err)
}

类似于 os.Open 函数,标准包中所有可能出错的API都会返回一个 error 变量,以方便错误处理,这个小节将详细地介绍 error 类型的设计,和讨论开发 Web 应用中如何更好地处理 error

因此经常会发现一个项目中包含了大量的if err != nil的语句,虽然麻烦但是可以及时发现错误点。

1-9.1 Error 类型

error 类型是一个接口类型,这是它的定义:

1
2
3
type error interface {
Error() string
}

error 是一个内置的接口类型,可以在 /builtin/ 包下面找到相应的定义。而在很多内部包里面用到的 errorerrors 包下面的实现的私有结构 errorString

1
2
3
4
5
6
7
8
// errorString is a trivial implementation of error.
type errorString struct {
s string
}

func (e *errorString) Error() string {
return e.s
}

可以通过 errors.New 把一个字符串转化为 errorString,以得到一个满足接口 error 的对象,其内部实现如下:

1
2
3
4
// New returns an error that formats as the given text.
func New(text string) error {
return &errorString{text}
}

下面这个例子演示了如何使用 errors.New:

1
2
3
4
5
6
func Sqrt(f float64) (float64, error) {
if f < 0 {
return 0, errors.New("math: square root of negative number")
}
// implementation
}

在下面的例子中,在调用Sqrt的时候传递的一个负数,然后就得到了 non-nilerror 对象,将此对象与 nil 比较,结果为 true,所以 fmt.Println(fmt 包在处理 error 时会调用 Error 方法)被调用,以输出错误:

1
2
3
4
f, err := Sqrt(-1)
if err != nil {
fmt.Println(err)
}

1-9.2 自定义 Error

error 是一个 interface,所以在实现自己的包的时候,通过定义实现此接口的结构,就可以实现自己的错误定义,来自 Json 包的示例:

1
2
3
4
5
6
type SyntaxError struct {
msg string // 错误描述
Offset int64 // 错误发生的位置
}

func (e *SyntaxError) Error() string { return e.msg }

Offset 字段在调用 Error 的时候不会被打印,但可以通过类型断言获取错误类型,然后可以打印相应的错误信息,请看下面的例子:

1
2
3
4
5
6
7
if err := dec.Decode(&val); err != nil {
if serr, ok := err.(*json.SyntaxError); ok {
line, col := findLine(f, serr.Offset)
return fmt.Errorf("%s:%d:%d: %v", f.Name(), line, col, err)
}
return err
}

需要注意的是,函数返回自定义错误时,返回值推荐设置为 error 类型,而非自定义错误类型,特别需要注意的是不应预声明自定义错误类型的变量。例如:

1
2
3
4
5
6
7
func Decode() *SyntaxError { // 错误,将可能导致上层调用者err!=nil的判断永远为true。
var err *SyntaxError // 预声明错误变量
if 出错条件 {
err = &SyntaxError{}
}
return err // 错误,err永远等于非nil,导致上层调用者err!=nil的判断始终为true
}

原因见 http://golang.org/doc/faq#nil_error (需科学上网)

上面例子简单的演示了如何自定义 Error 类型。但是如果还需要更复杂的错误处理呢?此时,来参考一下 net 包采用的方法:

1
2
3
4
5
6
7
package net

type Error interface {
error
Timeout() bool // Is the error a timeout?
Temporary() bool // Is the error temporary?
}

在调用的地方,通过类型断言 err 是不是 net.Error,来细化错误的处理,例如下面的例子,如果一个网络发生临时性错误,那么将会 sleep 1秒之后重试:

1
2
3
4
5
6
7
if nerr, ok := err.(net.Error); ok && nerr.Temporary() {
time.Sleep(1e9)
continue
}
if err != nil {
log.Fatal(err)
}

1-9.3 错误处理

Go 在错误处理上采用了与 C 类似的检查返回值的方式,而不是其他多数主流语言采用的异常方式,这造成了代码编写上的一个很大的缺点:错误处理代码的冗余,对于这种情况是通过复用检测函数来减少类似的代码。

请看下面这个例子代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func init() {
http.HandleFunc("/view", viewRecord)
}

func viewRecord(w http.ResponseWriter, r *http.Request) {
c := appengine.NewContext(r)
key := datastore.NewKey(c, "Record", r.FormValue("id"), 0, nil)
record := new(Record)
if err := datastore.Get(c, key, record); err != nil {
http.Error(w, err.Error(), 500)
return
}
if err := viewTemplate.Execute(w, record); err != nil {
http.Error(w, err.Error(), 500)
}
}

上面的例子中获取数据和模板展示调用时都有检测错误,当有错误发生时,调用了统一的处理函数 http.Error,返回给客户端500错误码,并显示相应的错误数据。但是当越来越多的 HandleFunc 加入之后,这样的错误处理逻辑代码就会越来越多,其实可以通过自定义路由器来缩减代码

1
2
3
4
5
6
7
type appHandler func(http.ResponseWriter, *http.Request) error

func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err := fn(w, r); err != nil {
http.Error(w, err.Error(), 500)
}
}

上面定义了自定义的路由,然后可以通过如下方式来注册函数:

1
2
3
func init() {
http.Handle("/view", appHandler(viewRecord))
}

当请求 /view 的时候逻辑处理可以变成如下代码,和第一种实现方式相比较已经简单了很多。

1
2
3
4
5
6
7
8
9
func viewRecord(w http.ResponseWriter, r *http.Request) error {
c := appengine.NewContext(r)
key := datastore.NewKey(c, "Record", r.FormValue("id"), 0, nil)
record := new(Record)
if err := datastore.Get(c, key, record); err != nil {
return err
}
return viewTemplate.Execute(w, record)
}

上面的例子错误处理的时候所有的错误返回给用户的都是500错误码,然后打印出来相应的错误代码,其实可以把这个错误信息定义的更加友好,调试的时候也方便定位问题,可以自定义返回的错误类型:

1
2
3
4
5
type appError struct {
Error error
Message string
Code int
}

这样自定义路由器可以改成如下方式:

1
2
3
4
5
6
7
8
9
type appHandler func(http.ResponseWriter, *http.Request) *appError

func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if e := fn(w, r); e != nil { // e is *appError, not os.Error.
c := appengine.NewContext(r)
c.Errorf("%v", e.Error)
http.Error(w, e.Message, e.Code)
}
}

这样修改完自定义错误之后,逻辑处理可以改成如下方式:

1
2
3
4
5
6
7
8
9
10
11
12
func viewRecord(w http.ResponseWriter, r *http.Request) *appError {
c := appengine.NewContext(r)
key := datastore.NewKey(c, "Record", r.FormValue("id"), 0, nil)
record := new(Record)
if err := datastore.Get(c, key, record); err != nil {
return &appError{err, "Record not found", 404}
}
if err := viewTemplate.Execute(w, record); err != nil {
return &appError{err, "Can't display record", 500}
}
return nil
}

如上所示,在访问 view 的时候可以根据不同的情况获取不同的错误码和错误信息,虽然这个和第一个版本的代码量差不多,但是这个显示的错误更加明显,提示的错误信息更加友好,扩展性也比第一个更好。

二、常用方法

标准库中文版:https://studygolang.com/pkgdoc

2-1 网络编程

2-1.1 HTTP请求

发送 HTTP 数据包需要使用 net/http 库,使用 GET 访问 https://example.com/,将状态码、响应头、响应体返回。

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
package main

import (
"fmt"
"io/ioutil"
"net/http"
)

func main() {
// 使用变量代替硬编码的URL
url := "https://example.com/"

// 发起请求并处理可能的错误
resp, err := http.Get(url)
if err != nil {
fmt.Println("请求失败:", err)
return
}
defer resp.Body.Close()

// 检查响应状态码
if resp.StatusCode != http.StatusOK {
fmt.Println("请求失败,状态码:", resp.StatusCode)
return
}

// 读取响应体
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Println("读取响应体失败:", err)
return
}

// 打印响应
fmt.Println("响应状态:", resp.Status)
fmt.Println("响应头:", resp.Header)
fmt.Println("响应体:", string(body))
}

获取一个页面的 body 信息案例如下,忽略证书验证错误,并添加自定义的 Header 头。

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
func (p PageContent) getPageContent(url string) string {
// 创建一个自定义的 Transport,它忽略证书验证错误
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}

// 使用自定义的 Transport 创建一个 HTTP 客户端
client := &http.Client{Transport: transport}

req, err := http.NewRequest("GET", url, nil)
if err != nil {
fmt.Printf("创建请求失败: %v\n", err)
return ""
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36")
resp, err := client.Do(req)
if err != nil {
fmt.Printf("请求失败: %v\n", err)
return ""
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("读取响应失败: %v\n", err)
return ""
}
return string(body)
} else {
fmt.Printf("返回异常: %d\n", resp.StatusCode)
}

return ""
}

2-1.2 简单的 Web 服务

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
package main

import (
"fmt"
"log"
"net/http"
"strings"
)

func sayhelloName(w http.ResponseWriter, r *http.Request) {
r.ParseForm() //解析参数,默认是不会解析的
fmt.Println(r.Form) //这些信息是输出到服务器端的打印信息
fmt.Println("path", r.URL.Path)
fmt.Println("scheme", r.URL.Scheme)
fmt.Println(r.Form["url_long"])
for k, v := range r.Form {
fmt.Println("key:", k)
fmt.Println("val:", strings.Join(v, ""))
}
fmt.Fprintf(w, "Hello") //这个写入到w的是输出到客户端的
}

func main() {
http.HandleFunc("/", sayhelloName) //设置访问的路由
err := http.ListenAndServe(":9090", nil) //设置监听的端口
if err != nil {
log.Fatal("ListenAndServe: ", err)
}
}

2-2 文件操作

文件的操作被封装在 os.File 中,File 是一个结构体。

2-2.1 文件读取

使用 os.Open 打开文件,及时使用 defer 关闭文件,防止内存泄露。

这里使用缓存读入的方式,逐步的读取文件内容。

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
package main

import (
"bufio"
"fmt"
"io"
"os"
"path/filepath"
)

func main() {
fmt.Println("打开文件")
currentDir, err := os.Getwd()
if err != nil {
fmt.Println("获取当前路径错误:", err)
return
}
absolutePath := filepath.Join(currentDir, "test.txt")

file, err := os.Open(absolutePath)
if err != nil {
fmt.Println("打开文件错误:", err)
return
}
defer file.Close() // 使用defer来确保文件最后会被关闭
reader := bufio.NewReader(file)
for {
// 使用换行符作为一个截断
str, err := reader.ReadString('\n')
fmt.Print(str)
// 遇到 EOF 说明读取到了文件末尾
if err == io.EOF {
break
}
}
fmt.Println("\n文件读取结束")

}

如果文件比较小,可以直接全量读取,使用 os.ReadFile 进行修改。

io/ioutil 在 Go 1.16 后已经被弃用,但很多旧的代码中仍然包含。

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
package main

import (
"fmt"
"os"
"path/filepath"
)

func main() {
fmt.Println("打开文件")
currentDir, err := os.Getwd()
if err != nil {
fmt.Println("获取当前路径错误:", err)
return
}
absolutePath := filepath.Join(currentDir, "test.txt")
content, err := os.ReadFile(absolutePath)
if err != nil {
println("读取错误:", err)
}
// 由于返回的是 byte 类型,如果需要展示内容需要转换为 string
fmt.Println(string(content))

fmt.Println("文件读取结束")

}

常用的方法是查找一个值是否在文件中已经存在,可以使用 bufio.NewScanner 进行逐行搜索。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func CheckDuplicateLinks(link string) bool {
file, err := os.Open(getResName())
if err != nil {
log.Error(err)
return false
}
defer file.Close()

scanner := bufio.NewScanner(file)
for scanner.Scan() {
if strings.TrimSpace(scanner.Text()) == link {
return false
}
}

if err := scanner.Err(); err != nil {
log.Error(err)
return false
}

return true
}

2-2.2 文件写入

os.OpenFile 可以用来读、写文件,此外可以进行追加、打开清空等操作,也可以为文件设置权限(Unix有效)

权限与 Unix 下的对应:r-4;w-2;x-1

1
func OpenFile(name string, flag int, perm FileMode) (file *File, err error)

模式如下

1
2
3
4
5
6
7
8
9
10
const (
O_RDONLY int = syscall.O_RDONLY // 只读模式打开文件
O_WRONLY int = syscall.O_WRONLY // 只写模式打开文件
O_RDWR int = syscall.O_RDWR // 读写模式打开文件
O_APPEND int = syscall.O_APPEND // 写操作时将数据附加到文件尾部
O_CREATE int = syscall.O_CREAT // 如果不存在将创建一个新文件
O_EXCL int = syscall.O_EXCL // 和O_CREATE配合使用,文件必须不存在
O_SYNC int = syscall.O_SYNC // 打开文件用于同步I/O
O_TRUNC int = syscall.O_TRUNC // 如果可能,打开时清空文件
)

读取文件采取追加写入,如果没有该文件则创建文件。

1
file, err := os.OpenFile("version.txt", os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644)

使用创建、追加写入的方式写入五遍 hello。

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
package main

import (
"fmt"
"os"
"path/filepath"
)

func main() {
fmt.Println("打开文件")
currentDir, err := os.Getwd()
if err != nil {
fmt.Println("获取当前路径错误:", err)
return
}
absolutePath := filepath.Join(currentDir, "test.txt")
flie, err := os.OpenFile(absolutePath, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666)
if err != nil {
fmt.Println("文件读取错误:", err)
return
}
defer file.Close()
for i := 0; i < 5; i++ {
_, err := flie.WriteString("hello\n")
if err != nil {
fmt.Println("文件写入错误:", err)
return
}
}
fmt.Println("写入完成")
}

2-3 正则

常用的包是 regexp,采用的是 RE2 语法,与一般编程语言中的语法基本一致。

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
package main

import (
"fmt"
"regexp"
)

func main() {
text := `Hello 世界!123 Go.`

// 查找连续的小写字母
reg := regexp.MustCompile(`[a-z]+`)
fmt.Printf("%q\n", reg.FindAllString(text, -1))
// ["ello" "o"]

// 查找连续的非小写字母
reg = regexp.MustCompile(`[^a-z]+`)
fmt.Printf("%q\n", reg.FindAllString(text, -1))
// ["H" " 世界!123 G" "."]

// 查找连续的单词字母
reg = regexp.MustCompile(`[\w]+`)
fmt.Printf("%q\n", reg.FindAllString(text, -1))
// ["Hello" "123" "Go"]

// 查找连续的非单词字母、非空白字符
reg = regexp.MustCompile(`[^\w\s]+`)
fmt.Printf("%q\n", reg.FindAllString(text, -1))
// ["世界!" "."]

// 查找连续的大写字母
reg = regexp.MustCompile(`[[:upper:]]+`)
fmt.Printf("%q\n", reg.FindAllString(text, -1))
// ["H" "G"]

// 查找连续的非 ASCII 字符
reg = regexp.MustCompile(`[[:^ascii:]]+`)
fmt.Printf("%q\n", reg.FindAllString(text, -1))
// ["世界!"]

// 查找连续的标点符号
reg = regexp.MustCompile(`[\pP]+`)
fmt.Printf("%q\n", reg.FindAllString(text, -1))
// ["!" "."]

// 查找连续的非标点符号字符
reg = regexp.MustCompile(`[\PP]+`)
fmt.Printf("%q\n", reg.FindAllString(text, -1))
// ["Hello 世界" "123 Go"]

// 查找连续的汉字
reg = regexp.MustCompile(`[\p{Han}]+`)
fmt.Printf("%q\n", reg.FindAllString(text, -1))
// ["世界"]

// 查找连续的非汉字字符
reg = regexp.MustCompile(`[\P{Han}]+`)
fmt.Printf("%q\n", reg.FindAllString(text, -1))
// ["Hello " "!123 Go."]

// 查找 Hello 或 Go
reg = regexp.MustCompile(`Hello|Go`)
fmt.Printf("%q\n", reg.FindAllString(text, -1))
// ["Hello" "Go"]

// 查找行首以 H 开头,以空格结尾的字符串
reg = regexp.MustCompile(`^H.*\s`)
fmt.Printf("%q\n", reg.FindAllString(text, -1))
// ["Hello 世界!123 "]

// 查找行首以 H 开头,以空白结尾的字符串(非贪婪模式)
reg = regexp.MustCompile(`(?U)^H.*\s`)
fmt.Printf("%q\n", reg.FindAllString(text, -1))
// ["Hello "]

// 查找以 hello 开头(忽略大小写),以 Go 结尾的字符串
reg = regexp.MustCompile(`(?i:^hello).*Go`)
fmt.Printf("%q\n", reg.FindAllString(text, -1))
// ["Hello 世界!123 Go"]

// 查找 Go.
reg = regexp.MustCompile(`\QGo.\E`)
fmt.Printf("%q\n", reg.FindAllString(text, -1))
// ["Go."]

// 查找从行首开始,以空格结尾的字符串(非贪婪模式)
reg = regexp.MustCompile(`(?U)^.* `)
fmt.Printf("%q\n", reg.FindAllString(text, -1))
// ["Hello "]

// 查找以空格开头,到行尾结束,中间不包含空格字符串
reg = regexp.MustCompile(` [^ ]*$`)
fmt.Printf("%q\n", reg.FindAllString(text, -1))
// [" Go."]

// 查找“单词边界”之间的字符串
reg = regexp.MustCompile(`(?U)\b.+\b`)
fmt.Printf("%q\n", reg.FindAllString(text, -1))
// ["Hello" " 世界!" "123" " " "Go"]

// 查找连续 1 次到 4 次的非空格字符,并以 o 结尾的字符串
reg = regexp.MustCompile(`[^ ]{1,4}o`)
fmt.Printf("%q\n", reg.FindAllString(text, -1))
// ["Hello" "Go"]

// 查找 Hello 或 Go
reg = regexp.MustCompile(`(?:Hell|G)o`)
fmt.Printf("%q\n", reg.FindAllString(text, -1))
// ["Hello" "Go"]

// 交换 Hello 和 Go
reg = regexp.MustCompile(`(Hello)(.*)(Go)`)
fmt.Printf("%q\n", reg.ReplaceAllString(text, "$3$2$1"))
// "Go 世界!123 Hello."

}

匹配响应包里面所有满足条件的内容,可以使用循环获取每个匹配结果。

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
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Println("读取响应包错误:", err)
return
}
body := string(bodyBytes)
// Github Tags 匹配特征
pattern := `class="Link--primary Link">(.*?)<`
re := regexp.MustCompile(pattern)
matcher := re.FindAllStringSubmatch(body, -1)
if matcher != nil {
for _, version := range matcher {
// 判断是否为正确版本号
if !containsSpecialVersion(version[1]) {
// 使用正则提取对应的版本信息
versionName := extractVersion(version[1])
if versionName != "" {
fmt.Println(versionName)
err := writeVersion(versionName, this.appName)
if err != nil {
fmt.Println("写入错误:", err)
}
} else {
// 处理版本号为空的情况
fmt.Println("未匹配到正确的版本号")
}
}
}

提取对应版本内容

1
2
3
4
5
6
7
8
9
func extractVersion(s string) string {
// 使用正则表达式匹配版本号
re := regexp.MustCompile(`\d+(\.\d+)+`)
match := re.FindString(s)
if match != "" {
return match
}
return ""
}

如果不想获取所有匹配结果,但关注子表达式,使用 re.FindStringSubmatch 即可。

1
2
3
4
5
6
7
8
9
func (p Page) getTitle(content string) string {
pattern := `<title>(.*?)</title>`
re := regexp.MustCompile(pattern)
match := re.FindStringSubmatch(content)
if len(match) > 1 {
return match[1]
}
return ""
}

2-4 命令行参数

通过命令行将参数传递到程序中,这里推荐使用 flag 包。直接放上前面 GitHub Tags 爬虫的部分代码做案例。

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 (
"flag"
"fmt"
"spider_demo/spider_tool"
)

func main() {
// 定义命令行标志
url := flag.String("u", "https://github.com/pallets/flask/tags", "GitHub仓库的URL")
app := flag.String("n", "Flask", "应用程序名称")

// 解析命令行参数
flag.Parse()

// 使用NewSpiderTags创建SpiderTags实例,并传递URL参数
spiderTags := spider_tool.NewSpiderTags(*url, *app)

// 调用SpiderGithubTags方法
spiderTags.SpiderGithubTags("")
fmt.Scan()
}

2-5 并发

2-5.1 进程、线程、协程

进程是程序在操作系统中的一次执行过程,是系统资源分配和调度的基本单位。

线程是进程的一个执行实例,是程序执行的最小单位,它是比进程更小的能独立运行的基本单位。

一个进程可以创建、销毁多个线程,同一个进程中的多个线程可以并发执行。

一个程序至少有一个进程,一个进程至少有一个线程。

进程可以比作一个下载器程序,其中如果下载多个内容则可以看做是多个线程。

Go 的设计者认为,线程还是太”重“了,因此协程可以理解为轻量级的线程。而 Go 主线程(有时候被称为线程,也可理解为进程)可以理解为一个 Go 线程中有多个协程。

Go 协程的特点

  • 有独立的栈空间
  • 共享程序堆空间
  • 调度由程序控制
  • 协程是轻量级的线程

2-5.2 goroutine

goroutine 是 Go 并行设计的核心。goroutine 说到底其实就是协程,但是它比线程更小,十几个 goroutine 可能体现在底层就是五六个线程,Go 语言内部实现了这些 goroutine 之间的内存共享。执行 goroutine 只需极少的栈内存(大概是4~5KB),当然会根据相应的数据伸缩。也正因为如此,可同时运行成千上万个并发任务。goroutine thread 更易用、更高效、更轻便。

goroutine 是通过 Go 的 runtime 管理的一个线程管理器。goroutine 通过 go 关键字实现了,其实就是一个普通的函数。

主线程是一个物理线程,是直接作用在 CPU 上的,重量级,比较消耗资源。

协程是从主线程开启的,是轻量级的线程,是逻辑态,资源消耗较少。golang 可以开启上万个协程,而其他语言经常是基于线程的,提现了 golang 协程的优势。

1
go hello(a, b, c)

通过关键字 go 就启动了一个 goroutine。来看一个例子

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"
"runtime"
)
func say(s string) {
for i := 0; i < 5; i++ {
runtime.Gosched()
fmt.Println(s)
}
}
func main() {
go say("world") //开一个新的Goroutines执行
say("hello") //当前Goroutines执行
}
// 以上程序执行后将输出:
// hello
// world
// hello
// world
// hello
// world
// hello
// world
// hello

可以看到 go 关键字很方便的就实现了并发编程。

上面的多个 goroutine 运行在同一个进程里面,共享内存数据,不过设计上要遵循:不要通过共享来通信,而要通过通信来共享。

runtime.Gosched() 表示让 CPU 把时间片让给别人,下次某个时候继续恢复执行该 goroutine

默认情况下,在 Go 1.5 将标识并发系统线程个数的 runtime.GOMAXPROCS 的初始值由 1 改为了运行环境的CPU核数。

但在 Go 1.5 以前调度器仅使用单线程,也就是说只实现了并发。想要发挥多核处理器的并行,需要程序中显式调用 runtime.GOMAXPROCS(n) 告诉调度器同时使用多个线程。GOMAXPROCS 设置了同时运行逻辑代码的系统线程的最大数量,并返回之前的设置。如果 n < 1,不会改变当前设置。

2-5.3 channels

goroutine 运行在相同的地址空间,为了保证线程安全,因此访问共享内存必须做好同步。

channel 的本质是队列,数据先进先出,自身是线程安全的,channel 是有类型的,一个 string 的 channel 只能存放 string。

Go 提供了一个很好的通信机制 channelchannel 可以与 Unix shell 中的双向管道做类比:可以通过它发送或者接收值。这些值只能是特定的类型:channel类型。定义一个channel 时,也需要定义发送到 channel 的值的类型。注意,引用类型必须使用 make 创建 channel

1
2
3
ci := make(chan int)
cs := make(chan string)
cf := make(chan interface{})

channel 通过操作符 <- 来接收和发送数据。

1
2
ch <- v    // 发送v到channel ch.
v := <-ch // 从ch中接收数据,并赋值给v

把这些应用到例子中来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main
import "fmt"
func sum(a []int, c chan int) {
total := 0
for _, v := range a {
total += v
}
c <- total // send total to c
}
func main() {
a := []int{7, 2, 8, -9, 4, 0}
c := make(chan int)
go sum(a[:len(a)/2], c)
go sum(a[len(a)/2:], c)
x, y := <-c, <-c // receive from c
fmt.Println(x, y, x + y)
}

默认情况下,channel 接收和发送数据都是阻塞的,除非另一端已经准备好,这样就使得 Goroutines 同步变的更加的简单,而不需要显式的 lock。所谓阻塞,也就是如果读取(value := <-ch)它将会被阻塞,直到有数据接收。其次,任何发送(ch<-5)将会被阻塞,直到数据被读出。无缓冲 channel 是在多个 goroutine 之间同步很棒的工具。

一个简单的无缓冲 channel 例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import "fmt"

func main() {
ch := make(chan int) // 创建无缓存channel

go func() {
fmt.Println("Sending 42")
ch <- 42 // 向channel发送数据
}()

num := <-ch // 从channel接收数据
fmt.Println("Received", num)
}

goroutine 会阻塞,直到从 channel 中接收到数据。当辅助 goroutinechannel 发送数据后,主 goroutine 才能继续执行并打印接收到的数据。如果去掉 go 关键字,使得发送操作在主 goroutine 中执行,那么程序会因为死锁而无法运行,因为没有其他的 goroutine 来接收数据。

2-5.4 Buffered Channels

上面介绍了默认的非缓存类型的 channel,不过 Go 也允许指定 channel 的缓冲大小,很简单,就是 channel 可以存储多少元素。ch:= make(chan bool, 4),创建了可以存储4个元素的 bool 型 channel。在这个 channel 中,前4个元素可以无阻塞的写入。当写入第5个元素时,代码将会阻塞,直到其他 goroutinechannel 中读取一些元素,腾出空间。

1
ch := make(chan type, value)

value = 0 时,channel 是无缓冲阻塞读写的,当 value > 0 时,channel 有缓冲、是非阻塞的,直到写满 value 个元素才阻塞写入。

看下面这个例子。

1
2
3
4
5
6
7
8
9
10
11
package main
import "fmt"
func main() {
c := make(chan int, 2)//修改2为1就报错,修改2为3可以正常运行
c <- 1
c <- 2
fmt.Println(<-c)
fmt.Println(<-c)
}
//修改为1报如下的错误:
//fatal error: all goroutines are asleep - deadlock!

2-5.5 Range 和 Close

上面这个例子中,需要读取两次 c,这样不是很方便,Go 考虑到了这一点,所以也可以通过 range,像操作 slice 或者 map 一样操作缓存类型的 channel,请看下面的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main
import (
"fmt"
)
func fibonacci(n int, c chan int) {
x, y := 1, 1
for i := 0; i < n; i++ {
c <- x
x, y = y, x + y
}
close(c)
}
func main() {
c := make(chan int, 10)
go fibonacci(cap(c), c)
for i := range c {
fmt.Println(i)
}
}

管道取值需要使用 for i := range c,能够不断的读取 channel 里面的数据,直到该 channel 被显式的关闭。上面代码看到可以显式的关闭 channel,生产者通过内置函数 close 关闭 channel

关闭 channel 之后就无法再发送任何数据了,在消费方可以通过语法 v, ok := <-ch 测试 channel 是否被关闭。如果 ok 返回 false,那么说明 channel 已经没有任何数据并且已经被关闭。(被关闭的 channel 只能出,无法写入。)

记住应该在生产者的地方关闭 channel,而不是消费的地方去关闭它,这样容易引起 panic

另外记住一点的就是 channel 不像文件之类的,不需要经常去关闭,只有确实没有任何发送数据了,或者想显式的结束range循环之类的。

案例

1.开启一个 writeData 协程,向管道 intChan 写入 50 个整数。

2.开启一个 readData 协程,从管道中读取 intChan 中写入的数据。

3.两个协程操作的是同一个管道

4.主线程需要等待两个协程结束后再结束

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
package main

import (
"fmt"
)

func writeData(intChan chan int) {
for i := 0; i <= 50; i++ {
intChan <- i
}
close(intChan)
}

func readData(intChan chan int, exitChan chan bool) {
for v := range intChan {
fmt.Println("读取到数据:", v)
}
// 通知主goroutine读取已完成
exitChan <- true
}

func main() {
fmt.Println("程序开始")
intChan := make(chan int, 50)
exitChan := make(chan bool, 1)

go writeData(intChan)
go readData(intChan, exitChan)

// 阻塞,直到等待读取完成的通知
<-exitChan
// 此时可以安全地关闭exitChan,但通常情况下,如果程序即将结束,可以省略这一步
close(exitChan)

fmt.Println("程序结束")
}

2-5.6 Select

上面介绍的都是只有一个 channel 的情况,那么如果存在多个 channel 的时候,该如何操作呢,Go 里面提供了一个关键字 select,通过 select 可以监听 channel 上的数据流动。

select 默认是阻塞的,只有当监听的 channel 中有发送或接收可以进行时才会运行,当多个 channel 都准备好的时候,select 是随机的选择一个执行的。

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 fibonacci(c, quit chan int) {
x, y := 1, 1
for {
select {
case c <- x:
x, y = y, x + y
case <-quit:
fmt.Println("quit")
return
}
}
}
func main() {
c := make(chan int)
quit := make(chan int)
go func() {
for i := 0; i < 10; i++ {
fmt.Println(<-c)
}
quit <- 0
}()
fibonacci(c, quit)
}

select 里面还有 default 语法,select 其实就是类似 switch 的功能,default 就是当监听的 channel 都没有准备好的时候,默认执行的(select 不再阻塞等待 channel)。

1
2
3
4
5
6
select {
case i := <-c:
// use i
default:
// 当c阻塞的时候执行这里
}

2-5.7 超时

有时候会出现 goroutine 阻塞的情况,那么如何避免整个程序进入阻塞的情况呢?可以利用 select 来设置超时,通过如下的方式实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func main() {
c := make(chan int)
o := make(chan bool)
go func() {
for {
select {
case v := <- c:
println(v)
case <- time.After(5 * time.Second):
println("timeout")
o <- true
break
}
}
}()
<- o
}

2-5.8 runtime goroutine

runtime 包中有几个处理 goroutine 的函数:

  • Goexit : 退出当前执行的goroutine,但是defer函数还会继续调用

  • Gosched: 让出当前goroutine的执行权限,调度器安排其他等待的任务运行,并在下次某个时候从该位置恢复执行。

  • NumCPU : 返回 CPU 核数量

  • NumGoroutine: 返回正在执行和排队的任务总数

  • GOMAXPROCS : 用来设置可以并行计算的CPU核数的最大值,并返回之前的值。

三、Go 工具使用

3-1 Module

Go 1.11 版本引入了模块(module)系统,这是一个新的依赖管理系统,它使得管理 Go 项目的依赖更加方便和可靠。模块是相关 Go 包的集合,它们一起被版本控制。每个模块都有一个单独的 go.mod 文件,该文件定义了模块的路径和依赖项。

在使用模块的时候,GOPATH 是无意义的,

通过 go env 可以查看 golang 的环境变量

  • GO111MODULE=off,无模块支持,go 会从 GOPATH 和 vendor 文件夹寻找包
  • GO111MODULE=on,模块支持,go 会忽略 GOPATH 和 vendor 文件夹,只根据 go.mod 下载依赖
  • GO111MODULE=auto,在 $GOPATH/src 外面且根目录有 go.mod 文件时,开启模块支持

3-1.1 go mod 简介

Golang 1.11 版本引入的 go mod ,其思想类似 maven:摒弃 vendor 和 GOPATH,拥抱本地库。从 Go 1.11 开始,Go 允许在 $GOPATH/src 外的任何目录下使用 go.mod 创建项目。在 $GOPATH/src 中,为了兼容性,Go 命令仍然在旧的 GOPATH 模式下运行。从 Go 1.13 开始,Module 模式将成为默认模式。

Go mod 本质上就是一个包管理工具,相关命令如下

1
2
3
4
5
6
7
8
9
10
go mod
The commands are:
download download modules to local cache (下载依赖的module到本地cache))
edit edit go.mod from tools or scripts (编辑go.mod文件)
graph print module requirement graph (打印模块依赖图))
init initialize new module in current directory (再当前文件夹下初始化一个新的module, 创建go.mod文件))
tidy add missing and remove unused modules (增加丢失的module,去掉未用的module)
vendor make vendored copy of dependencies (将依赖复制到vendor下)
verify verify dependencies have expected content (校验依赖)
why explain why packages or modules are needed (解释为什么需要依赖)

3-1.2 使用 go mod 管理包

首先进入项目文件夹,进行初始化。

1
go mod init [proj name]

3-2 go build

Go 语言的编译速度非常快。Go 1.17 版本后默认利用 Go 语言的并发特性进行函数粒度的并发编译。

3-2.1 常见的编译参数

语法遵循下面的结构

1
go build [-o 输出名] [-i] [编译标记] [包名]
  • 如果参数为 XX.go 文件或文件列表,则编译为一个个单独的包。

  • 当编译单个 main 包(文件),则生成可执行文件。

  • 当编译包时,会自动忽略 _test.go 的测试文件。

如果需要对当前目录进行编译,可以省略参数。

1
2
go build
go build .

如果编译包,就可以跟上包的名称(支持通配符)。

1
2
go build github.com/ourlang/noutil
go build github.com/ourlang/noutil/...

3-2.2 跨平台编译

Go 语言支持跨平台的交叉编译,可以编译出不同平台的可执行文件。只需要配置 go env 文件中的参数即可。

其中,重要的是修改 GOOSGOARCHCGO_ENABLEd三个环境变量。

1
go env
  • GOOS:目标平台的操作系统(darwin、freebsd、linux、windows)
  • GOARCH:目标平台的体系架构32位还是64位(386、amd64、arm)
  • 交叉编译不支持 CGO 所以要禁用它
3-2.2.1 Windows 编译

Windows 下编译 Mac 和 Linux 64位可执行程序

1
2
3
4
5
6
7
8
9
SET CGO_ENABLED=0
SET GOOS=darwin
SET GOARCH=arm
go build main.go

SET CGO_ENABLED=0
SET GOOS=linux
SET GOARCH=amd64
go build main.go
3-2.2.2 Mac 编译

Mac 下编译 Linux 和 Windows 64位可执行程序对应参数。

1
2
3
4
5
6
7
8
9
SET CGO_ENABLED=0
SET GOOS=linux
SET GOARCH=amd64
go build main.go

SET CGO_ENABLED=0
SET GOOS=windows
SET GOARCH=amd64
go build main.go
3-2.2.3 Linux 编译

Linux 下编译 Mac 和 Windows 64位可执行程序对应参数。

1
2
3
4
5
6
7
8
9
SET CGO_ENABLED=0
SET GOOS=darwin
SET GOARCH=arm
go build main.go

SET CGO_ENABLED=0
SET GOOS=windows
SET GOARCH=amd64
go build main.go

3-3 go get

go get 命令允许用户通过代码版本控制工具从远程仓库拉取或更新 Go 代码包及其依赖,并自动编译和安装这些包。这个过程类似于安装应用程序一样简单。go get 能够从多个源获取代码包,包括 BitBucket、GitHub、Google Code 和 Launchpad。在使用 go get 之前,需要确保安装了与远程代码库相对应的版本控制工具,例如 Git、SVN 或 Mercurial。执行 go get 时,需要指定要下载的包的名称。

1
go get [-d] [-f] [-t] [-u] [-fix] [-insecure] [build flags] [packages]
参数 描述
-d 让命令程序只执行下载动作,而不执行安装动作。
-f 仅在使用-u标记时才有效。该标记会让命令程序忽略掉对已下载代码包的导入路径的检查。如果下载并安装的代码包所属的项目是你从别人那里Fork过来的,那么这样做就尤为重要了。
-fix 让命令程序在下载代码包后先执行修正动作,而后再进行编译和安装。
-insecure 允许命令程序使用非安全的scheme(如HTTP)去下载指定的代码包。如果你用的代码仓库(如公司内部的Gitlab)没有HTTPS支持,可以添加此标记。请在确定安全的情况下使用它。
-t 让命令程序同时下载并安装指定的代码包中的测试源码文件中依赖的代码包。
-u 让命令利用网络来更新已有代码包及其依赖包。默认情况下,该命令只会从网络上下载本地不存在的代码包,而不会更新已有的代码包。
-v 打印出被构建的代码包的名字
-x 打印出用到的命令

四、编码规范

4-1 命名规范

4-1.1 文件命名

尽量采取有意义的文件名,简短,有意义,应该为小写单词,使用下划线分隔各个单词。

1
my_test.go

4-1.2 包命名

保持 package 的名字和目录保持一致,尽量采取有意义的包名,简短,有意义,尽量和标准库不要冲突。包名应该为小写单词,不要使用下划线或者混合大小写。

1
2
3
4
5
package demo

package main

package utils

4-1.3 结构体命名

采用驼峰命名法,首字母根据访问控制大写或者小写。

struct 申明和初始化格式采用多行,例如下面:

1
2
3
4
5
6
7
8
9
10
11
// 多行申明
type User struct{
UserName string
Email string
}

// 多行初始化
u := User{
UserName: "astaxie",
Email: "astaxie@gmail.com",
}

4-1.4 接口命名

命名规则基本和上面的结构体类型

单个函数的结构名以 er 作为后缀,例如 Reader, Writer

1
2
3
type Reader interface {
Read(p []byte) (n int, err error)
}

两个名称则结合两个方法名称

1
2
3
4
type WriteFlusher interface {
Write([]byte) (int, error)
Flush() error
}

三个及以上方法,可以使用功能命名

1
2
3
4
5
type Car interface {
Start([]byte)
Stop() error
Recover()
}

4-1.5 函数命名

若函数或方法为判断类型(返回值主要为 bool 类型),则名称应以 Has, Is, CanAllow 等判断性动词开头。

1
2
3
4
func HasPrefix(name string, prefixes []string) bool { ... }
func IsEntry(name string, entries []string) bool { ... }
func CanManage(name string) bool { ... }
func AllowGitHook() bool { ... }

4-1.6 常量命名

常量包含(布尔常量、符文常量、整数常量、浮点数常量、复数常量和字符串常量)。字符、整数、浮点数和复数常量统称为数值常量

常量均需使用全部大写字母组成,并使用下划线分词:const APP_VER = “1.0”

如果是枚举类型的常量,需要先创建相应类型:

1
2
3
4
5
type Scheme string
const (
HTTP Scheme = "http"
HTTPS Scheme = "https"
)

如果模块的功能较为复杂、常量名称容易混淆的情况下,为了更好地区分枚举类型,可以使用完整的前缀:

1
2
3
4
5
6
type PullRequestStatus int
const (
PULL_REQUEST_STATUS_CONFLICT PullRequestStatus = iota
PULL_REQUEST_STATUS_CHECKING
PULL_REQUEST_STATUS_MERGEABLE
)

4-1.7 变量命名

驼峰命名式。局部变量用小写字母开头。需要在 package 外部使用的全局变量用大写字母开头,否则用小写字母开头。

全局变量:采用驼峰命名方式,仅限在包内的全局变量。

1
2
3
4
5
var ProjectName string 
//如多组变量则使用,组和声明或者平行赋值
var(
ProjectName string
)

局部变量:采用小驼峰命名方式,注意声明局部变量尽量使用 :=

简洁性:使用 := 可以在声明变量的同时自动推导其类型,这样可以让代码更加简洁,减少冗余。

明确性:使用 := 声明的变量在视觉上很容易与已经声明过的变量区分开来,这样可以减少在阅读代码时的混淆。

可读性:当变量类型对于阅读代码不是很重要时(通常在函数内部),使用 := 可以让代码更加专注于逻辑而不是类型,从而提高代码的可读性。

灵活性:在函数内部,变量的作用域通常是明确的,使用 := 可以更容易地在需要的地方声明新的变量,而不必担心变量名的冲突。

1
projectName := "name"

在相对简单的环境(对象数量少、针对性强)中,可以将一些名称由完整单词简写为单个字母。

  • user 可以简写为 u
  • userID 可以简写 uid
  • 若变量类型为 bool 类型,则名称应以 Has, Is, Can 或 Allow 开头
1
2
3
4
var isExist bool
var hasConflict bool
var canManage bool
var allowGitHook bool

4-2 注释规范

单行注释是最常见的注释形式,你可以在任何地方使用以//开头的单行注释

多行注释也叫块注释,均已以/*开头,并以*/结尾,且不可以嵌套使用,多行注释一般用于包的文档描述或注释成块的代码片段。

4-2.1 包注释

每个包都应该有一个包注释,一个位于 package 子句之前的块注释或行注释。包如果有多个 go 文件,只需要出现在一个 go 文件中(一般是和包同名的文件)即可。 包注释应该包含下面基本信息(请严格按照这个顺序,简介,创建人,创建时间):

  • 包的基本简介(包名,简介)
  • 创建者,格式: 创建人: rtx 名
  • 创建时间,格式:创建时间: yyyyMMdd
1
2
3
// util 包, 该包包含了项目共用的一些常量,封装了项目中一些共用函数。
// 创建人: xxx
// 创建时间: 20240101

4-2.2 结构体/接口注释

每个自定义的结构体或者接口都应该有注释说明,该注释对结构进行简要介绍,放在结构体定义的前一行。

格式为: [结构体名] [结构体说明]。同时结构体内的每个成员变量都要有说明,该说明放在成员变量的后面(注意对齐),实例如下:

1
2
3
4
5
// User 用户对象,定义了用户的基础信息
type User struct{
Username string // 用户名
Email string // 邮箱
}

4-2.3 函数/方法注释

每个函数,或者方法(结构体或者接口下的函数称为方法)都应该有注释说明。

1
2
3
4
5
6
7
// @Title NewtAttrModel 
// @Description 属性数据层操作类的工厂方法
// @Auth xxx
// @Param ctx 上下文信息
// @Return 属性操作类指针
func NewAttrModel(ctx *common.Context) *AttrModel {
}

说明

1
2
3
4
5
6
7
8
9
10
11
@Title 这个 API 所表达的含义,是一个文本,空格之后的内容全部解析为 title
@Description 这个 API 详细的描述,是一个文本,空格之后的内容全部解析为 Description
@Param 参数,表示需要传递到服务器端的参数,有五列参数,使用空格或者 tab 分割,表示的含义如下
1 参数名
2 参数类型,可以有的值是 formData、query、path、body、header,
3 参数类型
4 是否必须
5 注释
@Success 成功返回给客户端的信息
@Failure 失败返回的信息,包含两个参数,使用空格分隔,第一个表示 status code,第二个表示错误信息
@router 路由信息,包含两个参数,使用空格分隔,第一个是请求的路由地址,支持正则和自定义路由,和之前的路由规则一样,第二个参数是支持的请求方法,放在 [] 之中,如果有多个方法,那么使用 , 分隔。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// @Title Get Product list
// @Description 开发时间 编写人 Get Product list by some info
// @Success 200 {object} models.ZDTProduct.ProductList
// @Param category_id query int false "category id"
// @Param brand_id query int false "brand id"
// @Param query query string false "query of search"
// @Param segment query string false "segment"
// @Param sort query string false "sort option"
// @Param dir query string false "direction asc or desc"
// @Param offset query int false "offset"
// @Param limit query int false "count limit"
// @Param price query float false "price"
// @Param special_price query bool false "whether this is special price"
// @Param size query string false "size filter"
// @Param color query string false "color filter"
// @Param format query bool false "choose return format"
// @Failure 400 no enough input
// @Failure 500 get products common error
// @router /products [get]

4-2.4 代码逻辑注释

对于一些关键位置的代码逻辑,或者局部较为复杂的逻辑,需要有相应的逻辑说明,方便其他开发者阅读该段代码。但也需要注意不用注释一些显而易见的内容,例如这行代码是打开xxx文件。

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
package main

import (
"database/sql"
"fmt"
//执行driver.go文件中的init(),向"database/sql"注册一个mysql的驱动
_ "github.com/go-sql-driver/mysql"
)

func main() {
dsn := "root:admin@tcp(127.0.0.1:3306)/go_test?charset=utf8"
//Open打开一个driverName指定的数据库,dataSourceName指定数据源
//不会校验用户名和密码是否正确,只会对dsn的格式进行检测
db, err := sql.Open("mysql", dsn)
if err != nil { //dsn格式不正确的时候会报错
fmt.Printf("打开数据库失败,err:%v\n", err)
return
}
//尝试连接数据库,Ping方法可检查数据源名称是否合法,账号密码是否正确。
err = db.Ping()
if err != nil {
fmt.Printf("连接数据库失败,err:%v\n", err)
return
}
fmt.Println("连接数据库成功!")
}

4-2.5 bug 注释

在对应的 bug 上标注原因

1
2
// BUG(astaxie):This divides by zero. 
var i float = 1/0

4-2.6 注释风格

统一使用中文注释(除非是大型开源项目,不然读起来容易有歧义),对于中英文字符之间严格使用空格分隔, 这个不仅仅是中文和英文之间,英文和中文标点之间也都要使用空格分隔。单注释也不要太长。

1
// 从 Redis 中批量读取属性,对于没有读取到的 id , 记录到一个数组里面,准备从 DB 中读取

4-3 项目开发规范

4-3.1 项目结构

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
+--bin 编译后的文件
+--pkg 本项目或其他项目使用的包
项目根目录
+-- api 接口规范目录
+-- cmd
+-- swagger 自动化API文档
+-- test 该目录放的是临时的测试方法
+-- config 所有的配置文件目录
+-- internal 只在本项目使用的包
+-- doc 说明文档(含go-bindata和mysql文件)
+-- exec_package 可执行的打包文件(目前只有win 64bit的打包)
+-- inits 所有需初始化的目录
| +-- parse 所有配置文件的初始化目录
| +-- init.go 用于初始化系统root用户,并注入所有service
+-- middleware 包含的中间件目录
| +-- casbins 用于rbac权限的中间件的目录
| +-- jwts jwt中间件目录
+-- resources 打包的前端静态资源文件
| +-- img 静态图片
| +-- html 网页资源
| +-- file 文件资源
+-- utils 工具包目录
+-- plugin 插件,扩展码的包
+-- web
| +-- db 数据库dao层目录
| +-- models models 存放实体类
| +-- service 业务逻辑
| +-- controller 所有分发出来的路由的目录
| +-- supports 提供辅助方法的目录(可以无)
+-- main.go 入口

4-3.2 包管理

统一使用Go Model进行包管理,对标准包,程序内部包,第三方包进行分组。

1
2
3
4
5
6
7
8
9
import (
"encoding/json" //标准包
"strings"

"myproject/models" //内部包
"myproject/utils"

"github.com/go-sql-driver/mysql" //第三方包
)

常见的 go mod 命令

go mod 命令 描述
go mod download 下载依赖的module到本地cache(默认为$GOPATH/pkg/mod目录)
go mod edit 编辑go.mod文件
go mod graph 打印模块依赖图
go mod init 初始化当前文件夹, 创建go.mod文件
go mod tidy 增加缺少的module,删除无用的module
go mod vendor 将依赖复制到vendor下
go mod verify 校验依赖
go mod why 解释为什么需要依赖