Go语言进阶、依赖管理与测试 | 青训营笔记

110 阅读4分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 1 天,今天学习了Go语言进阶Goroutine和Channel、依赖管理、测试等内容。

1. 语言进阶

1.1 Goroutine

协程与线程的区别

  • 协程:用户态,轻量级线程,没有上下文切换的耗时,栈KB级别

  • 线程:内核态,一个线程上运行多个协程,栈MB级别

因为协程需要的资源极小,一个应用中可以开启上万个协程,这就是Go语言应用适合高并发场景的原因所在。

开启一个协程

package main

import (
	"fmt"
	"time"
)

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

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

在Go中开启协程非常简单,只需要在调用函数前加上关键字go即可。最后一行time.Sleep的目的是保证子协程执行完之前,主协程不会退出。

image.png

1.2 CSP(Communicating Sequential Processes)

Go语言哲学:通过通信共享内存而不是通过共享内存通信 使用共享内存通信需要对临界区加锁,可能造成数据竞态,在一定程度上影响程序的性能。

1.3 Channel

Go语言中提供了Channel这一特性用于协程之间的数据传输。

如何定义一个channel

  • 无缓冲通道 make(chan int)
  • 有缓冲通道 make(chan int, 2)

区别:

  • 无缓冲通道会阻塞发送goroutine,直到数据被接收,也被称为同步通信
  • 有缓冲通道当缓冲区满后才会阻塞发送goroutine

代码示例

package main

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 * i
      }
   }()
   for i := range dest {
      // 。。。复杂操作
      println(i)
   }
}

第一个协程和第二个协程通过src通信,第二个协程和主协程通过dest通信,带缓冲的channel可以一定程度上缓解生产者和消费者速度上的差异。

1.4 并发安全 Lock

多个协程并发工作时,可能会有多个协程同时操作一个资源的情况,也就是数据竞态,访问资源时需要对资源加锁。

代码示例

启动5个协程,每个协程对变量进行2000次+1操作

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)
   println("withoutLock: ", x)
   x = 0
   for i := 0; i < 5; i++ {
      go addWithLock()
   }
   time.Sleep(time.Second)
   println("withLock: ", x)
}

image.png

可以看到没有加锁的方式出现了多个协程同时执行的现象,导致丢失了很多次操作。

1.5 WaitGroup

前面的例子中都使用了time.Sleep()来阻塞主协程,但这是不优雅的,因为我们不知道子协程确切的执行时间,无法精确的设置sleep时间。 Go中可以使用WaitGroup来进行协程同步,提供了三个方法:

  • Add(delta int): 计数器+delta
  • Done(): 计数器-1
  • Wait(): 阻塞直到计数器为0 代码示例
func hello(i int) {
   fmt.Printf("hello goroutine: %d\n", i)
}

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

2. 依赖管理

2.1 背景

  • 工程项目开发中不可能基于标准库从0到1搭建,应该把更多的精力放到业务逻辑上。
  • 外部的依赖都可以通过SDK的方式引入。

2.2 依赖管理演进

GOPATH -> Go Vendor -> Go Module

目标:

  • 实现不同环境(项目)使用的依赖版本不同
  • 控制使用的依赖包版本

2.2.1 GOPATH

弊端

场景:项目A和B同时依赖于某一package的不同版本,这种情况下可能导致A和B无法同时编译成功 问题:无法实现package的多版本控制

2.2.2 Go Vendor

  • 在项目目录下增加vendor文件,所有依赖包以副本的形式存放在$ProjectRoot/vendor
  • 依赖的寻址方式:vendor => GOPATH 通过每个项目引入一份依赖副本的方式,解决了多个项目依赖同一个package的不同版本的场景。

弊端

场景:项目A依赖package B和C,B和C依赖package D的不同版本,容易导致依赖冲突 问题:不能控制依赖的依赖的版本

2.2.3 Go Module

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

2.3 依赖管理三要素

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

2.3.1 依赖配置 & Proxy

1. go.mod

image.png 2. version

  1. 语义化版本 ${MAJOR}.${MINOR}.${PATCH} V1.3.0
  2. 基于commit伪版本 vX.0.0-yyyymmddhhmmss-abcdefg1234

3. indirect

对于非直接依赖会用// indirect标识

4. incompatible

对于没有go.mod文件并且主版本在V2+的包会标识出来,标识可能会有不兼容的问题

5. 依赖图

依赖的依赖使用不同版本时,选择最低的兼容版本

6. 回源&Proxy

直接使用代码托管平台依赖的问题:

  • 无法保证构建稳定性
  • 无法保证依赖可用性
  • 增加第三方压力,代码托管平台负载能力

为了解决直接使用代码托管平台的问题,在代码托管平台与开发者之间增加了Proxy进行依赖代理。

2.3.2 工具

1. go get

使用时go get example.org/pkg后面可以增加参数

  • @update 使用最新的提交
  • @none 删除依赖
  • @v1.1.2 拉取特定版本
  • @23dfdd5 拉取特定commit
  • @master 拉取分支的最新commit

2. go mod

  • go mod init 初始化,创建go.mod文件
  • go mod download 下载模块到本地缓存
  • go mod tidy 增加需要的依赖,删除不需要的依赖,建议每次提交代码前执行

3. 测试

image.png

3.1 单元测试

1. 目的

  • 保证开发质量
  • 提高开发效率(快速定位问题)

2. 规则

  • 测试文件以_test.go结尾
  • 函数命名:func TestXxx(*testing.T)
  • 比较好的实践:初始化逻辑放到TestMain中
    func TestMain(m *testing.M) {
        // 测试前数据装载、配置初始化等
        code := m.Run()
        // 测试后资源释放等收尾工作
        os.Exit(code)
    }
    

使用assert包来比较预期输出与实际输出

3. 覆盖率

go test a_test.go a.go --cover

评估代码是否经过了足够的测试、测试的水准、是否达到高水准测试等级的指标

4. tips

  • 一般覆盖率50%-60%
  • 测试分支相互独立、全面覆盖
  • 测试单元力度足够小,函数单一职责

3.2 Mock

1. 目的

对于一些外部依赖比如File、DB、Cache,希望在测试过程中得到稳定幂等的结果,测试结果只与代码本身有关,排除其他因素干扰。

2. 如何Mock

使用一些开源库例如monkey、goMock进行Mock,测试过程中对依赖的外部资源进行打桩。

3.3 基准测试

go test -bench=.

1. 目的

测试程序的性能、CPU损耗

2. 规则

  • 函数以Benchmark开头BenchmarkXxx(b *testing.B)

4. 学习小结

  1. 因为协程所需的资源很少,应用中可以轻松开启大量协程,且协程处于用户态,没有切换上下文的开支,这些特性使Go成为了适合高并发场景的语言。
  2. channel用于协程间传输数据,实现了通过通信共享内存。
  3. 站在巨人的肩膀上,Go的依赖管理在发展中遇到并解决了许多问题,最终形成了Go Module的解决方案,是否还有改进空间?

5. 参考资料

  1. Go语言进阶与依赖管理
  2. Go语言工程实践之测试