Go语言工程化实践入门与依赖管理 | 青训营

740 阅读4分钟

go语言工程化实践入门与依赖管理

在前几节课学习了Go的基础语法和实战案例,在实际开发中,如何进行Go工程化实践是一个很重要的话题,接下来就是要学习Go工程化管理和一些实践细节。

一、go语言进阶

并发VS并行

  • 并发:多线程程序在一个核的CPU上运行

image.png

  • 并行:多线程程序在多个核的CPU上运行

image.png

并发和并行两个有三点区别:处理任务不同、存在不同、CPU资源不同。

并行是实现并发的手段。go可以充分发挥多核优势,高效运行。go为并发而生。

coroutine

image.png

  • 协程:用户态,轻量级线程,栈KB级别。
  • 线程:内核态,线程跑多个协程,栈MB级别。资源昂贵。

协程的创建、调度由go语言本身完成

下面是一个使用四个线程打印的例子。

// 快速打印hello goroutine:0~hello gorountine:4
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) //i当作func的参数传入
	}
	time.Sleep(time.Second)
}

CSP 独立并发执行

共享内存是通过内存来共享内存。 管道是通过通信来共享内存。

我们提倡通过通信共享内存而不是通过共享内存而实现通信

image.png

channel

使用make来创建通道。

  • 无缓冲通道:同步通道src := make(chan int)
  • 有缓冲通道:快递仓库 dest := make(chan int, 3)

我们可以把此类问题看成消费者和生产者之间的关系。 生产者逻辑简单,消费者可能有其他复杂操作。消费者的消费速度可能会慢一些,生产速度快。

而channel的目的就为了平衡两者的速度

   go func() {
		// 子协程发送0-9数字
		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)
	}

并发安全Lock

使用lock进行并发执行输出正确结果,不加锁时会输出未知的结果。

所以在并发执行时加锁很有必要。

//利用加锁实现变量2000次加1操作
func addWithLock() {
	for i := 0; i < 2000; i++ {
		lock.Lock()  //lock获取临界区资源 通过临界区实现
		x += 1
		lock.Unlock()//将临界区的权限释放掉
	}
}
//不加锁
func addWithoutLock() {
	for i := 0; i < 2000; i++ {
		x += 1
	}
}

WaitGroup

sleep起到暴力阻塞的作用,因为我们无法精确知道子协程的执行时间,所以无法设置精确sleep时间。

waitGroup有三个方法:

  • Add(delta int) 计数器+delta
  • Done() 计数器-1
  • Wait()阻塞直到计数器为0

WaitGroup内部维护一个计数器,可以增加减少。

  • 加一代表开启协程
  • 减一执行结束
  • 主协程阻塞直到计数器为0代表所有并发完成。

下面我们用watigroup实现协程的同步阻塞。首先通过add方法,对计数器+5,然后开启协程,每个协程执行完后,通过done对计数器减少1,最后wait主协程阻塞,计数器为0退出主协程。

func hello(i int) {
	println("hello goroutine:" + fmt.Sprint(i))
}
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()

}

最后输出结果为

image.png

二、依赖管理

背景

对于hello world以及类似的单体函数只需要依赖原生SDK,而实际工程会相对复杂,工程项目不可能基于标准库0-1编码搭建,更多的关注业务逻辑的实现,以及设计框架、日志、driver、以及collection等一系列依赖都会通过SDK的方式引入,这样对依赖包的管理就尤为重要。

到目前被广泛应用的go module,整个演化路线主要围绕实现两个目标来迭代发展的。分别是不同环境依赖的版本不同和控制依赖库的版本。 go依赖管理主要经历以下三个阶段:

  • GOPATH
  • GO VENDOR
  • GO Module

GOPATH

gopath是go语言支持的一个环境变量,value是Go项目的工作区。

image.png 目录有以下结构:

  • bin: 存放项目编译产生的二进制文件
  • pkg:存放项目编译的中间产物,加速编译
  • src:存放项目源码

项目代码直接依赖src下的代码

go get下载最新版本的包到SRC目录下

弊端:

image.png 两个项目A和B都依赖同一个pkg,但是pkg有不同版本pkg v1和pkg v2,里面包含两个方法。而src下只能有一个版本存在,那AB项目无法保证都能编译通过。

就是在gopath管理模式下,如果多个项目依赖同一个库,则依赖该库是同一份代码,所以不同项目不能依赖同一个库的不同版本,这很显然不能满足我们的项目依赖需求。无法实现package的多版本控制。

为了解决这个问题,go vendor出现了。

GO Vendor

  • 项目目录下增加vendor文件,所有依赖包副本形式放在$projectRoot/vendor
  • 依赖寻址方式:bendor=>GOPATH

在vendor机制下,如果当前项目下存在vendor目录,会优先使用该目录下的依赖,如果依赖不存在,会在GOPATH中寻找。

通过每个项目引入一份依赖的副本, 解决了多个项目需要同一个package依赖的冲突问题。

但vendor无法很好解决依赖包的版本变动问题和一个项目依赖同一个包的不同版本的问题。

弊端

image.png

如图,A依赖pkg B和C,而B和C依赖D的不同版本,通过vendor的管理模式我们不能很好的控制对于D的依赖版本,一旦更新项目,有可能带来以下问题:

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

总之,vendor不能很清晰的标识依赖的版本概念。go module为了解决这个问题就产生了,下面就开始介绍一下go module。

Go Module

go module是go语言官方推出的依赖管理系统,解决了之前依赖管理系统存在的诸如无法依赖同一个库的多个版本等问题,Go module从go1.11开始引入,go1.16默认开启,我们一般都读go mod。

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

目标:定义版本规则和管理项目依赖关系

下面是关于go.mod的语法结构。

依赖管理的三要素

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

1、 依赖配置-go.mod

image.png

依赖标识:[MOdule path] [version/pseudo-version]

  • 模块路径用来标识一个模块,从模块路径可以看出从哪里找到该模块,如果是从github仓库找到该模块,依赖包的源代码由github托管,如果项目的子包想被单独引用,则需要通过单独的init go.mod文件进行管理。
  • 第二行是依赖的原生SDK版本号,如上面截图中是1.16
  • 最下面是单元依赖,每个依赖单元用模块路径加版本来唯一标识。

2 依赖配置-version

gopath和govendor都是源码副本方式依赖,没有版本规则概念,而gomod为了方便管理则定义了版本规则,分为语义化版本和基于commit伪版本。

  • 语义化版本${MAJOR}.${MINOR}.${PATCH}

    不同MAJOR版本表示是不兼容的API,所以即使是一个库,MAJOR版本不同也会被认为是不同的模块。

    MINOR版本通常是新增函数或功能,向后兼容。

    patch版本一般是修复bug.

  • 基于commit伪版本 vx.0.0-yyyymmddhhmmss-abcdefgh1234

    基础版本前缀是和语义化版本一样的,时间戳也就是提交commit的时间,最后是校验码,包括12位的哈希前缀。每次提交commit后go都会默认生成一个伪版本号。

3 依赖配置-indirect关键字

A->B->C,A->B属于直接依赖,A->C属于间接依赖。

在go.mod中,对于没有直接导入该依赖模块的包,也就是非直接依赖,标识间接依赖。所以加上indirect后缀。

image.png

4 依赖配置-incompatible关键字

主版本在2及以上的模块在模块路径中都需要增加/vN后缀,这能让go module按照不同的模块来处理同一个项目不同主版本的依赖。

对于没有go.mod文件并且主版本在2及以上的依赖,会在版本号后加上incompatible后缀作为标识

image.png

5 依赖配置-依赖图

image.png

最后选B,最终构建项目时,C的依赖版本要选择最低的兼容版本。

6 依赖分发-回源

image.png gomodule的依赖分发:就是从哪里下载,如何下载的问题。

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

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

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

7 依赖分发-proxy

image.png

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

使用go proxy之后,构建时会直接从go proxy站点拉取依赖。类比项目中,下游无法满足我们上游的需求。

8 依赖分发-变量 GOPROXY

go modules通过GOPROXY环境变量控制如何使用go proxy。

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

对于示例配置,整体的依赖寻址路径,会优先从proxy1下载依赖,如果proxy1不存在,后去proxy2寻找,如果proxy2中不存在,则会回源到源站直接下载依赖,缓存到proxy站点中。

image.png

9 工具-go get/mod

开头提到go model有两个本地工具,go get/mod。

  • 下面就先介绍一下go get的一些使用规则。 image.png
  • 接着是go mod工具。下面是三个常用的用法。 image.png

总结

Go工程化实践是一个非常重要的话题,可以提高项目的开发效率、代码质量和安全性。在实际开发中,需要注意项目结构、依赖管理、代码规范、持续集成、性能优化和安全性等方面的问题,才能开发出高质量的Go项目。