Go依赖管理和工程实践 | 青训营笔记

96 阅读7分钟

这是我参与「第五届青训营」伴学笔记创作活动的第 2 天

Go依赖管理与工程实践 | 青训营笔记

1、并发编程

并发VS并行

由于现在硬件多为多核

Go 可以充分发挥多核优势,高效运行

image.png

协程Goroutine

Go 的 Goroutine 是用户态的,其协程栈占用仅有 KB 级别,十分节约系统资源;但不同的是,Goroutine 将协程和并发简化到了仅需一个 go 关键字即可完成,而不像其他语言的协程一样极其繁琐复杂。

image.png

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

import (
	"fmt"
	"time"
)

func HelloPrint(i int) {
	// println("Hello goroutine : " + fmt.Sprint(i))
	fmt.Println("Hello goroutine :", i)
}


func HelloGoroutine() {
	for i := 0; i < 5; i++ {
		go func(j int) {
			HelloPrint(j)
		}(i)
	}
	// time.Sleep()的作用是:保证了子协程在执行完之前,主协程不退出。
	time.Sleep(time.Second)
}

func main() {
	HelloGoroutine()
}

  • 即使你在你的程序中不使用任何一个 go 关键字,也依然存在一个协程在运行:这个协程就是 main 函数自己。
  • 当我们有多个子协程执行时,应该等待这些协程全部执行完毕后,再结束主协程
  • 只有在一段程序有空闲时间的时候,另一端程序才有机会抢过执行权,执行自己。标准库中WaitGroup提供解决方案

通信共享内存

image.png

通道Channel

image.png

  • 无缓存 Channel 意味着,一个数据的发送必须等待另一端代码的接收,如果没有人接收发送的数据,那么发送端便会被永远阻塞。
  • 有缓存Channel 则在缓冲区满时,数据未被接收而阻塞

向Channel中发送数据:

ch <- v //v是一个变量

从Channel中接收数据:

v := <-ch//赋值给变量v

也可以使用 for range 来取出Channel中所有数据

for i := range ch {
    fmt.Println(i)
}

for range将会始终读取一个 Channel 中发送的数据,直到该 Channel 被关闭。Channel被使用完时应该调用close关闭

close(ch)

并发安全Lock

Mutex互斥锁实现数据同步

var (
    x    int64
    lock sync.Mutex
)
​
func addWithLock() {
    for i := 0; i < 2000; i++ {
        lock.Lock()
        x += 1
        lock.Unlock()
    }
}
​
func main() {
    x = 0
    for i := 0; i < 5; i++ {
        go addWithLock()
    }
    time.Sleep(time.Second)
    println("WithLock:", x)
}

并发锁的原理是,当第一次调用 Lock 方法时,什么都不会发生,但当第二次调用 Lock 方法时,该调用便会立刻阻塞协程,直到有程序调用 Unlock 方法解锁。

WaitGroup

package main

import (
	"fmt"
	"sync"
)

func HelloPrint(i int) {
	fmt.Println("Hello WaitGroup :", i)
}

func ManyGoWait() {
	var wg sync.WaitGroup //声明了一个名字叫wg的WaitGroup
	wg.Add(5)
	for i := 0; i < 5; i++ {
		go func(j int) {
			defer wg.Done()
			HelloPrint(j)
		}(i)
	}
	wg.Wait()
}

func main() {
	ManyGoWait()
}

WaitGroup它内部维护一个计数器:

  • 通过调用 Add 方法,向 WaitGroup 的计数器添加指定值;

  • 通过调用 Wait 方法阻塞当前协程,这会使得协程陷入无限的等待;

  • 通过调用 Done 方法使 WaitGroup 内部的计数器 -1,直到计数器值为 0 时,先前被阻塞的协程便会被释放,继续执行接下来的代码或是直接结束运行。

2、依赖管理

对引入第三方依赖库进行统一管理

GO依赖管理的演进过程

对于 Go 的依赖管理来说,经历了 GOPATH,Go Vender,Go Module 三部分的演进。

GOPATH

最初,Go 直接将依赖库源码扔进 GOPATHsrc 文件夹以作为项目依赖。

GOPATH 是一个环境变量,指向一个目录,作为项目的编译产出目录和依赖目录。这是一个公共环境变量,也就意味着,所有项目都依赖于同一个 GOPATH,这就会导致这样的问题:如果项目 A 依赖于依赖库 Lib 的版本 1,而项目 B 依赖于同一个依赖库的版本 2,由于 GOPATH 并没有任何版本管理措施,就会导致编译出错。

Go Vender

于是,Go 引入了 Go Vendor,通过在项目目录下新建 vendor 文件夹,并存放依赖库文件副本的方式,使得不同项目可以依赖不同的依赖库版本,解决了版本冲突的问题。

值得一提的是,如果无法在 vendor 文件夹中找到项目所需的依赖文件,那么 Go 会尝试回到 GOPATH 查找。

Go Vendor 的引入看似解决了版本问题,但是实际上依然造成了问题:如果项目 A 引入了 项目 B 和项目 C 作为依赖库,而后两者又共同依赖了项目 D 的不同版本,那么由于 B,C,D 作为项目 A 的依赖依然被同时存在同一个 vendor 文件夹中,依旧导致了依赖冲突。

Go Module

终于,Go 采用了和 JavaScript(NodeJS)的 npm 类似的包管理方案 —— 这就是 Go Module。

Go Module 通过项目路径中的 go.mod 文件(类似于 npm 的 pakcage.json 声明所需依赖的名称和版本范围),然后,通过 go.sum 文件记录项目实际使用的依赖和版本(类似于 npm 的 package-lock.json)。

我们没有必要像 Java 的 Maven/Gradle 那样手动编辑配置文件指定依赖,Go 为我们提供了 go getgo mod 两条指令来方便的添加和移除项目中的依赖。

go.mode 文件详解

一个合法的go.mod

module example/project/app
​
go 1.16
​
require (
    example/lib1 v1.0.2
    example/lib2 v1.0.0 // indirect
    example/lib3 v0.1.0-20190725025543-5a5fe074e612
    example/lib4 0.0.0-20180306012644-bacd9c7efldd // indirect
    example/lib5/v3 v3.0.2
    example/lib6 v3.2.0+incompatible
)
  • module example/project/app 标识了依赖管理的基本单元;
  • go 1.16 指定了 Go 原生库(标准库)的版本,此处我们指定版本为 1.16
  • require 内则指定了单元依赖,格式是 [Module Path] [Version/Pseudo-version]

对于一个依赖项,我们首先指定其名称(路径),然后指定所需的版本。版本号应当按照语义化版本(MAJOR.MINOR.PATCH)的格式填入,例如 v1.0.2

或者,我们可以填入一个基于 commit 的伪版本,代表我们需要来自某个 commit 的依赖库版本,它的格式是 vx.0.0-yyyymmddhhmmss-abcdefgh1234,其中 yyyymmddhhmmss 是提交 Commit 的时间,而 abcdefgh1234 则是该 commit 的哈希值。

有的依赖可能会使用 // indirect 注释标识,这意味着该依赖并非由项目直接引入,而是透过其他依赖间接引入(例如 A 项目引入了 B 依赖,B 依赖依赖于依赖 C,那么依赖 C 就是 项目 A 的间接依赖)

有的依赖可能会在版本末尾添加 +incompatible 标识,这是为了兼容非语义化版本所致。

依赖分发

image.png

go get指令

image.png

go mod指令

image.png

3、单元测试

首先,GO是内置单元测试支持的。

所有以_test.go结尾的代码文件会被GO识别为单元测试文件

一个单元测试函数的函数名应当以Test开头,并包含*testing.T形参

通过 func TestMain(m *testing.M) 函数对测试数据进行初始化,并调用 m.Run() 运行单元测试。

以下代码是一个简单的单元测试例子,测试 HelloTom 函数是否正常返回值 Tom

// In xxx.go:
func HelloTom() string {
    return "Jerry"
}
​
// In xxx_test.go:
func TestHelloTom(t *testing.T) {
    output := HelloTom()
    expectOutput := "Tom"
    if output != expectOutput {
        t.Errorf("Expected %s do not match actual %s", expectOutput, output)
    }
}

运行测试 go test [flags] [packages]

还有引入依赖库加快单元测试开发

通过 testify/assert 库进行覆盖率测试

通过 bouk/monkey 库对数据进行 Mock

基准测试

基准测试是指测试一段程序的运行性能及耗费CPU的程度。而我们在实际项目开发中,经常会遇到代码性能瓶颈,为了定位问题经常要对代码做性能分析,这就用到了基准测试。使用方法类似于单元测试

下面代码模拟了一个负载均衡的例子,在 10 个服务器中随机返回数据,我们将对 Select 方法分别进行串行和并行的基准测试:

// In xxx.go:
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.Intn(10)]
}
​
// In xxx_test.go:
package main
​
import "testing"
​
func BenchmarkSelect(b *testing.B) {
    InitServerIndex()
    b.ResetTimer() // 重置计数器是因为 InitServerIndex 不应包含在测试时间内
    for i := 0; i < b.N; i++ {
        Select()
    }
}
func BenchmarkSelectParallel(b *testing.B) {
    InitServerIndex()
    b.ResetTimer()
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            Select()
        }
    })
}

使用 go test -bench= 指令运行基准测试,得到结果

4、项目实战

通过使用高性能 go web 框架 Gin 配合 MVC(Model-View-Controller)模式,开发了一个有一个路由的简易论坛后端。(以后补充)

未完待续。。。