Go基础语法与部分进阶| 青训营

159 阅读23分钟

Go(一)

Go语言基础语法

1. 什么是Go语言

  1. 高性能、高并发
  2. 语法简单、学习曲线平缓
  3. 丰富的标准库
  4. 完善的工具链
  5. 静态链接
  6. 快速编译
  7. 跨平台
  8. 垃圾回收

2.入门

2.1 基础语法——Hello,World

package main

import "fmt"

func main() {
    fmt.Print("Hello, world!")
}

2.2 变量

package main

import (
	"fmt"
    "math"
)

func main() {
    var a = "initial"
    
    var b, c int = 1, 2
    
    var d = true
    
    var e float64
    
    f := float32(e)
    
    g := a + "foo"
    fmt.Println(a, b, c, d, e, f)	//initial 1 2 true 0 0
    fmt.Println(g)					//initialfoo
    
    const s string = "constant"
    const h = 500000000
    const i = 3e20 / h
    fmt.Println(s, h, i, math.Sin(h), math.Sin(i))
}

类型可以省略,Go语言编译器会根据初始值自动判断类型

使用:=时不可加var关键字以及类型

2.3 循环

package main

import "fmt"

func main() {
    i := 1
    for {
        fmt.Println("loop")
        break
    }
    for j := 7; j < 9; j++ {
        fmt.Println(j)
    }
    for n:=0; n < 5; n++ {
        if n % 2 == 0{
            continue
        }
        fmt.Println(n)
    }
    for i <= 3 {
        fmt.Println(i)
        i = i + 1
    }
}

Go语言没有while循环、do while循环,只有一种for循环,for后不加任何参数代表一个死循环,for后加一个条件作用类似于其他语言中的while循环

2.4 switch

switch语句与其他语言的switch语句类似,但Go语言中执行完一个case后会自动跳出switch代码块,无需手动break,如果需要执行后续case,可使用fallthrough

num := 2
switch num {
case 1:
    fmt.Println("one")
case 2, 3:
    fmt.Println("two or three")
default:
    fmt.Println("other")
}

Goswitch 可以匹配任意类型,也可以实现 if-else 的功能,在 switch 后面不加匹配条件,可以在 case 里进行匹配。

t := time.Now()
switch {
case t.Hour() < 12:
    fmt.Println("It's before noon")
default:
    fmt.Println("It's after noon")
}

2.5 数组

2.5.1 一维数组

声明一个长度为3的数组

var a [3]int

声明同时初始化数组

b := [3]int{1, 2, 3}

赋值与其他语言类似

a[1] = 100
2.5.2 多维数组

声明一个二维数组

var c [2][3]int

循环遍历

for i := 0; i < 2; i++ {
    for j := 0; j < 3; j++ {
        c[i][j] = i + j
    }
}

2.6 切片

2.6.1 声明
  • 切片更类似于C++Java语言中的集合
  • 切片声明需要通过make函数进行

声明一个空切片,长度为3,容量为10

s1 := make([]string, 3, 10)

由于切片的长度为 3,s1 创建后包含了 3 个字符串元素的位置,但这些位置还没有赋值,因此切片中的元素都是空字符串 ""

由于切片的容量为 10,这意味着切片底层数组的长度为 10,当需要添加更多元素时,切片可以继续扩展,直到达到底层数组的容量。一旦切片长度超过容量时,底层数组会重新分配更大的内存空间,将原有的元素复制到新的内存地址中。

声明一个切片并初始化

// 声明并初始化一个字符串切片,其中包含三个元素
s1 := []string{"apple", "banana", "orange"}
2.6.2 操作

对于切片元素的操作,类似于数组

s := make([]string, 3)
s[0] = "a"
s[1] = "b"
s[2] = "c"
2.6.3 添加

可以通过append向切片中添加元素,需要一个变量接收返回值

s = append(s, "d")
s = append(s, "e", "f")
2.6.4 复制

可以通过copy对切片进行复制,复制后是两个独立的切片,互不影响

c := make([]string, len(s))
copy(c, s)
fmt.Println(c) // [a b c d e f]
2.6.5 切片

对切片进行切片,截取切片的一部分元素

// 返回下标2到4的值
fmt.Println(s[2:5]) // [c d e]
// 返回下标0到4的值
fmt.Println(s[:5])  // [a b c d e]
// 返回下标2到最后一个元素
fmt.Println(s[2:])  // [c d e f]

2.7 Map

Java中的类似,拥有键值对形式的数据结构

2.7.1 声明

声明一个 keystringvalueint 的空 Map

m := make(map[string]int)

声明并进行初始化

m2 := map[string]int{"one": 1, "two": 2}
2.7.2 访问和操作
m["one"] = 1
m["two"] = 2
fmt.Println(m["one"])    // 1
fmt.Println(m["unknow"]) // 0

Go 在查询 key 时返回了两个值,一个是 value,另一个是 bool 类型的值代表是否存在

r, ok := m["unknow"]
fmt.Println(r, ok) // 0 false

可以使用 delete 函数来删除 Map 中的键值对

Goland 中可以通过 map.delete 快速生成

delete(m, "one")

2.8 range

类似于Java中的增强for循环,JavaScript中的解构赋值

数组、切片、Map 都可以使用 range 来遍历,通过 range 可以得到索引和对应的值

nums := []int{2, 3, 4}
sum := 0
// 遍历切片
for i, num := range nums {
    fmt.Println("index:", i, "num:", num)
}
// 遍历Map
m := map[string]string{"a": "A", "b": "B"}
for k, v := range m {
    fmt.Println(k, v) // b 8; a A
}
// 只获取key
for k := range m {
    fmt.Println("key", k) // key a; key b
}
// 只获取value
for _, v := range m {
    fmt.Println("value", v) // value A; value B
}

2.9 函数

Go语言中,声明函数格式如下

func functionName(arg1 type1, arg2 type2) (returnValue1 type3, returnValue2 type4){
    // write down the function body here...
}

如下为一个例子:

func exists(m map[string]string, k string) (v string, ok bool) {
    v, ok = m[k]
    return v, ok
}

2.10 指针

Go 支持指针,相对于 C语言Go 的指针功能十分简单,当然功能也很有限,主要用途就是对函数传入的参数进行修改。

默认情况下,Go 函数的参数传参是值传递,也就是拷贝了一个副本,对其修改不会影响原来的值,如果要对原来的值也要变的话,需要使用指针。

func add2(n int) {
    n += 2
}
func add2ptr(n *int) {
    *n += 2
}
func main() {
    n := 5
    add2(n)
    fmt.Println(n) // 5
    add2ptr(&n)
    fmt.Println(n) // 7
}

2.11 结构体

C语法、作用都类似

2.11.1 声明

声明一个结构体

type user struct {
    name     string
    password string
}

结构体的初始化

a := user{name: "wang", password: "1024"}
b := user{"wang", "1024"}
c := user{name: "wang"}

c中password字段的值为”“

对于结构体内部成员的访问可以通过. 操作符来进行访问或赋值。

var d user
d.name = "wang"
d.password = "1024"
fmt.Println(d.password)
2.11.2 结构体方法

可以视作Java中类的成员方法

在函数声明时,func关键字后加上对应结构体信息,就代表这个函数是属于结构体的

user 结构体添加一个检查密码的函数:

func (u user) checkPassword(password string) bool {
    return u.password == password
}

user 结构体添加一个重置密码的函数,因为要修改结构体内部的值,所以要使用指针类型:

func (u *user) resetPassword(password string) {
    u.password = password
}

结构体方法的调用:

a := user{name: "wang", password: "1024"}
a.resetPassword("2048")
fmt.Println(a.checkPassword("2048")) // true

2.12 错误处理

Go 内置的错误接口提供了非常简单的错误处理机制,error 类型是一个接口类型,其定义如下:

type error interface {
    Error() string
}

Go 函数可以返回多个值,通常函数的最后一个值用来代表错误信息,在函数内部可以使用 errors.New 可返回一个错误信息:

func findUser(users []user, name string) (v *user, err error) {
    for _, u := range users {
        if u.name == name {
            return &u, nil
        }
    }
    return nil, errors.New("not found")
}

如果函数不出现错误的话,可以直接返回 nil

在调用函数的时候,需要用多个变量来接收函数的返回值,我们可以通过函数的返回值来判断是否发生了异常。

u, err := findUser([]user{{"wang", "1024"}}, "wang")
if err != nil {
    fmt.Println(err)
    return
}

3. 标准库

Go 内置了非常丰富的标准库工具,包括字符串操作、字符串格式化、json 处理、时间处理等

3.1 字符串处理

a := "hello"
// 是否包含
fmt.Println(strings.Contains(a, "ll"))                // true
// 字符统计
fmt.Println(strings.Count(a, "l"))                    // 2
// 判断字符串开头
fmt.Println(strings.HasPrefix(a, "he"))               // true
// 判断字符串结尾
fmt.Println(strings.HasSuffix(a, "llo"))              // true
// 查找字符串
fmt.Println(strings.Index(a, "ll"))                   // 2
// 字符串拼接
fmt.Println(strings.Join([]string{"he", "llo"}, "-")) // he-llo
// 复制字符串指定次数
fmt.Println(strings.Repeat(a, 2))                     // hellohello
// 字符串替换
fmt.Println(strings.Replace(a, "e", "E", -1))         // hEllo
// 字符串分割
fmt.Println(strings.Split("a-b-c", "-"))              // [a b c]
// 转为小写
fmt.Println(strings.ToLower(a))                       // hello
// 转为大写
fmt.Println(strings.ToUpper(a))                       // HELLO
// 字符串长度
fmt.Println(len(a))                                   // 5
3.2 字符串格式化

Println 最为常用作用为打印并换行,Printf 可以按指定格式打印字符串。

+v 可以打印字段和值详细信息。

#v 可以打印出整个结构体的构造以及详细信息。

go复制代码s := "hello"
n := 123
p := point{1, 2}
fmt.Println(s, n) // hello 123
fmt.Println(p)    // {1 2}

fmt.Printf("s=%v\n", s)  // s=hello
fmt.Printf("n=%v\n", n)  // n=123
fmt.Printf("p=%v\n", p)  // p={1 2}
fmt.Printf("p=%+v\n", p) // p={x:1 y:2}
fmt.Printf("p=%#v\n", p) // p=main.point{x:1, y:2}

f := 3.141592653
fmt.Println(f)          // 3.141592653
fmt.Printf("%.2f\n", f) // 3.14

3.2 JSON处理

Go 在处理 json 时十分简单,只需要将结构体中的字段第一个字母变成大写就能用内置的 JSON 工具进行处理。

Go 中一些特殊的类型,比如 Channelcomplexfunction 是不能被解析成 JSON 的。

GoJSON 对象只支持 string 作为 key,对于 map,那么必须是 map[string]T 这种类型,T 可以是 Go 语言中任意的类型。

Gojson 处理是通过 MarshalUnmarshal 方法来进行处理的。

Marshal 用于自定义编码 json 的方法,也就是将变量、对象转成 json,转换之后需要用 string 方法强转一下,否则会打印出的是 16 进制字符串。

Unmarshal 用于自定义解码 json 方法,也就是将 json 转为对象。

type userInfo struct {
    Name  string
    Age   int `json:"age"` // 自定义json输出的字段
    Hobby []string
}

func main() {
    a := userInfo{Name: "wang", Age: 18, Hobby: []string{"Golang", "TypeScript"}}
    buf, err := json.Marshal(a)
    if err != nil {
        panic(err)
    }
    fmt.Println(buf)         // [123 34 78 97...]
    fmt.Println(string(buf)) // {"Name":"wang","age":18,"Hobby":["Golang","TypeScript"]}

    buf, err = json.MarshalIndent(a, "", "\t")
    if err != nil {
        panic(err)
    }
    fmt.Println(string(buf))

    var b userInfo
    err = json.Unmarshal(buf, &b)
    if err != nil {
        panic(err)
    }
    fmt.Printf("%#v\n", b) // main.userInfo{Name:"wang", Age:18, Hobby:[]string{"Golang", "TypeScript"}}
}

3.3 时间处理

Go 提供了很多常用的时间处理函数,例如 Now、解析字符串、转字符串、获取时间戳等。在操作时间相关的方法时,需要导入 time 包。

// 获取当前时间
now := time.Now()
fmt.Println(now) // 2022-03-27 18:04:59.433297 +0800 CST m=+0.000087933
t := time.Date(2022, 3, 27, 1, 25, 36, 0, time.UTC)
t2 := time.Date(2022, 3, 27, 2, 30, 36, 0, time.UTC)
fmt.Println(t)                                                  // 2022-03-27 01:25:36 +0000 UTC
// 获取时间的年月日
fmt.Println(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute()) // 2022 March 27 1 25
// 时间转字符串
fmt.Println(t.Format("2006-01-02 15:04:05"))                    // 2022-03-27 01:25:36
// 获取时间差
diff := t2.Sub(t)
fmt.Println(diff)                           // 1h5m0s
fmt.Println(diff.Minutes(), diff.Seconds()) // 65 3900
// 解析字符串
t3, err := time.Parse("2006-01-02 15:04:05", "2022-03-27 01:25:36")
if err != nil {
        panic(err)
}
fmt.Println(t3 == t)    // true
// 获取时间戳
fmt.Println(now.Unix()) // 1648738080

Go语言进阶

1. 并发编程

1.1 Goroutine

Go语言中存在一个概念为协程,相较于Java中的线程协程更轻、更快

  • 协程:用户态、轻量级线程,栈KB级别
  • 线程:内核态、线程跑多个协程,栈MB级别

在Go语言中,使用关键字go新建协程执行某段代码,可以简单将效果理解为Java语言中创建子线程执行某段代码

func hello(i int) {
    println("hello goroutine : " + fmt.Sprint(i))
}

func HelloGoRoutine() {
    for i := 0; i < 5; i++ {
        go func(j int) {
            hello(j)
        }(i)
    }
    time.Sleep(time.Second)
}

此处使用了time.sleep(time.Second)来手动暂停主 Goroutine 的执行,但这种方式并不推荐,因为使用固定的时间来等待子 Goroutine 完成是不可靠的,可能会导致结果不一致,在下文中将会解决这个问题。

这段代码的输出结果类似如下:

hello goroutine : 4
hello goroutine : 1
hello goroutine : 3
hello goroutine : 2
hello goroutine : 0

1.2 CSP(Communicating Sequential Processes)

CSP 的核心概念是通过通信来共享内存而不是通过共享内存来通信。在 CSP 中,各个并发执行单元(通常称为进程或 Goroutine)通过在彼此之间发送消息来进行通信,而不是共享共享的状态。这样的设计避免了传统的多线程编程中可能出现的共享资源冲突和竞态条件,从而更容易实现并发安全性。

在 Go 语言中,CSP 被广泛应用于并发编程,主要通过以下两个机制来实现:

  1. Goroutine
  2. Channel

1.3 Channel

Channel 是用于 Goroutine 之间进行通信的管道。通过 Channel,一个 Goroutine 可以向另一个 Goroutine 发送数据,并在需要时接收数据。Channel 的使用确保了并发安全,因为任何时候只有一个 Goroutine 可以访问 Channel 中的数据,避免了数据竞争

1.3.1 声明

可通过make函数创建channel,语法如下

make(chan dataType, [bufferSize])

举例如下:

  • 无缓冲通道

    chan1 := make(chan int)
    
  • 有缓冲通道

    chan2 := make(chan int, 2)
    
1.3.2 用法

发送数据到 Channel 使用 <- 运算符,其语法为:

ch <- data

接收 Channel 中的数据使用 <- 运算符,其语法为:

data := <- ch

当一个 Goroutine 尝试向 Channel 发送数据时,如果 Channel 已满,则发送操作将阻塞,直到 Channel 中有空间可以容纳数据。同样地,当一个 Goroutine 尝试从 Channel 接收数据时,如果 Channel 为空,则接收操作将阻塞,直到 Channel 中有数据可供接收。

如下为一个简单入门案例:

func CalSquare() {
    src := make(chan int)
    dest := make(chan int, 3)
    go func() {
        defer close(src)
        for i := 0; i < 10; i++ {
            src <- i
        }
    }()
    go func() {
        defer close(dest)
        for i := range src {
            dest <- i
        }
    }()
    for i := range dest {
        // 复杂操作
        fmt.Println(i)
    }
}

输出结果如下:

0
1
4
9
16
25
36
49
64
81

1.4 Lock

Lock即锁,使用该锁可保证在本地线程情况下的并发安全

以下为一段Lock实例代码

var (
	x    int64
	lock sync.Mutex
)

func addWithLock() {
	for i := 0; i < 2000; i++ {
		lock.Lock()
		x += 1
		lock.Unlock()
	}
}

func addWithoutLock() {
	for i := 0; i < 2000; i++ {
		x += 1
	}
}

func Add() {
	x = 0
	for i := 0; i < 5; i++ {
		go addWithoutLock()
	}
	time.Sleep(time.Second)
	fmt.Println("WithoutLock:", x)
	x = 0
	for i := 0; i < 5; i++ {
		go addWithLock()
	}
	time.Sleep(time.Second)
	fmt.Println("WithLock:", x)
}

代码输出结果类似如下:

WithoutLock: 8382
WithLock: 10000

原因如下:

在单个 Goroutine 中,x += 1 操作是原子性的。但是,在多个 Goroutine 并发执行时,多个 Goroutine 可能同时读取 x 的旧值,然后各自加 1,并将结果写回 x。这会导致多个写入操作互相覆盖,并导致丢失一些增加的值,从而使最终结果不是预期的一万。

具体的并发写入操作可能出现交错的情况,例如:

  1. 一个 Goroutine 读取 x 的值为 0。
  2. 另一个 Goroutine 读取 x 的值为 0。
  3. 第一个 Goroutine 将 x 的值加 1,并写回 1。
  4. 第二个 Goroutine 将 x 的值加 1,并写回 1(覆盖了第一次的结果)。

在使用Lock并发锁的情况下,可以避免如上情况

1.5 WaitGroup

WaitGroup库中,有如下三个函数

  • Add(delta int):计数器+delta
  • Done():计数器-1
  • Wait():阻塞知道计数器为0

计数器:开启协程+1;执行结束-1;主协程阻塞知道计数器为0

1.1 GoRoutine的例子中time.Sleep(time.Second)是一种不可靠的做法,替换为WaitGroup的三个函数

func ManyGoWait() {
    var wg sync.WaitGroup
    wg.add(5)
    for i := 0; i < 5; i++ {
        go func(j int) {
            defer wg.Done()
            hello(j)
        }(i)
    }
    wg.Wait()
}

每个 Goroutine 都会调用 hello(j),并在函数结束时调用 wg.Done() 来通知 WaitGroup 已完成。然后通过 wg.Wait() 等待所有的子 Goroutine 完成。WaitGroup 会等待所有的 Done() 调用执行后才会继续执行主 Goroutine。

2. 依赖管理

在Go语言中,也有许多现成的工具包、开发包,我们可利用已经封装好的、经过验证的开发组件或工具来提升自己的研发效率。

类似于Java中的Maven,Go语言也有一套自己的依赖管理系统

2.1 GOPATH

GOPATH是Go语言支特的一个环境变量,value是Go项目的工作区。目录有以下结构:

  • src:存放Go项目的源码

  • pkg:存放编译的中间产物,加快编译速度

  • bin:存放Go项目编译生成的二进制文件

GOPATH弊端:

  1. 全局共享:GoPath 是一个全局共享的环境变量,所有的项目都使用同一个 GoPath。这可能导致项目之间的依赖冲突问题,不同项目使用相同的依赖版本可能会出现兼容性问题。
  2. 依赖管理不方便:GoPath 对于依赖管理不够灵活,每个项目必须位于 GoPath 的特定目录结构中,而且项目的依赖无法直接指定特定的版本,只能通过目录名来区分不同的版本。
  3. 版本冲突:由于 GoPath 是全局的,如果多个项目依赖同一个第三方库的不同版本,可能会导致版本冲突问题,而且很难在不同项目中使用不同版本的同一个依赖。
  4. 难以迁移:由于依赖管理和项目结构与 GoPath 紧密耦合,当需要迁移项目或更改依赖时,可能会面临一些困难。

2.2 Go Vendor

在 Go 1.5 版本引入了 Vendor 目录的功能,用于改进 Go 语言的依赖管理。Vendor 目录允许开发者在项目中显式地指定所使用的依赖包版本,并将这些依赖包的副本存放在项目的 Vendor 目录下,而不再依赖全局 GOPATH

Vendor 目录的作用和优点:

  1. 显式指定依赖版本:Vendor 目录允许开发者显式地指定项目所使用的依赖包版本,这样可以确保项目在不同环境下使用相同的依赖版本,避免版本冲突和兼容性问题。
  2. 离线构建:Vendor 目录中的依赖包副本使得项目可以在离线环境下进行构建,不再依赖于网络连接和全局 GOPATH 或 Go Modules。这在一些特定场景下很有用,比如内网构建或构建服务器等。
  3. 稳定构建:Vendor 目录中的依赖包版本是固定的,这确保了项目在不同时间和不同环境下构建的结果保持一致,从而提高构建的稳定性。

Vendor 目录的一些弊端:

  1. 大规模依赖管理:对于大规模项目,Vendor 目录可能会导致项目的体积增加,并且在版本升级或依赖包更新时需要手动管理 Vendor 目录,增加了维护成本。
  2. 版本更新:由于 Vendor 目录中的依赖包版本是固定的,当需要更新依赖包版本时,需要手动更新 Vendor 目录,并可能会遇到版本冲突问题。
  3. 依赖复制:Vendor 目录将项目所依赖的包复制到项目中,这可能会造成依赖包的重复复制,浪费磁盘空间。

2.3 Go Module

  • 通过go.mod文件管理依赖包版本
  • 通过go get/go mod指令工作管理依赖工具包

依赖管理三要素

  1. 配置文件,描述依赖 go.mod
  2. 中心仓库管理依赖库 Proxy
  3. 本地工具 go get/mod
2.3.1 依赖配置-go.mod
module example/project/app

go 1.20

require (
	example/lib1 v1.0.2
	example/lib2 v1.0.0 // indirect
	examp1e/1ib3v0.1.0-20190725025543-5a5fe074e612
	example/lib4 v0.0.0-20180306012644-bacd9c7ef1dd /indirect
	exampLe/[1b5/v3 v3.0.2
	example/lib6 v3.2.0+incompatible
)
  1. module 声明:用于指定当前项目的模块路径。例如:

    module example.com/myproject
    
  2. 依赖声明:用于指定项目所依赖的模块和版本信息。例如:

    require (
        github.com/pkg1 v1.2.3
        github.com/pkg2 v1.0.0
    )
    
  3. 替代声明:用于指定替代的依赖路径和版本,主要用于代理服务器或本地开发等场景。例如:

    replace (
        example.com/pkg1 => /path/to/local/pkg1
        github.com/pkg2 => github.com/user/pkg2 v0.1.0
    )
    
  4. 排除声明:用于排除特定版本的依赖,以避免与其他依赖版本冲突。例如:

    exclude example.com/pkg1 v1.2.3
    

该文件类似于pom.xml

2.3.2 version

在 Go Modules 中,依赖配置的版本可以使用语义化版本(Semantic Versioning,也称为 SemVer)或基于 commit 的伪版本(Pseudo Versions)。

  1. 语义化版本(SemVer): 语义化版本是一种常用的版本号规范,它由三个部分组成:主版本号(Major)、次版本号(Minor)和修订版本号(Patch)。按照规范,版本号的递增规则如下:

    • 主版本号(Major):当 API 发生不兼容的改变时,递增主版本号。
    • 次版本号(Minor):当添加新特性,但向后兼容时,递增次版本号。
    • 修订版本号(Patch):当进行向后兼容的错误修复时,递增修订版本号。

    例如:v1.2.3,其中 1 是主版本号、2 是次版本号、3 是修订版本号。

    在 Go Modules 中,依赖配置的版本可以直接使用语义化版本,例如:

    require (
        github.com/pkg1 v1.2.3
        github.com/pkg2 v2.1.0
    )
    
  2. 基于 commit 的伪版本(Pseudo Versions): 当使用 GitHub 等版本控制系统作为 Go Modules 的代理时,如果没有明确的语义化版本标签,Go Modules 会使用基于 commit 的伪版本来标记依赖。

    基于 commit 的伪版本由 v0.0.0 开始,后面跟着日期时间、commit hash 和 build 信息。它表示了代码在某个特定 commit 上的状态。

    例如:v0.0.0-20220723181229-abcd12345678,其中 20220723181229 表示日期时间,abcd12345678 表示 commit hash。

    基于 commit 的伪版本在项目代码中没有对应的 tag 或 release 版本时很常见,它为 Go Modules 提供了一种灵活的版本标记方式。

2.3.3 indirect

间接依赖(Indirect Dependency): 间接依赖是指项目直接依赖的库或模块引入的其他依赖项。这些依赖项不会被直接列在 go.mod 文件中,而是由直接依赖项引入的。

go.mod 文件中,Go Modules 会在需要时用 indirect 标记来标识间接依赖项。indirect 标记表示该依赖是由项目的直接依赖项引入的,而不是项目直接使用的。这些间接依赖项通常是由直接依赖项的依赖项引入的,但对于项目自身并不是直接使用的。

2.3.4 incompatible

"incompatible" 表示某个依赖包的版本与当前项目的其他依赖包版本不兼容。当执行 go getgo mod tidy 等命令时,Go Modules 会检查项目的依赖关系,如果发现存在不兼容的依赖包版本,则会在生成的 go.mod 文件中将该依赖包标记为 "incompatible"。

标记为 "incompatible" 的依赖包版本会被认为是不安全的,因为它与项目的其他依赖包可能存在冲突,可能导致编译错误或运行时错误。Go Modules 会将 "incompatible" 标记添加到 go.mod 文件中,以警示开发者可能存在的版本冲突,并提示开发者采取适当的措施来解决这些问题。

在遇到 "incompatible" 标记时,可以考虑以下几种解决方案:

  1. 更新依赖包:尝试更新依赖包的版本,选择与其他依赖包兼容的版本。可以通过修改 go.mod 文件手动指定依赖包版本,然后执行 go get 命令来更新依赖。
  2. 解决冲突:如果不同依赖包之间存在版本冲突,可以尝试调整依赖包版本,或者联系依赖包的维护者解决冲突。
  3. 使用替代包:在 go.mod 文件中使用 replace 语句,指定替代的依赖包,以解决不兼容的问题。

当某项目同时依赖A、B两个包,且A、B都依赖C,但版本不同时,项目会选择最低的兼容版本

2.3.5 依赖管理-回源

Go Module的依赖分发,即从哪里下载,如何下载

GitHub是比较常见的代码托管系统平台,而Go Module系统中定义的依赖,最终可以i对应到多版本代码管理系统中某一项目的特定提交或版本,如此一来,对于go.mod中定义的依赖,则直接可以从对应仓库中下载指定软件以来,从而完成以来分发。

但直接使用版本管理仓库下载依赖,存在多个问题

  • 无法保证构建完整性:软件作者可以直接在代码平台增加/修改/删除软件版本,导致下次构建使用另外版本的依赖,或者找不到依赖版本
  • 无法保证依赖可用性:以来软件作者可以直接在代码平台删除软件,导致以来不可用,大幅增加第三方代码托管平台压力

解决方案:Go Proxy

Go Proxy是一个服务站点,他会缓存源站中的软件内容,缓存的软件版本不会改变,并且在源站软件删除之后依然可用,从而实现了共"immutability"和"available"的依赖分发。

2.3.6 变量GOPROXY

GOPROXY是一个Go Proxy站点URL列表,可以使用"direct"表示源站。

2.3.7 go get

go get 是 Go 语言中一个用于获取并安装依赖包的命令。它是 Go 语言官方提供的一个工具,用于下载和安装指定的包或模块,并将其保存到 GOPATH 或 Go Modules 的缓存目录中,以供项目使用。

使用 go get 命令的基本语法是:

go get [package]

其中,[package] 是需要下载和安装的包或模块的导入路径。例如:

go get github.com/example/mypackage

执行上述命令会从 GitHub 上下载 github.com/example/mypackage 这个包,并将其安装到 GOPATH 或 Go Modules 的缓存目录中。如果项目是使用 Go Modules 进行依赖管理的,go get 命令还会更新项目的 go.mod 文件,添加新下载的依赖项。

go get 命令还可以配合 -u 参数来更新已存在的依赖包到最新版本。例如:

go get -u github.com/example/mypackage

上述命令会更新 github.com/example/mypackage 这个包到最新版本,并将其安装到 GOPATH 或 Go Modules 的缓存目录中。

2.3.8 go mod

go mod 是 Go 语言中用于模块(module)管理的命令。Go Modules 是 Go 1.11 版本引入的一种依赖管理工具,它用于管理 Go 项目的依赖关系,并替代了之前的 GOPATH 依赖管理方式。Go Modules 提供了更简单、更灵活的依赖管理方式,支持版本管理、依赖缓存等功能,能够有效地管理项目的依赖关系。

使用 go mod 命令可以进行以下操作:

  1. 初始化模块:通过 go mod init 命令可以初始化一个新的 Go 模块。例如:

    go mod init example.com/myproject
    

    该命令会在当前目录下初始化一个名为 example.com/myproject 的新模块,并生成一个 go.mod 文件,用于记录项目的依赖关系。

  2. 添加依赖:在 Go Modules 中,可以通过 go get 命令添加新的依赖。例如:

    go get github.com/example/mypackage
    

    该命令会下载并安装 github.com/example/mypackage 包,并将其添加到 go.mod 文件中记录的依赖列表中。

  3. 更新依赖:使用 go get -u 命令可以更新已存在的依赖包到最新版本。例如:

    go get -u github.com/example/mypackage
    

    该命令会更新 github.com/example/mypackage 包到最新版本,并更新 go.mod 文件中的记录。

  4. 移除依赖:通过 go mod tidy 命令可以移除项目中未使用的依赖项,从而保持 go.mod 文件的干净和整洁。

  5. 替代依赖:使用 go mod edit 命令可以添加或修改 go.mod 文件中的替代依赖。

  6. 查看依赖:使用 go list 命令可以查看项目的依赖树或列出项目的依赖包。

3. 测试

3.1 单元测试

3.1.1 规则
  • 所有测试文件以_test.go结尾
  • func TestXxx(t *testing.T)
  • 初始化逻辑放到TestMain中
3.1.2 例子
func HelloTom() string {
    return "Jerry"
}
func TestHelloTom(t *testing.T) {
    output := HelloTom()
    expectOutput := "Tom"
    if output != expectOutput {
        t.Errorf("Expected %s do not match actual %s", expectOutput, output)
    }
}
3.1.3 运行
go test [flags] [packages]
3.1.4 assert

一个GitHub开源的工具包,代码示例如下:

import (
	"github.com/stretchr/testify/assert"
    "testing"
)

func TestHelloTom(t *testing.T) {
    output := HelloTom()
    expectOutput := "Tom"
    assert.Equal(t, expectOutput, output)
}
3.1.5 覆盖率

代码覆盖率是一种用来衡量测试的质量和覆盖程度的度量指标。它表示在执行测试时,有多少代码被测试覆盖到了。代码覆盖率通常以百分比表示,衡量了测试用例中执行到的代码行数与总代码行数的比例。

代码覆盖率可以帮助开发者评估他们的测试用例是否足够全面,是否覆盖了代码的各种路径和逻辑。高代码覆盖率意味着测试用例执行了大部分代码,而低代码覆盖率可能意味着有一些代码路径未被测试到,存在未发现的潜在错误。

常见的代码覆盖率指标有:

  1. 行覆盖率(Line Coverage):衡量被测试覆盖到的代码行数与总代码行数的比例。
  2. 分支覆盖率(Branch Coverage):衡量被测试覆盖到的分支(if/else、switch 等)与总分支数的比例。
  3. 函数覆盖率(Function Coverage):衡量被测试覆盖到的函数与总函数数的比例。
  4. 语句覆盖率(Statement Coverage):衡量被测试覆盖到的语句与总语句数的比例。

一般覆盖率:50%-60%,较高覆盖率80%+

测试分支相互独立、全面覆盖

测试单元粒度足够小,函数单一职责

3.1.6 单元测试-依赖

在单元测试中,测试对象可能依赖于其他组件、外部服务、数据库等。为了保持测试的隔离性和可控性,需要处理这些依赖项。在处理依赖项时,常见的方法包括使用 MockStub

  1. Mock(模拟): 使用模拟对象来替代真实的依赖项,以模拟外部组件的行为。模拟对象是特定类型的替代对象,它可以被设置为返回预定义的值或执行预定义的行为。这样,在测试中,我们可以控制依赖项的行为,使其返回特定的结果,从而测试代码在不同情况下的反应。

    在 Go 中,可以使用 gomocktestify 等库来创建模拟对象。

  2. Stub(桩): 使用桩对象来提供依赖项的简单实现。桩对象是特定类型的替代对象,它提供了一组固定的数据或行为。桩对象可以用于替代复杂的依赖项,以使测试更加简单和可控。

    例如,如果代码依赖于一个数据库连接,可以使用桩对象来代替数据库连接,而不是实际连接到真实数据库。

Mock可参考Github项目monkey:Github|monkey

3.2 基准测试

  • 优化代码,需要对当前代码分析
  • 内置的测试框架提供了基准测试的能力
3.2.1 规则

func BenchmarkXxx(b *testing.B)

3.2.2 例子
import (
	"math/rand"
)

var ServerIndex [10]int

func InitServerIndex() {
    for i := 0; i < 10; i++ {
        ServerIndex[i] = i + 100
    }
}

func Select() int {
    return ServerIndex[rand.Init(10)]
}
func BenchmarkSelect(b *testing.B) {
	InitServerIndex()
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		Select()
	}
}
3.2.3 优化

可根据测试数值进行优化