发布于 

Go SDK:context、sync、reflect、errors

Go 语言类库要点整理 📄

context

协程如何退出

一个协程启动后,大部分情况需要等待里面的代码执行完毕,协程才会自行退出,如何让协程提前退出?

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

import (
"fmt"
"sync"
"time"
)

func main() {
var wg sync.WaitGroup
wg.Add(1)
stopCh := make(chan bool) //用来停止监控狗
go func() {
defer wg.Done()
watchDog(stopCh, "【监控狗1】")
}()
time.Sleep(5 * time.Second) //先让监控狗监控5秒
stopCh <- true //发停止指令
wg.Wait()
}

func watchDog(stopCh chan bool, name string) {
//开启 for select 循环,一直后台监控
for {
select {
case <-stopCh:
fmt.Println(name, "停止指令已收到,马上停止")
return
default:
fmt.Println(name, "正在监控……")
}
time.Sleep(1 * time.Second)
}
}

实现了通过 select + channel 发送指令让监控狗停止,进而达到协程退出的目的。

初识 Context

如果希望做到同时取消很多个协程或定时取消协程又该怎么办?这时候 select + channel 的局限性就凸现出来了,即使定义了多个 channel 解决问题,代码逻辑也会非常复杂、难以维护。

通过 Context 重写上面的示例,实现让监控狗停止的功能:

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
func main() {
var wg sync.WaitGroup
wg.Add(1)
ctx, stop := context.WithCancel(context.Background())
go func() {
defer wg.Done()
watchDog(ctx, "【监控狗1】")
}()
time.Sleep(5 * time.Second) //先让监控狗监控5秒
stop() //发停止指令
wg.Wait()

}

func watchDog(ctx context.Context, name string) {
//开启for select循环,一直后台监控
for {
select {
case <-ctx.Done():
fmt.Println(name, "停止指令已收到,马上停止")
return
default:
fmt.Println(name, "正在监控……")
}
time.Sleep(1 * time.Second)
}
}

相比 select+channel 的方案,Context 方案主要有 4 个改动点:

  1. watchDog 的 stopCh 参数换成了 ctx,类型为 context.Context。
  2. 原来的 case <-stopCh 改为 case <-ctx.Done(),用于判断是否停止。
  3. 使用 context.WithCancel(context.Background()) 函数生成一个可以取消的 Context,用于发送停止指令。这里的 context.Background() 用于生成一个空 Context,一般作为整个 Context 树的根节点。
  4. 原来的 stopCh <- true 停止指令,改为 context.WithCancel 函数返回的取消函数 stop()

这和修改前的整体代码结构一样,只不过从 channel 换成了 Context。

什么是 Context

一个任务会有很多个协程协作完成,一次 HTTP 请求也会触发很多个协程的启动,而这些协程有可能会启动更多的子协程,并且无法预知有多少层协程、每一层有多少个协程。

如果因为某些原因导致任务终止了,HTTP 请求取消了,那么它们启动的协程怎么办?该如何取消呢?因为取消这些协程可以节约内存,提升性能,同时避免不可预料的 Bug。

Context 就是用来简化解决这些问题的,并且是并发安全的。Context 是一个接口,它具备手动、定时、超时发出取消信号、传值等功能,主要用于控制多个协程之间的协作,尤其是取消操作。一旦取消指令下达,那么被 Context 跟踪的这些协程都会收到取消信号,就可以做清理和退出操作。

Context 接口只有四个方法:

1
2
3
4
5
6
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
  1. Deadline 方法可以获取设置的截止时间,第一个返回值 deadline 是截止时间,到了这个时间点,Context 会自动发起取消请求,第二个返回值 ok 代表是否设置了截止时间。
  2. Done 方法返回一个只读的 channel,类型为 struct{}。在协程中,如果该方法返回的 chan 可以读取,则意味着 Context 已经发起了取消信号。通过 Done 方法收到这个信号后,就可以做清理操作,然后退出协程,释放资源。
  3. Err 方法返回取消的错误原因,即因为什么原因 Context 被取消。
  4. Value 方法获取该 Context 上绑定的值,是一个键值对,所以要通过一个 key 才可以获取对应的值。

Context 接口的四个方法中最常用的就是 Done 方法,它返回一个只读的 channel,用于接收取消信号。当 Context 取消的时候,会关闭这个只读 channel,也就等于发出了取消信号。

Context 树

Go 语言提供了函数可以帮助生成不同的 Context,通过这些函数可以生成一颗 Context 树,这样 Context 才可以关联起来,父 Context 发出取消信号的时候,子 Context 也会发出,这样就可以控制不同层级的协程退出。

从使用功能上分,有四种实现好的 Context。

  1. 空 Context:不可取消,没有截止时间,主要用于 Context 树的根节点。
  2. 可取消的 Context:用于发出取消信号,当取消的时候,它的子 Context 也会取消。
  3. 可定时取消的 Context:多了一个定时的功能。
  4. 值 Context:用于存储一个 key-value 键值对。

在 Go 语言中,可以通过 context.Background() 获取一个根节点 Context,有了根节点 Context 后,使用 Go 语言提供的四个函数生成这棵 Context 树:

  1. **WithCancel(parent Context)**:生成一个可取消的 Context。
  2. **WithDeadline(parent Context, d time.Time)**:生成一个可定时取消的 Context,参数 d 为定时取消的具体时间。
  3. **WithTimeout(parent Context, timeout time.Duration)**:生成一个可超时取消的 Context,参数 timeout 用于设置多久后取消
  4. **WithValue(parent Context, key, val interface{})**:生成一个可携带 key-value 键值对的 Context。

以上四个生成 Context 的函数中,前三个都属于可取消的 Context,它们是一类函数,最后一个是值 Context,用于存储一个 key-value 键值对。

取消多个协程

要取消多个协程,把 Context 作为参数传递给协程即可,还是以监控狗为例,如下所示:

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 main() {
var wg sync.WaitGroup
wg.Add(3)
ctx, stop := context.WithCancel(context.Background())
go func() {
defer wg.Done()
watchDog(ctx, "【监控狗1】")
}()
go func() {
defer wg.Done()
watchDog(ctx, "【监控狗2】")
}()
go func() {
defer wg.Done()
watchDog(ctx, "【监控狗3】")
}()
time.Sleep(5 * time.Second)
// 发出取消信号,这三个协程都会退出,他们的根节点都是 main 中的 ctx
stop()
wg.Wait()
}

func watchDog(ctx context.Context, name string) {
for {
select {
case <-ctx.Done():
fmt.Println(name, "停止指令已收到,马上停止")
return
default:
fmt.Println(name, "正在监控……")
}
time.Sleep(1 * time.Second)
}
}

Context 传值

Context 不仅可以取消,还可以传值,通过这个能力,可以把 Context 存储的值供其他协程使用。

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
func main() {
var wg sync.WaitGroup
wg.Add(1)
ctx, cancelFunc := context.WithCancel(context.Background())
valCtx := context.WithValue(ctx, "userId", 2)
go func() {
defer wg.Done()
getUser(valCtx)
}()
time.Sleep(5 * time.Second)
cancelFunc()
wg.Wait()
}

func getUser(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("【获取用户】", "协程退出")
return
default:
userId := ctx.Value("userId")
fmt.Println("【获取用户】", "用户ID为:", userId)
time.Sleep(1 * time.Second)
}
}
}

通过 context.WithValue() 函数存储一个 userId 为 2 的键值对,就可以在 getUser 函数中通过 ctx.Value("userId") 方法把对应的值取出来,达到传值的目的。

Context 使用原则

Context 是一种非常好的工具,使用它可以很方便地控制取消多个协程。在 Go 语言标准库中也使用了它们,比如 net/http 中使用 Context 取消网络的请求。

要更好地使用 Context,有一些使用原则需要尽可能地遵守。

  1. Context 不要放在结构体中,要以参数的方式传递。
  2. Context 作为函数的参数时,要放在第一位,也就是第一个参数。
  3. 要使用 context.Background() 函数生成根节点的 Context,也就是最顶层的 Context。
  4. Context 传值要传递必须的值,而且要尽可能地少,不要什么都传。
  5. Context 多协程安全,可以在多个协程中放心使用。

以上原则是规范类的,Go 语言的编译器并不会做这些检查,要靠自己遵守。

sync

channel 为什么是并发安全的呢?是因为 channel 内部使用了互斥锁 来保证并发的安全。在 Go 语言中,不仅有 channel 这类比较易用且高级的同步机制,还有 sync.Mutex、sync.WaitGroup 等比较原始的同步机制。通过它们可以更加灵活地 控制数据的同步和多协程的并发

资源竞争

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//共享的资源
var sum = 0
func main() {

//开启100个协程让sum+10
for i := 0; i < 100; i++ {
go add(10)
}

//防止提前退出
time.Sleep(2 * time.Second)
fmt.Println("和为:",sum)
}

func add(i int) {
sum += i
}

小技巧:使用 go build、go run、go test 这些 Go 语言工具链提供的命令时,添加 -race 标识可以帮检查 Go 语言代码是否存在资源竞争。

同步原语

sync.Mutex

互斥锁 指的是在同一时刻只有一个协程执行某段代码,其他协程都要等待该协程执行完毕后才能继续执行。

1
2
3
4
5
6
7
8
9
10
11
var(
sum int
mutex sync.Mutex
)

func add(i int) {
mutex.Lock()
defer mutex.Unlock()
sum += i
// mutex.Unlock()
}

以上被加锁保护的 sum+= i 代码片段又称为 临界区。在同步的程序设计中,临界区段指的是一个访问共享资源的程序片段,而这些共享资源又有无法同时被多个协程访问的特性。当有协程进入临界区段时,其他协程必须等待,这样就保证了临界区的并发安全。

sync.RWMutex

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func main() {
for i := 0; i < 100; i++ {
go add(10)
}

for i:=0; i<10;i++ {
go fmt.Println("和为:",readSum())
}
time.Sleep(2 * time.Second)
}

//增加了一个读取sum的函数,便演示并发
func readSum() int {
mutex.Lock()
defer mutex.Unlock()
b:=sum
return b
}

解决了多个 goroutine 同时读写的资源竞争问题,但是又遇到另外一个问题——性能。因为每次读写共享资源都要加锁,所以性能低下,有以下几种情况:

  1. 写的时候不能同时读,因为这个时候读取的话可能读到脏数据(不正确的数据);
  2. 读的时候不能同时写,因为也可能产生不可预料的结果;
  3. 读的时候可以同时读,因为数据不会改变,所以不管多少个 goroutine 读都是并发安全的。

通过读写锁 sync.RWMutex 来优化这段代码,提升性能:

1
2
3
4
5
6
7
8
var mutex sync.RWMutex
func readSum() int {
//只获取读锁
mutex.RLock()
defer mutex.RUnlock()
b:=sum
return b
}

把锁的声明换成读写锁 sync.RWMutex,把函数 readSum 读取数据的代码换成读锁,也就是 RLock 和 RUnlock,这样性能就会有很大的提升,因为多个 goroutine 可以同时读数据,不再相互等待。

sync.WaitGroup

上面代码的 time.Sleep(2 * time.Second),这是为了防止主函数 main 返回使用,一旦 main 函数返回了,程序也就退出了,因为不知道 100 个执行 add 的协程和 10 个执行 readSum 的协程什么时候完全执行完毕,所以设置了一个比较长的等待时间,也就是两秒。

有没有办法监听所有协程的执行,一旦全部执行完毕,程序马上退出,这样既可保证所有协程执行完毕,又可以及时退出节省时间,提升性能。

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
func main() {
run()
}

func run(){
var wg sync.WaitGroup
//因为要监控110个协程,所以设置计数器为110
wg.Add(110)
for i := 0; i < 100; i++ {
go func() {
//计数器值减1
defer wg.Done()
add(10)
}()
}
for i:=0; i<10;i++ {
go func() {
//计数器值减1
defer wg.Done()
fmt.Println("和为:",readSum())
}()
}
//一直等待,只要计数器值为0
wg.Wait()
}

通过 sync.WaitGroup 可以很好地跟踪协程。在协程执行完毕后,整个 run 函数才能执行完毕,时间不多不少,正好是协程执行的时间。

sync.Once

让代码只执行一次,哪怕是在高并发的情况下,比如创建一个单例。针对这种情形,Go 语言为提供了 sync.Once 来保证代码只执行一次

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
func main() {
doOnce()
}

func doOnce() {
var once sync.Once
onceBody := func() {
fmt.Println("Only once")
}

//用于等待协程执行完毕
done := make(chan bool)

//启动10个协程执行once.Do(onceBody)
for i := 0; i < 10; i++ {
go func() {
//把要执行的函数(方法)作为参数传给once.Do方法即可
once.Do(onceBody)
done <- true
}()
}

for i := 0; i < 10; i++ {
<-done
}
}

sync.Cond

sync.WaitGroup 用于最终完成的场景,关键点在于一定要等待所有协程都执行完毕;而 sync.Cond 可以用于发号施令,一声令下所有协程都可以开始执行,关键点在于协程开始的时候是等待的,要等待 sync.Cond 唤醒才能执行。

sync.Cond 从字面意思看是条件变量,它具有阻塞协程和唤醒协程的功能,所以可以在满足一定条件的情况下唤醒协程,但条件变量只是它的一种使用场景。

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
//10个人赛跑,1个裁判发号施令
func race(){

cond :=sync.NewCond(&sync.Mutex{})
var wg sync.WaitGroup
wg.Add(11)

for i:=0;i<10; i++ {
go func(num int) {
defer wg.Done()
fmt.Println(num,"号已经就位")
cond.L.Lock()
cond.Wait()//等待发令枪响
fmt.Println(num,"号开始跑……")
cond.L.Unlock()
}(i)
}

//等待所有goroutine都进入wait状态
time.Sleep(2*time.Second)

go func() {
defer wg.Done()
fmt.Println("裁判已经就位,准备发令枪")
fmt.Println("比赛开始,大家准备跑")
cond.Broadcast()//发令枪响
}()
//防止函数提前返回退出
wg.Wait()
}

sync.Cond 有三个方法,它们分别是:

  1. Wait,阻塞当前协程,直到被其他协程调用 Broadcast 或者 Signal 方法唤醒,使用的时候需要加锁,使用 sync.Cond 中的锁即可,也就是 L 字段。
  2. Signal,唤醒一个等待时间最长的协程。
  3. Broadcast,唤醒所有等待的协程。

在调用 Signal 或者 Broadcast 之前,要确保目标协程处于 Wait 阻塞状态,不然会出现死锁问题。

reflect

反射提供了一种可以在运行时操作任意类型对象的能力,比如查看一个接口变量的具体类型、看看一个结构体有多少字段、修改某个字段的值等。

Go 语言是静态编译类语言,比如在定义一个变量的时候,已经知道了它是什么类型,那么为什么还需要反射呢?比如定义了一个函数,它有一个 interface{} (any)类型的参数,这也就意味着调用者可以传递任何类型的参数给这个函数。在这种情况下,如果想知道调用者传递的是什么类型的参数,就需要用到反射。如果想知道 一个结构体有哪些字段和方法,也需要反射。

在 Go 语言的反射定义中,任何接口都由两部分组成:接口的具体类型,以及具体类型对应的值。

interface{} 是空接口,可以表示任何类型,也就是说可以把任何类型转换为空接口,它通常用于 反射、类型断言,以减少重复代码,简化编程。

1
2
3
4
5
6
func main() {
i:=3
iv:=reflect.ValueOf(i)
it:=reflect.TypeOf(i)
fmt.Println(iv,it) // 3 int
}

reflect.Value

在 Go 语言中,reflect.Value 被定义为一个 struct 结构体:

1
2
3
4
5
type Value struct {
typ *rtype
ptr unsafe.Pointer
flag
}

它的常用方法有三类:

  • 一类用于获取和修改对应的
  • 一类和 struct 类型的 字段 有关,用于获取对应的字段;
  • 一类和类型上的 方法集 有关,用于获取对应的方法。

获取原始类型

reflect.Value 和 int 类型互转

1
2
3
4
5
6
7
8
func main() {
i:=3
//int to reflect.Value
iv:=reflect.ValueOf(i)
//reflect.Value to int
i1:=iv.Interface().(int)
fmt.Println(i1)
}

修改对应的值

因为 reflect.ValueOf 函数返回的是一份值的拷贝,所以要传入变量的指针,因为传递的是一个指针,所以需要调用 Elem 方法找到这个指针指向的值,这样才能修改。

1
2
3
4
5
6
7
// 修改一个变量的值
func main() {
i:=3
ipv:=reflect.ValueOf(&i)
ipv.Elem().SetInt(4)
fmt.Println(i)
}

修改 struct 结构体字段的值,可总结出以下步骤:

  1. 传递一个 struct 结构体的指针,获取对应的 reflect.Value;
  2. 通过 Elem 方法获取指针指向的值;
  3. 通过 Field 方法获取要修改的字段;
  4. 通过 Set 系列方法修改成对应的值。
1
2
3
4
5
6
7
8
9
10
11
// 修改 struct 结构体字段的值
func main() {
p:=person{Name: "hcjjj", Age: 26}
ppv:=reflect.ValueOf(&p)
ppv.Elem().Field(0).SetString("HCJ")
fmt.Println(p)
}
type person struct {
Name string
Age int
}

在程序运行时通过反射修改一个变量或字段的值的规则:

  1. 可被寻址,通俗地讲就是要向 reflect.ValueOf 函数传递一个指针作为参数。
  2. 如果要修改 struct 结构体字段值的话,该字段需要是可导出的,而不是私有的,也就是该字段的首字母为大写。
  3. 记得使用 Elem 方法获得指针指向的值,这样才能调用 Set 系列方法进行修改。

获取对应的底层类型

底层类型对应的主要是基础类型,比如接口、结构体、指针……因为可以通过 type 关键字声明很多新的类型。变量 p 的实际类型是 person,但是 person 对应的底层类型是 struct 这个结构体类型,而 &p 对应的则是指针类型。

1
2
3
4
5
6
7
func main() {
p:=person{Name: "hcjjj",Age: 26}
ppv:=reflect.ValueOf(&p)
fmt.Println(ppv.Kind()) // ptr
pv:=reflect.ValueOf(p)
fmt.Println(pv.Kind()) // struct
}

reflect.Type

reflect.Value 可以用于与值有关的操作中,而如果是和变量类型本身有关的操作,则最好使用 reflect.Type,比如要获取结构体对应的字段名称或方法。

接口定义

和 reflect.Value 不同,reflect.Type 是一个接口,而不是一个结构体,所以也只能使用它的方法。对比 reflect.Value 的方法功能, reflect.Type 几个特有的方法如下:

  1. Implements 方法用于判断是否实现了接口 u;
  2. AssignableTo 方法用于判断是否可以赋值给类型 u,其实就是是否可以使用 =,即赋值运算符;
  3. ConvertibleTo 方法用于判断是否可以转换成类型 u,其实就是是否可以进行类型转换;
  4. Comparable 方法用于判断该类型是否是可比较的,其实就是是否可以使用关系运算符进行比较。

遍历结构体的字段和方法

1
2
3
4
5
6
7
8
9
10
11
12
func main() {
p:=person{Name: "hcjjj",Age: 26}
pt:=reflect.TypeOf(p)
//遍历person的字段
for i:=0;i<pt.NumField();i++{
fmt.Println("字段:",pt.Field(i).Name)
}
//遍历person的方法
for i:=0;i<pt.NumMethod();i++{
fmt.Println("方法:",pt.Method(i).Name)
}
}

可以通过 FieldByName 方法获取指定的字段,也可以通过 MethodByName 方法获取指定的方法,这在需要获取某个特定的字段或者方法时非常高效,而不是使用遍历。

是否实现某接口

1
2
3
4
5
6
7
8
func main() {
p:=person{Name: "hcjjj",Age: 26}
pt:=reflect.TypeOf(p)
stringerType:=reflect.TypeOf((*fmt.Stringer)(nil)).Elem()
writerType:=reflect.TypeOf((*io.Writer)(nil)).Elem()
fmt.Println("是否实现了fmt.Stringer:",pt.Implements(stringerType))
fmt.Println("是否实现了io.Writer:",pt.Implements(writerType))
}

尽可能通过类型断言的方式判断是否实现了某接口,而不是通过反射。

string ↔️ struct

字符串和结构体互转的场景中,使用最多的就是 JSON 和 struct 互转,还有配置文件和 struct 的转换

JSON 和 Struct 互转

Go 语言的标准库有一个 json 包,通过 json.Marshal 函数,可以把一个 struct 转为 JSON 字符串。通过 json.Unmarshal 函数,可以把一个 JSON 字符串转为 struct。

1
2
3
4
5
6
7
8
9
10
11
12
func main() {
p:=person{Name: "hcjjj",Age: 26}
//struct to json
jsonB,err:=json.Marshal(p)
if err==nil {
fmt.Println(string(jsonB))
}
//json to struct
respJSON:="{\"Name\":\"hcjjj\",\"Age\":26}"
json.Unmarshal([]byte(respJSON),&p)
fmt.Println(p)
}

JSON 字符串的 Key 和 struct 结构体的字段名称一样

Struct Tag

struct tag 是一个添加在 struct 字段上的标记,使用它进行辅助,可以完成一些额外的操作,比如 json 和 struct 互转。如果想把输出的 json 字符串的 Key 改为小写的 name 和 age,可以通过为 struct 字段添加 tag 的方式。

1
2
3
4
type person struct {
Name string `json:"name"`
Age int `json:"age"`
}

json 作为 Key,是 Go 语言自带的 json 包解析 JSON 的一种约定,它会通过 json 这个 Key 找到对应的值,用于 JSON 的 Key 值。

这个 tag 就像是为 struct 字段起的别名,那么 json 包是如何获得这个 tag 的呢?这就需要反射了。

1
2
3
4
5
6
//遍历person字段中key为json的tag
// pt:=reflect.TypeOf(p)
for i:=0;i<pt.NumField();i++{
sf:=pt.Field(i)
fmt.Printf("字段%s上,json tag为%s\n",sf.Name,sf.Tag.Get("json"))
}

结构体的字段可以有多个 tag,用于不同的场景,比如 json 转换、bson 转换、orm 解析等。如果有多个 tag,要使用空格分隔。采用不同的 Key 可以获得不同的 tag:

1
2
3
4
5
6
7
8
9
10
11
12
//遍历person字段中key为json、bson的tag

// pt:=reflect.TypeOf(p)
for i:=0;i<pt.NumField();i++{
sf:=pt.Field(i)
fmt.Printf("字段%s上,json tag为%s\n",sf.Name,sf.Tag.Get("json"))
fmt.Printf("字段%s上,bson tag为%s\n",sf.Name,sf.Tag.Get("bson"))
}
type person struct {
Name string `json:"name" bson:"b_name"`
Age int `json:"age" bson:"b_name"`
}
1
2
3
4
字段Name上,key为json的tag为name
字段Name上,key为bson的tag为b_name
字段Age上,key为json的tag为age
字段Age上,key为bson的tag为b_name

通过不同的 Key,使用 Get 方法就可以获得自定义的不同的 tag。

实现 Struct 转 JSON

自定义的 jsonBuilder 负责 json 字符串的拼接,通过 for 循环把每一个字段拼接成 json 字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func main() {
p:=person{Name: "hcjjj",Age: 26}
pv:=reflect.ValueOf(p)
pt:=reflect.TypeOf(p)

//自己实现的struct to json
jsonBuilder:=strings.Builder{}
jsonBuilder.WriteString("{")
num:=pt.NumField()
for i:=0;i<num;i++{
jsonTag:=pt.Field(i).Tag.Get("json") //获取json tag
jsonBuilder.WriteString("\""+jsonTag+"\"")
jsonBuilder.WriteString(":")
//获取字段的值
jsonBuilder.WriteString(fmt.Sprintf("\"%v\"",pv.Field(i)))
if i<num-1{
jsonBuilder.WriteString(",")
}
}

jsonBuilder.WriteString("}")
fmt.Println(jsonBuilder.String())//打印json字符串

}

json 字符串的转换只是 struct tag 的一个应用场景,完全可以把 struct tag 当成结构体中字段的元数据配置,使用它来做想做的任何事情,比如 orm 映射、xml 转换、生成 swagger 文档等。

反射定律

反射是计算机语言中程序 检视其自身结构 的一种方法,它属于元编程的一种形式。反射灵活、强大,但也存在不安全。它可以绕过编译器的很多静态检查,如果过多使用便会造成混乱。Go 语言的作者在博客上总结了 反射的三大定律

  1. 任何接口值 interface{} 都可以反射出反射对象,也就是 reflect.Value 和 reflect.Type,通过函数 reflect.ValueOf 和 reflect.TypeOf 获得。
  2. 反射对象也可以还原为 interface{} 变量,也就是第 1 条定律的可逆性,通过 reflect.Value 结构体的 Interface 方法获得。
  3. 要修改反射的对象,该值必须可设置,也就是 可寻址

errors

错误是通过内置的 error 接口表示,它只有一个 Error 方法用来返回具体的错误信息:

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

一般而言,error 接口用于当方法或者函数执行遇到错误时进行返回,而且是第二个返回值。通过这种方式,可以让调用者自己根据错误信息决定如何进行下一步处理。

1
2
3
4
5
6
7
8
func main() {
i,err:=strconv.Atoi("a")
if err!=nil {
fmt.Println(err)
} else {
fmt.Println(i)
}
}
1
func Atoi(s string) (int, error)

error 工厂函数

自己定义的函数也可以返回错误信息给调用者:

1
2
3
4
5
6
7
func add(a, b int) (int, error){
if a<0 || b<0 {
return 0, errors.New("a 或者 b 不能为负数")
}else {
return a+b,nil
}
}
1
2
3
4
5
6
sum,err := add(-1,2)
if err != nil {
fmt.Println(err)
} else {
fmt.Println(sum)
}

自定义 error

采用工厂返回错误信息的方式只能传递一个字符串,也就是携带的信息只有字符串,如果想要携带更多信息(比如错误码信息)就需要自定义 error 。

1
2
3
4
5
6
7
8
type commonError struct {
errorCode int //错误码
errorMsg string //错误信息
}

func (ce *commonError) Error() string{
return ce.errorMsg
}
1
2
3
return 0, &commonError {
errorCode: 1,
errorMsg: "a 或者 b 不能为负数"}

error 断言

需要先把返回的 error 接口转换为自定义的错误类型

1
2
3
4
5
6
sum, err := add(-1, 2)
if cm,ok := err.(*commonError); ok {
fmt.Println("错误代码为:", cm.errorCode, ",错误信息为:", cm.errorMsg)
} else {
fmt.Println(sum)
}

错误嵌套

调用一个函数,返回了一个错误信息 error,在不想丢失这个 error 的情况下,又想添加一些额外信息返回新的 error。

1
2
3
4
type MyError struct {
err error
msg string
}

让 MyError 这个 struct 实现 error 接口,然后在初始化 MyError 的时候传递存在的 error 和新的错误信息:

1
2
3
4
5
6
7
8
func (e *MyError) Error() string {
return e.err.Error() + e.msg
}

func main() {
//err是一个存在的错误,可以从另外一个函数返回
newErr := MyError{err, "数据上传问题"}
}

Error Wrapping

这种方式可以满足的需求,但是非常烦琐,因为既要定义新的类型还要实现 error 接口。所以从 Go 语言 1.13 版本开始,Go 标准库新增了 Error Wrapping 功能,可以基于一个存在的 error 生成新的 error,并且可以保留原 error 信息:

1
2
3
e := errors.New("原始错误e")
w := fmt.Errorf("Wrap 了一个错误: %w", e)
fmt.Println(w)

Go 语言没有提供 Wrap 函数,而是扩展了 fmt.Errorf 函数,然后加了一个 %w,通过这种方式,便可以生成 wrapping error。

errors.Unwrap 函数

Go 语言提供了 errors.Unwrap 用于获取被嵌套的 error,比如以上例子中的错误变量 w ,就可以对它进行 unwrap,获取被嵌套的原始错误 e。

1
fmt.Println(errors.Unwrap(w))

errors.Is 函数

有了 Error Wrapping 后,会发现原来用的判断两个 error 是不是同一个 error 的方法失效了,比如 Go 语言标准库经常用到的如下代码中的方式:

1
if err == os.ErrExist

于是 Go 语言为提供了 errors.Is 函数,用来判断两个 error 是否是同一个:

1
func Is(err, target error) bool

以上就是 errors.Is 函数的定义,可以解释为:

  • 如果 err 和 target 是同一个,那么返回 true。
  • 如果 err 是一个 wrapping error,target 也包含在这个嵌套 error 链中的话,也返回 true。
1
fmt.Println(errors.Is(w,e))

errors.As 函数

有了 error 嵌套后,error 断言也不能用了,因为不知道一个 error 是否被嵌套,又嵌套了几层。所以 Go 语言为解决这个问题提供了 errors.As 函数,比如前面 error 断言的例子,可以使用 errors.As 函数重写,效果是一样的:

1
2
3
4
5
6
var cm *commonError
if errors.As(err, &cm) {
fmt.Println("错误代码为:", cm.errorCode,",错误信息为:", cm.errorMsg)
} else {
fmt.Println(sum)
}

所以在 Go 语言提供的 Error Wrapping 能力下,写的代码要尽可能地使用 Is、As 这些函数做判断和转换。

defer

  • defer 调用栈
  • return x 不是一个原子语句

Go 语言为提供了 defer 函数,可以保证文件关闭(资源的释放)后一定会被执行,不管自定义的函数出现异常还是错误。

下面的代码是 Go 语言标准包 ioutil 中的 ReadFile 函数,它需要打开一个文件,然后通过 defer 关键字确保在 ReadFile 函数执行结束后,f.Close() 方法被执行,这样文件的资源才一定会释放。

1
2
3
4
5
6
7
8
9
func ReadFile(filename string) ([]byte, error) {
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
//省略无关代码
return readAll(f, n)
}

defer 关键字用于修饰一个函数或者方法,使得该函数或者方法在返回前才会执行,也就说被延迟,但又可以保证一定会执行。

以上面的 ReadFile 函数为例,被 defer 修饰的 f.Close 方法延迟执行,也就是说会先执行 readAll(f, n),然后在整个 ReadFile 函数 return 之前执行 f.Close 方法。

defer 语句常被用于成对的操作,如文件的打开和关闭,加锁和释放锁,连接的建立和断开等。不管多么复杂的操作,都可以保证资源被正确地释放。

panic

Go 语言是一门静态的强类型语言,很多问题都尽可能地在编译时捕获,但是有一些只能在运行时检查,比如数组越界访问、不相同的类型强制转换等,这类运行时的问题会引起 panic 异常。

除了运行时可以产生 panic 外,自己也可以抛出 panic 异常。假设需要连接 MySQL 数据库,可以写一个连接 MySQL 的函数 connectMySQL,如下面的代码所示:

1
2
3
4
5
6
func connectMySQL(ip,username,password string){
if ip =="" {
panic("ip不能为空")
}
//省略其他代码
}

在 connectMySQL 函数中,如果 ip 为空会直接抛出 panic 异常。这种逻辑是正确的,因为数据库无法连接成功的话,整个程序运行起来也没有意义,所以就抛出 panic 终止程序的运行。

panic 是 Go 语言内置的函数,可以接受 interface{} 类型的参数,也就是任何类型的值都可以传递给 panic 函数,如下所示:

1
func panic(v interface{})

小提示:interface{} 是空接口的意思,在 Go 语言中代表任意类型。

panic 异常是一种非常严重的情况,会让程序中断运行,使程序崩溃,所以 如果是不影响程序运行的错误,不要使用 panic,使用普通错误 error 即可。

recover

通常情况下,不对 panic 异常做任何处理,因为既然它是影响程序运行的异常,就让它直接崩溃即可。但是也的确有一些特例,比如在程序崩溃前做一些资源释放的处理,这时候就需要从 panic 异常中恢复,才能完成处理。

在 Go 语言中,可以通过内置的 recover 函数恢复 panic 异常。因为在程序 panic 异常崩溃的时候,只有被 defer 修饰的函数才能被执行,所以 recover 函数要结合 defer 关键字使用才能生效。

下面的示例是通过 defer 关键字 + 匿名函数 + recover 函数 从 panic 异常中恢复的方式。

1
2
3
4
5
6
7
8
func main() {
defer func() {
if p:=recover();p!=nil{
fmt.Println(p)
}
}()
connectMySQL("","root","123456")
}

运行这个代码,可以看到如下的打印输出,这证明 recover 函数成功捕获了 panic 异常。

1
ip 不能为空

通过这个输出的结果也可以发现,recover 函数返回的值就是通过 panic 函数传递的参数值。

unsafe

Go 将其定义为这个包名,也是为了让尽可能地不使用它。不过虽然不安全,它也有优势,那就是可以绕过 Go 的内存安全机制,直接对内存进行读写。所以有时候出于性能需要,还是会冒险使用它来对内存进行操作。

指针类型转换

Go 是强类型的静态语言,强类型意味着一旦定义了,类型就不能改变;静态意味着类型检查在运行前就做了。同时出于安全考虑,Go 语言是不允许两个指针类型进行转换的。

1
2
3
4
5
6
func main() {
i := 10
ip := &i
var fp *float64 = (*float64)(ip)
fmt.Println(fp)
}

在编译的时候,会提示 cannot convert ip (type * int) to type * float64,也就是不能进行强制转型。那如果还是需要转换呢?这就需要使用 unsafe 包里的 Pointer 了。

unsafe.Pointer

unsafe.Pointer 是一种特殊意义的指针,可以表示任意类型的地址,类似 C 语言里的 void * 指针,是全能型的。正常情况下,* int 无法转换为 *float64,但是通过 unsafe.Pointer 做中转就可以了。

1
2
3
4
5
6
7
func main() {
i:= 10
ip:=&i
var fp *float64 = (*float64)(unsafe.Pointer(ip))
*fp = *fp * 3
fmt.Println(i)
}

按 Go 语言官方的注释,ArbitraryType 可以表示任何类型,而 unsafe.Pointer 又是 *ArbitraryType,也就是说 unsafe.Pointer 是任何类型的指针,也就是一个通用型的指针,足以表示任何内存地址。

uintptr 指针类型

uintptr 也是一种指针类型,它足够大,可以表示任何指针。unsafe.Pointer 不能进行运算,比如不支持 +(加号)运算符操作,但是 uintptr 可以对指针偏移进行计算,这样就可以访问特定的内存,达到对特定内存读写的目的,这是真正内存级别的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func main() {
p := new(person)
//Name是person的第一个字段不用偏移,即可通过指针修改
pName := (*string)(unsafe.Pointer(p))
*pName = "gogogo"
//Age 并不是 person 的第一个字段,所以需要进行偏移,这样才能正确定位到 Age 字段这块内存,才可以正确的修改
pAge := (*int)(unsafe.Pointer(uintptr(unsafe.Pointer(p))+unsafe.Offsetof(p.Age)))
*pAge = 20
fmt.Println(*p)
}
type person struct {
Name string
Age int
}

指针转换规则

graph LR
A[*T] <--互转---> B(unsafe.Pointer)
B <--互转--->  C(uintptr)

Go 语言中存在三种类型的指针,它们分别是常用的 *T、unsafe.Pointer 及 uintptr,这三者的转换规则:

  1. 任何类型的 *T 都可以转换为 unsafe.Pointer;
  2. unsafe.Pointer 也可以转换为任何类型的 *T;
  3. unsafe.Pointer 可以转换为 uintptr;
  4. uintptr 也可以转换为 unsafe.Pointer。

unsafe.Pointer 主要用于指针类型的转换,而且是各个指针类型转换的桥梁。uintptr 主要用于指针运算,尤其是通过偏移量定位不同的内存。

others

String 和 [] byte

字符串 string 也是一个不可变的字节序列,所以可以直接转为字节切片 [] byte:

1
2
s:="Hello 世界"
bs:=[]byte(s)

string 不止可以直接转为 [] byte,还可以使用 [] 操作符获取指定索引的字节值:

1
2
3
4
5
6
7
8
9
10
11
12
s:="Hello 世界"
bs:=[]byte(s)
fmt.Println(bs)
fmt.Println(s[0],s[1],s[6])
// 字节长度 12 (6 + 3*2)
fmt.Println(len(s))
// 字符长度 8
fmt.Println(utf8.RuneCountInString(s))
// 逐个遍历字符
for i, r := range s {
fmt.Printf("%d %c\n", i, r)
}

new 和 make

Go 语言程序所管理的虚拟内存空间会被分为两部分:堆内存和栈内存。栈内存主要由 Go 语言来管理,开发者无法干涉太多,堆内存才是开发者发挥能力的舞台,因为程序的数据大部分分配在堆内存上,一个程序的大部分内存占用也是在堆内存上。常说的 Go 语言的内存垃圾回收是针对 堆内存 的垃圾回收。

变量的声明

1
2
var s string // 声明并未初始化,string 的零值:""(空字符串)
var sp *string // 指针类型变量的零值:nil,它没有指向的内存,无法使用

变量的赋值

如果在声明一个变量的时候就给这个变量赋值,这种操作就称为变量的初始化。指针类型的变量 如果没有分配内存,就默认是零值 nil,它没有指向的内存,所以无法使用,强行使用就会得到 nil 指针错误。而对于 值类型的变量 来说,即使只声明一个变量,没有对其初始化,该变量也会有分配好的内存。

struct 结构体,是一个值类型,Go 语言自动分配了内存,所以可以直接使用,不会报 nil 异常。

1
2
3
4
5
var s1 string = "Go"
s2 := "Go"
s3 = "Go" // 假设已经声明变量 s
var sp *string
*sp = "Go" // runtime error: invalid memory address or nil pointer dereference

如果要对一个变量赋值,这个变量必须有对应的分配好的内存,这样才可以对这块内存操作,完成赋值的目的。所以一个变量必须要经过 声明内存分配 才能 赋值,才可以在声明的时候进行 初始化。指针类型在声明的时候,Go 语言并没有自动分配内存,所以不能对其进行赋值操作,这和值类型不一样。

map 和 chan 也一样,因为它们本质上也是指针类型。

new 函数

1
2
3
4
// The new built-in function allocates memory. The first argument is a type,
// not a value, and the value returned is a pointer to a newly
// allocated zero value of that type.
func new(Type) *Type

new 函数的作用是根据传入的类型申请一块内存,然后返回指向这块内存的指针,指针指向的数据就是该类型的 零值

1
2
3
var sp *string
sp = new(string) // *sp = ""
*sp = "Go" // ok

变量的初始化

当声明了一些类型的变量时,这些变量的零值并不能满足要求,这时就需要在变量声明的同时进行赋值(修改变量的值),这个过程称为变量的初始化。

1
2
3
4
5
6
7
8
var s string = "Go"
s1 := "Go"

type person struct {
name string
age int
}
p := person{name: "Go", age: 15}

指针变量初始化

new 函数可以申请内存并返回一个指向该内存的指针,但是这块内存中数据的值默认是该类型的零值,在一些情况下并不满足业务需求。可以自定义一个函数,对指针变量进行初始化:

1
2
3
4
5
6
func NewPerson(name string, age int) *person{
p := new(person)
p.name = name
p.age = age
return p
}

NewPerson 函数就是工厂函数,除了使用 new 函数创建一个 person 指针外,还对它进行了赋值,也就是初始化。

make 函数

在使用 make 函数创建 map 的时候,其实调用的是 makemap 函数:

1
2
3
4
// makemap implements Go map creation for make(map[k]v, hint).
func makemap(t *maptype, hint int, h *hmap) *hmap{
// ...
}

makemap 函数返回的是 *hmap 类型,而 hmap 是一个结构体,它的定义如下面的代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// A header for a Go map.
type hmap struct {
// Note: the format of the hmap is also encoded in cmd/compile/internal/gc/reflect.go.
// Make sure this stays in sync with the compiler's definition.
count int // # live cells == size of map. Must be first (used by len() builtin)
flags uint8
B uint8 // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
hash0 uint32 // hash seed
buckets unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
nevacuate uintptr // progress counter for evacuation (buckets less than this have been evacuated)
extra *mapextra // optional fields
}

要想使用这样的 hmap,不是简单地通过 new 函数返回一个 *hmap 就可以,还需要对其进行初始化,这就是 make 函数要做的事情,如下所示:

1
m := make(map[string]int, 10)

其实 make 函数就是 map 类型的工厂函数,它可以根据传递它的 K-V 键值对类型,创建不同类型的 map,同时可以初始化 map 的大小。

make 函数不只是 map 类型的工厂函数,还是 chan、slice 的工厂函数。它同时可以用于 slice、chan 和 map 这三种类型的初始化。

  • new 函数只用于分配内存,并且把内存清零,也就是返回一个指向对应类型零值的指针。new 函数一般用于需要显式地返回指针的情况,不是太常用。

  • make 函数只用于 slice、chan 和 map 这三种内置类型的创建和初始化,因为这三种类型的结构比较复杂,比如 slice 要提前初始化好内部元素的类型,slice 的长度和容量等,这样才可以更好地使用它们。

闭包(匿名函数)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func main() {
cl:=colsure()
fmt.Println(cl()) // 1
fmt.Println(cl()) // 2
fmt.Println(cl()) // 3
}
// 注意这个返回值是一个闭包
func colsure() func() int {
i := 0
return func() int {
i++
return i
}
}

闭包对其作用域内变量的 捕获和持有。闭包捕获并持有了变量 i 的引用,而不是其值,因此每次调用闭包时,它都操作同一个 i 变量,而

不是每次重新初始化 i。

loopvar

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// go1.22
func main() {
done := make(chan bool)

values := []string{"a", "b", "c"}
for _, v := range values {
go func() {
fmt.Println(v)
done <- true
}()
}

// wait for all goroutines to complete before exiting
for _ = range values {
<-done
}
}
// 1.21 可能输出一样的值,也可能不一样
// 1.22 每个值都会输出,但是顺序有可能随机
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func rightLoop() {
done := make(chan bool)

values := []string{"a", "b", "c"}
for _, v := range values {
go func(str string) {
fmt.Println(str)
done <- true
}(v)
}

// wait for all goroutines to complete before exiting
for range values {
<-done
}
}
// a b c
  • go1.22 版本之后解决了 for 循环变量共享的问题,注意必须 go 版本和 gomod 版本都 >= 1.22 才会使用新的 loopvar
  • 虽然之前版本的变量共享,但在协程里可能会输出不同的值,不该想当然以为值永远是最后一个。