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

322 阅读8分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第1篇笔记

本文章分为三个主题

一、Go语言并发引入

二、Go语言依赖管理

三、Go语言单元测试

(一)Go语言并发引入

了解Go语言并发模型

线程与协程

线程

  • 分为内核态线程和用户态线程,一个内核线程绑定一个用户线程,一个线程可以跑多个协程
  • 线程的创建、切换、停止都属于很重的内核级操作,影响性能

协程(Corountine

  • 一个协程相当于一个在线程上运行的用户态轻量级线程
  • 协程的创建、切换、停止的系统开销都很小

Go中的协程

协程(Goroutine)

  • go不直接支持创建系统线程,协程是Go程序内部唯一的并发实现方式。
  • 起协程的语句:go func(){......}()
  • 注意主协程(main)结束后,此程序也就退出了,即使还有一些其他协程在运行。
  • 从古至今,多线程的优化有好多种方式,比较常见的一种是reactor模型:线程池中存储大量线程,需要多创建一个线程来执行任务时就从线程池中选取一个线程来用。
  • 而Go语言采用的是另一个思路:有几个核就跑几个线程,只是某个线程上面有很多协程;协程的切换是不会像线程切换那样有操作系统层面上的开销的,例如线程切换需要切换虚拟地址空间、切换内核栈、切换硬件上下文、CPUcache需要失效,而切换协程完全没有这些开销。
  • 协程底层的实现原理:基于GMP模型:
  • G:goroutines 表示一个协程
  • M:machine 表示一个线程
  • P:processor 管理器,通过队列管理协程

  • 基于GMP模型,协程运行在线程上
  • 一个协程中的信息:运行栈+寄存器数值(PC,BP,SP)
  • 协程的切换,仅仅需要改变寄存器的数值,cpu便会从需要切换的协程指定位置继续运行
  • 协程与线程的比例关系:N:M
协程:线程含义优点缺点
1:1一个协程在一个线程上运行(其实就是传统的多线程)利用多核上下文切换比较慢(reactor模型,代价较大)
N:1多个协程在一个线程上运行上下文切换较快1.无法充分利用多核2.饥饿,如果一个协程不结束,其余协程阻塞
N:M多个协程在多个线程上运行充分利用多核,上下文切换快对实现要求更高

通信管道(chanel)

  • 并发模型CSP,全称Communicationg Sequential Processes。它的核心观念是将两个并发执行的实体通过管道连接起来,所有的消息都通过管道传输。

  • 管道(通道),也是一种Go的数据同步技术。它可以被看作是在一个程序内部的一个先进先出(FIFO:first in first out) 数据队列。

  • 管道的操作有读、写和关闭。

    • 定义:ch := make(chan string)
    • 读:a = <- ch
    • 写: ch <- "hello"
    • 写一个已经关闭的channel会引发panic
  • 管道分类:无缓冲管道&缓冲管道

  • 无缓冲管道:长度为0的channel,为不带buffer的channel

    • ch := make(chan int)
    • 不会发生额外的拷贝
    • 读在写前
      • 同步通道
      • 发送和接收同步化
  • 有缓冲管道:长度大于0的channel,为带buffer的channel

    • ch := make(chan int,10)
    • 会发生额外的拷贝
    • 写在读前 ch<- 1
    • 缓冲区最大为65535
      • 生产消费模型,缓冲通道类似货架
  • 管道元素的传递,是复制,非缓冲区管道复制了1次,缓冲区管道复制了2次

  • 例如面试常考的:请用管道实现交替打印AB: 代码示例如下

func main() {
	ch1 := make(chan string)
	ch2 := make(chan string)
	ch3 := make(chan string)
	go printA(ch1, ch2)
	go printB(ch1, ch2, ch3)
	<-ch3
}

func printA(ch1, ch2 chan string) {
	for i := 0; i < 100; i++ {
		<-ch2
		fmt.Println(i, "A")
		ch1 <- "print A"
	}
}

func printB(ch1, ch2, ch3 chan string) {
	ch2 <- "begin"
	for i := 0; i < 100; i++ {
		<-ch1
		fmt.Println(i, "B")
		if i != 99 {
			ch2 <- "print B"
		} else {
			ch3 <- "end"
		}
	}
}
sync.WaitGroup(优雅永不过时)
  • 使用 time.Sleep 来实现协程阻塞的方式是不优雅的,可以使用 WaitGroup 来实现

  • wg.Add(5) //开启5个协程

  • wg.Done() //计数器-1 表明子协程结束

  • wg.Wait() //阻塞当前协程待全部子协程结束

  • 代码案例如下

    • package main
      ​
      import (
         "sync"
         "time"
      )
      ​
      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) //9136
         x = 0
         for i := 0; i < 5; i++ {
            go addWithLock()
         }
         time.Sleep(time.Second)
         println("WithLock:", x) //10000
      }
      ​
      func main() {
         Add()
      }
      

(二)Go语言依赖管理

了解Go语言依赖管理的演进路线

1 Go依赖管理演进

  • GOPATH ——> Go Vendor ——> Go Module
  • 不同环境(项目)依赖的版本不同
  • 控制依赖库的版本

1.1 GOPATH

  • 环境变量 $GOPATH,是 所有 go 项目的工作区,包含bin(项目编译的二进制文件)、pkg(项目编译的中间产物,加速编译)、src(项目源码) 三个目录

  • 项目代码直接依赖 src 下的代码,把所有依赖的源代码都放到 src 中

  • go get 下载最新版本的包到 src下

  • 弊端

    • 所有项目共享 src 目录,只能共享同一版本的依赖
    • 当两个不同的项目依赖于不同版本的 package 时,无法同时构建成功,“一山不容二虎”,无法实现 package 的多版本控制

1.2 Go Vendor

  • 每一个项目目录下增加 vendor 文件,所有依赖包以副本形式放在 $ProjectRoot/vendor

  • 依赖寻址方式vendor => GOPATH,首先去 vendor 里面找,找不到再去 GOPATH 找

  • 通过每个项目引入一份依赖的副本,解决了多个项目需要同一个 package 依赖的冲突问题,每个项目在vendor中存储自己所需要的依赖,版本可以不同

  • 弊端

    • 当出现依赖传递时,即同一个项目中的两个依赖包又分别需要依赖另一个包的不同版本时,就无法实现,会出现不兼容,原因与GOPATH弊端原因类似
    • 无法控制依赖的版本
    • 更新项目又可能出现依赖冲突,导致编译出错

1.3 Go Module

  • 通过go.mod 文件管理依赖包版本
  • 通过go get/ go mod指令工具管理依赖包
  • 通过定义版本规则和管理项目依赖的关系来实现,为不同依赖包的不同版本建立映射规则

2 依赖管理三要素

  • 配置文件,描述依赖 go.mod 描述项目所需的依赖及其版本关系
  • 中心仓库管理依赖库 Proxy 存放所有依赖资源包
  • 本地工具 go get / go mod 将仓库中的依赖获取到本地

2.1 go.mod

  • 模块路径: 标识模块,有github前缀表示可以从Github仓库中找到

  • 依赖标识[Module Path][Version/Pseudo-version]模块路径 + 语义化版本/伪版本号

  • 原生sdk版本

     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 v0.0.0-20180306012644-bacd9c7ef1dd   // indirect
         example/lib5/v3 v3.0.2
         example/lib6 v3.2.0+incompatible
     )
    

2.2 version

  • gopath 和 govendor 都是源码副本方式依赖,没有版本概念,gomod定义了版本规则

  • 语义化版本:

    ${MAJOR}.${MINOR}.${PATCH}

    V1.3.0

    V2.3.0

    • 不同MAJOR版本表示不兼容的API(不同库),即使在同一个库中,只要MAJOR版本不同便代表不是同一个模块
    • 不同MINOR版本表示新增函数或功能,可以向后兼容
    • 不同PATCH版本表示修复bug
  • 基于commit 的伪版本

    vX.0.0-yyyymmddhhmmss-abcdefgh1234

    v0.0.0-20220401081311-c38fb59326b7

    v1.0.0-20201130134442-10cb98267c6c

    • 第一部分:基础版本前缀,基本与语义化版本一致
    • 第二部分:时间戳(yyyymmddhhmmss),为提交Commit时的时间
    • 第三部分:校验码(abcdefgh1234),包含12位的哈希前缀
    • 每次Commit后Go都会默认生成一个伪版本号

2.3 indirect

  • 间接依赖: 未直接导入该依赖模块的源码包

2.4 incompatible

  • 主版本2+模块(v3 v4 v5)会在模块路径后面增加 /vN 后缀,用于实现按照不同的模块来处理同一个项目不同主版本的依赖
  • 对于没有 go.mod 文件并且主版本2+ 的依赖,会有 +incompatible

2.5 依赖图

image-20220508122535110.png

  • 选择最新的兼容版本,【最新】【兼容】
  • MINOR版本是可以向下兼容的

3 依赖分发(将依赖放置在多个第三方平台上)

3.1 回源

image-20220508122943992.png

  • 无法保证构建稳定性,依赖版本的作者进行增加、修改、删除软件版本时
  • 无法保证依赖可用性,依赖版本的作者删除软件时
  • 增加第三方压力,代码托管平台负载问题

3.2 Proxy(缓存)

Go Proxy , 服务站点,缓存源网站中的软件内容,缓存的软件版本不会改变,并且在源站软件删除后依然可用,项目构建时会直接从Go Proxy中拉取所需依赖资源

image-20220508125644460.png

2.3.2.1 Proxy相关配置——变量 GOPROXY
  • GOPROXY="https://proxy1.cn,https://proxy2.cn,direct",是服务站点的URL链表,其中,direct表示源站
2.3.2.2 Proxy工具——go get

image-20220508131003101.png

  • 啥都不加,默认拉取最新MAJOR版本
2.3.2.3 Proxy工具——go mod

image-20220508131121900.png

(三)Go语言单元测试

从单元测试实践出发,提升质量意识

3.1 单元测试

image-20220508131819410.png

3.1.1 单元测试规则

  • 测试文件命名规范:_test.go结尾,便于区分
  • 测试函数命名规范: func TestXxx(t *testing.T){}

image-20220508132200712.png

  • 初始化准备: 提供了一个测试用的TestMain函数,将初始化逻辑放入其中

    • m.Run()跑该包下的所有单元测试例子

image-20220508132356963.png

3.1.2 单元测试例子

  • go test [flags][packages]命令执行测试源代码文件(以_test.go结尾)
  • PS E:\go\0.Demos\courseDemo2\go-project-example-0> go test test/print_test.go test/print.go要同时写上测试函数所在文件和被测试函数所在文件
 package test
 ​
 func HelloTom() string {
     return "Tom"
 }
 ​
 package test
 ​
 import (
     //assert工具包
     "github.com/stretchr/testify/assert"
     "testing"
 )
 ​
 func TestHelloTom(t *testing.T) {
     output := HelloTom()
     expectOutput := "Tom"
     //判断是否相等
     assert.Equal(t, expectOutput, output)
 }

3.1.3 评估单元测试——代码覆盖率

  • 选择不同的测试分支在测试指令go test ..... 的最后加上--cover
  • 计算的是:成功执行的代码行数占总行数的比值

3.2 单元测试 —— Mock

3.2.1 单元测试依赖

image-20220508140318039.png

  • 单元的依赖多种多样,有文件、数据库、内存等等
  • 直接对整个单元进行测试是不稳定的,同时无法保证幂等性质,需要Mock机制,确保每次测试的输入是稳定的,一致的而不是依赖于文件

3.2.2 文件处理测试例子

  • log源文件

image-20220508141650251.png

  • 被测试函数
 func ReadFirstLine() string {
     open,err := os.Open("log")
     defer os.Close()
     if err != nil {
         return ""
     }
     scanner := bufio.NewScanner(open)
     //返回log文件中的第一行
     for scanner.Scan() {
         return scanner.Text()
     }
     return ""
 }
 ​
 func ProcessFirstLine() string {
     line := ReadFirstLine()
     destLine := strings.ReplaceAll(line,"11","00")
     return destLine
 }
  • 测试函数
 func TestReadFirstLine(t *testing T) {
     firstLine := ProcessFirstLine()
     assert.Equal(t,"line00",firstLine)
 }
  • 问题:一旦测试源文件 log 被删除,测试不可进行

3.2.3 Mock

  • 开源Mock包: monkey:github.com/bouk/monkey

  • 快速Mock函数:

    • 打桩: 用函数A替换函数B,则函数B是原函数,函数A是打桩函数
    • 为一个函数打桩
    • 为一个方法打桩
  • 直接在打桩函数里面将测试需要的测试模拟值写进去,而不是依赖文件

文件处理 —— Mock测试

  • 被测试文件(不变)
  • 测试文件
 //原测试方法
 func TestProcessFirstLine(t *testing.T) {
     firstLine := ProcessFirstLine()
     assert.Equal(t, "line00", firstLine)
 }
 ​
 //重写测试方法
 func TestProcessFirstLineWithMock(t *testing.T) {
     monkey.Patch(ReadFirstLine, func() string {
         //将原ReadFirstLine函数内容改为直接输出“line110”,将原本从文件获取测试值改为固定的测试值
         return "line110"
     })
     //卸载打桩
     defer monkey.Unpatch(ReadFirstLine)
     firstLine := ProcessFirstLine()
     assert.Equal(t, "line000", firstLine)
 }

3.3 基准测试

  • 被测试函数:随机选择服务器
 package benchmark
 ​
 import (
     "github.com/bytedance/gopkg/lang/fastrand"
     "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)]
 }
 ​
 func FastSelect() int {
     return ServerIndex[fastrand.Intn(10)]
 }
  • 测试函数:对 Select() 函数测试,命名Benchmark开头
 package benchmark
 ​
 import (
     "testing"
 )
 ​
 //通过循环做串行的压力测试
 func BenchmarkSelect(b *testing.B) {
     InitServerIndex()
     b.ResetTimer()
     for i := 0; i < b.N; i++ {
         Select()
     }
 }
 //通过RunParalle做并行压力测试
 func BenchmarkSelectParallel(b *testing.B) {
     InitServerIndex()
     b.ResetTimer() //重置时间,将初始化时间减去
     b.RunParallel(func(pb *testing.PB) {
         for pb.Next() {
             Select()
         }
     })
 }
 func BenchmarkFastSelectParallel(b *testing.B) {
     InitServerIndex()
     b.ResetTimer()
     b.RunParallel(func(pb *testing.PB) {
         for pb.Next() {
             FastSelect()
         }
     })
 }

到这里就先结束啦!

翻过这座山,他们就会听到你的故事!加油!