Go语言上手-工程实践 | 青训营笔记

144 阅读8分钟

简介

这是我参与「第三届青训营 -后端场」笔记创作活动的的第1篇笔记。课程是分为四部分,语言进阶、依赖管理、测试、项目实战。


1. 语言进阶

这部分从并发编程的视角来了解go。

1.1 并发和并行

并发就是多线程在一个核CPU上面运行, 涉及到时间片的切换,里面也涉及到了上下文切换等。而并行就是多线程在多个核CPU上面运行。这么说来,并发其实不是真正的同时,而是一段时间内可以运行多个程序,这个动作依靠线程的时间片的切换来完成。go就是为并发而生的



1.2 Goroutine

协程:用户态,轻量级线程,栈MB级别
线程:内核态,线程跑多个协程,栈KB级别。Go语言高并发强的一点就在于可以开启多个协程,耗费的资源少。

image.png 下面先来看一个例子,开启协程

package main

import (
	"fmt"
	"time"
)

func hello(i int) {
	fmt.Printf("i: %v\n", i)
}

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

func main() {
	HelloGoRoutine()
}

image.png

从这里也可以看出协程调用的特点就是无序的。后面通过Sleep阻塞,因为主线程也是通过一个协程启动的,后面这里会用其他方法来关闭。



1.3 CSP

Go语言提倡通过通信共享内存而不是通过共享内存而实现通信

一定程度上对共享内存加锁的方式会影响性能



1.4 Channel

make(chan 元素类型, [缓冲大小])

  • 无缓冲通道 make(chan int), 类似于同步模型
  • 有缓冲通道 make(chane int, 2), 类似于生产消费模型, 就是如果缓冲满了就会阻塞这样

发送数据的格式是: chan <- number

下面是代码示例:

func CalSquare() {
        //首先创建一个无缓冲通道
	src := make(chan int)
        //创建一个有缓冲通道
	dest := make(chan int, 3)
	go func() {
		defer close(src)
                //生产数字到src里面
		for i := 0; i < 10; i++ {
			src <- i
		}
	}()
	go func() {
		defer close(dest)
                //获取channel的数字进行平方操作
		for i := range src {
			dest <- i * i
		}
	}()
        //主协程打印出最后的平方数
	for i := range dest {
		println(i)
	}
}

image.png

可以看到就是这里的输出是有顺序的, 数据进入channel之后需要取出来才能接着拿, 类似队列。同时可以看到的就是说因为defer使用了一个有缓冲的, 所以尽管产生数字的速度很快, 但是消耗被阻塞了, 这样就能解决生产者消费者速度不均衡的问题。



1.5 Lock

既然Go也保存了通过共享内存实现通信的操作,那么就会出现数据共享的问题。图中是使用了加锁核不加锁的方式分别操作, 其实也可以猜到, 如果不加锁的话, 那么输出结果就会有线程安全的问题。原因还是因为共享内存的问题了,在java中出现这种问题一般就是共享内存的更新不一致,比如对于一个变量A线程核B线程同时进行操作了,在把缓冲区内存刷新回主存的时候就会发生数据覆盖的问题。

所以一般在项目开发的时候都会避免对共享内存有并发的操作, 当然了加了lock锁之后性能还是会下降的。用法和Java的也一样了,lock.Lock(), lock.Unlock()



1.6 WaitGroup

可以使用WaitGroup来进行通信,当启动一个协程的时候调用Add方法表示任务++, 做完的时候调用Done表示计数器--, Wait直到计数器为0就继续运行。有点类似于Java里面的CountDownLatch

下面就使用这个来进行线程通信

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

//hello world : 0
//hello world : 2
//hello world : 3
//hello world : 4
//hello world : 1



2. 依赖管理

实际中开发的项目都是比较复杂的, 不可能说基于标准库0~1开始搭建, 一旦一个项目的包数目开始多了, 那么我们就得去解决怎么去管理包的问题。以业务为第一需求才是最重要的


2.1 Go依赖管理演进

路线:GOPATH ==> Go Vender ==> Go Module

  • 不同环境(项目)依赖的版本不同
  • 控制依赖库的版本

到目前为止用的最多的还是Go Module


2.2 GOPATH

2.2.1 介绍

环境变量可以配置GOPATH,GOPATH是Go的工作区, 下面有三个目录: bin, okg, src

  • bin 项目编译的二进制文件
  • pkg 项目编译的中间产物, 加速编译
  • src 项目源码, 目前来说我们的代码都是在这个包下面的

项目代码是直接依赖src下面的代码的,可以通过go get指令下载最新版本的包到src目录下

2.2.2 弊端

其实这样做有一个弊端就是无法实现多版本控制。假设两个项目都依赖于一个package的不同版本,但是某一天项目B需要这个包的新版本,但是A还是依赖老版本,那么这样就有问题了,同一个包下面的同一个方法或者变量是不重复的,除非再写一个方法。


2.3 Go Vendor

2.2.1 介绍

  • 项目下面添加vender文件, 所有依赖包副本形式放在 ¥ProjectRoot/vendor
  • 依赖寻址方式:vendor => GOPATH

通过这个方法就可以解决版本依赖的问题,A项目有A的vendor,B项目有B的vendor,各自的vendor下面有不同的package,就能解决问题了。但是govendor不区分包版本,意味着开发期间拉的依赖的包很可能跟上线后的拉的依赖包版本不一致,很危险,所以对于版本的概念还是没有解决

2.2.2 弊端

如果项目A依赖了packageB和C,而B依赖了D-V1,C依赖了D-V2,一旦更新就有可能出现依赖冲突,不能很好控制V1和V2的版本的选择问题。

  • 无法控制依赖的版本
  • 更新项目又可能出现依赖冲突, 导致编译出错

2.4 Go Module

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

好处就是可以定义版本规则和项目依赖关系


2.5 依赖管理三要素

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

2.5.1 依赖管理-go.mod

image.png

  • 第一个module就是你的模块,你可以在创建一个文件的时候使用go mod init 模块名进行初始化。
  • 原始库就是go的版本
  • 单元依赖就是你的包 + 版本号,可以定位到具体的某一个版本的提交

还有一个go.sum的概念,考虑到下载的依赖包有可能是被篡改过的,比如原来的第一个版本1.0,后来发布的作者把原来1.0的版本删除了又随便弄了一个版本上去还定义为1.0,那么虽然都是同一个版本但是结果却不同。有可能里面的数据就是恶意串改过的数据,那么就需要go.sum通过一个hash的概念来标识版本的唯一性

对于go module,还有几个要点:

  • 对于go module,下载的包存在了$GOPATH/pkg/mod下面,里面存放了不同版本的相同包,版本在go.mod里面指定。
  • 如果包的版本错误了,可以在go.mod中使用replace来替换
  • go mod tidy会下载最新的包,如果下载的包不兼容,可以使用go get下载指定类型的包
  • 控制台搜索 go env, 要设置set GO111MODULE=on
  • 要设置代理, set GOPROXY=https://goproxy.cn

2.5.2 依赖管理-version

语义化版本:${MAJOR}.${MINOR}.${PATCH}
v1.3.0
v2.3.0

基于commit版本: vX0.0-yyyymmddhhmmss-abcdfg1234 (版本前缀-提交时间戳-12位hash码前缀)
v0.0.0-20220401081311-c38fb5326b4
v1.0.0-20220401081312-c38fb5626b4

2.5.3 依赖管理-indirect

A->B->C

  • A->B 直接依赖
  • A->C 间接依赖 (indirect)

image.png


2.5.4 依赖管理-incompatible

  • 主版本 2+ 模块会在模块路径增加/vN后缀。
  • 对于没有go.mod文件并且主版本2+的依赖,会+incompatible(go mod是在1.11才引入的)

2.5.5 依赖分发- Proxy

类似于适配器,通过Proxy来分发,有点是稳定和可靠
GOPROXY="heeps://proxy1.cn,https://proxy2.cn,direct" 服务站点URL列表, direct表示源站

image.png


2.5.6 go get和go mod工具

go get example.org/pkg

  • @update 默认
  • @none 删除依赖
  • @v1.1.2 tag版本, 语义版本
  • @23dfdd5 特点的commit
  • @master 分支的最新commit

go mod

  • init 初始化,创建go.mod文件
  • download 下载模块到本地缓存
  • tidy 增加需要的依赖,删除不需要的依赖(项目提交之前都可以用一次)



3. 测试

对于测试,可以减少程序在上线之后导致的"事故"。从上到下分为三种类型:回归测试、集成测试和单元测试,覆盖率逐层增大,成本逐层减低。

3.1 规则

  • 所有测试文件以_test.go结尾
  • 测试函数以Test开始,驼峰命名,函数参数是*testing.T
  • 初始化逻辑放到TestMain中,入参m *testing.M

3.2 示例

下面是通过assert进行断言测试:

func TestAdd(t *testing.T){
	//第二个参数:期望值
	//第三个参数:实际
	assert.Equal(t, 4, Add(2,2))
}

func Add(a int, b int) int{
	return a+b
}

失败示例:

Running tool: D:\go\golang\bin\go.exe test -timeout 30s -run ^TestAdd$ github.com/Moonlight-Zhao/go-project-example/attention

=== RUN   TestAdd
string_test.go:19 5 does not equal 4
--- FAIL: TestAdd (0.00s)
FAIL
FAIL    github.com/Moonlight-Zhao/go-project-example/attention  0.385s

成功示例:

Running tool: D:\go\golang\bin\go.exe test -timeout 30s -run ^TestAdd$ github.com/Moonlight-Zhao/go-project-example/attention

=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
PASS
ok      github.com/Moonlight-Zhao/go-project-example/attention  (cached)

3.3 覆盖率

覆盖率就是代码通过的程度, 比如下面的代码中可以看到的是假如传入5,4,那么就会走if流程,覆盖就是66.7%

func JudgePass(a int, b int) bool{
	if a >= b{
		return true
	}
	return false
}
  • 一般覆盖率: 50% ~ 60%, 较高覆盖率80%+
  • 测试分支相互独立、全面覆盖
  • 测试单元力度足够小,函数单一职责

3.4 单元测试-Mock

mock测试就是在测试过程中,对于某些不容易构造或者不容易获取的对象,用一个虚拟的对象来创建以便测试的测试方法。比如一个方法A依赖于方法B访问本地文件返回数据,但是当你的服务上线之后文件就不存在了,这时候方法B就报错,那么这种情况下我们可以使用mock来进行一个替换,我们可以自己定义一个接口返回数据给A,假设这个接口叫C,那么我们把B方法替换成C,这样方法A就可以继续运行了。
https://github.com/bouk/monkey可以用来做mock测试,Patch方法用于替换函数, Unpatch方法用于关闭桩

func TestGetNum(t *testing.T) {
	monkey.Patch(file, func() int{
		return 1
	})
	defer monkey.Unpatch(file())
	num := getNum()
	assert.Equal(t, 1, num)
}

3.5 基准测试

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

假设有一个场景我们要随机选择一个值,目前的实现方法就是使用rand函数:Benchmark开头

//随机选择一个值
func Select() int{
	return ServerIndex[rand.Intn(10)]
}

我们可以使用基准测试来测试出性能:

var ServerIndex [10]int

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

//随机选择一个值
func BenchmarkSelect(b *testing.B){
	InitServerIndex()
	b.ResetTimer()	//重置定时器
	for i:=0; i < b.N; i++{
		Select()
	}
}

func BenchmarkSelectParallel(b *testing.B) {
	InitServerIndex()
	b.ResetTimer()	//重置定时器
	b.RunParallel(func(p *testing.PB) {
		for p.Next(){
			Select()
		}
	})
}

image.png

两次测试的结果并没有太大差别,那么我们可以换成fastrand,这是一个开源的函数

//随机选择一个值
func Select() int {
	return ServerIndex[fastrand.Intn(10)]
}

最终结果可以得知fastrand在高并发下是效率是很高的。



总结

通过这次的讲解,对于并发安全的一些知识和依赖管理以及测试都有一些比较详细的了解。在不同场景下要用什么工具,以及代码的测试优化这些在项目的完成过程中都有很大的帮助。最后过了一遍项目的流程,也大概了解了用go是怎么样完整地实现一个接口的。