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

285 阅读8分钟

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

1.Go语言进阶

1. 并发 & 并行

并发表示多线程程序在一个核的cpu上运行

并行表示多线程程序在多个核的cpu上运行

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

1. Goroutine(协程)

而在Go语言机制里有一个重要概念:Goroutine(协程),Goroutine 是轻量级线程,Goroutine 的调度是由 Golang 运行时进行管理的。 以下就是Goroutine(协程)与线程的关系:

协程:用户态,轻量级线程,栈KB级别

线程:内核态:线程跑多个协程,栈MB级别

我们可以通过以下格式:

go 函数名( 参数列表 )

如下所示:

func hello(i int) {
    println("hello world : " + fmt.Sprint(i))
}

func Go() {
    for i := 0; i < 5; i++ {
        go hello(i)
    }
}

代码执行后并没有固定先后顺序。因为它们是多个 goroutine 在执行

2. CSP

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

通过通信共享内存

如图所示:多个Goroutine通过通道进行连接、

Goroutine1作为Go并发的执行体,中间的通道将协程进行了一个连接,就像传输队列,遵循先入先出,保证了收发数据的顺序

image.png

过共享内存实现通信

Go也保留了通过共享内存实现通信的机制,如下图所示

image.png

通过共享内存实现通信必须是通过互斥量对内存进行加锁,也就是要获取临界区的一个权限,在这种机制下,不同的Goroutine之间就容易发生数据静态问题,会影响到程序性能

3.Channerl(通道)

Channerl是用来传递数据的一个数据结构。可以用于连个Goroutine之间通过传递一个指定类型的值来同步运行和通讯。

Channerl(通道)需要使用make()关键字来进行创建又通过是否有缓冲大小来区分Channerl(通道)类型 并且Channerl(通道)利用操作符 <- 用于指定通道的方向,发送或接收。

ch1 := make(chan int)        //无缓冲通道
ch2 := make(chan int2)     //有缓冲通道

ch := make(chan int)
ch <- v    // 把 v 发送到通道 ch
v := <-ch  // 从 ch 接收数据
           // 并把值赋给 v           

有无缓冲通道的一个区别如下图所示

image.png

无缓冲通道进行通信时,会导致发送的Goroutine和接收的Goroutine发生同步化。解决同步问题就是使用有缓存通道。

有缓冲的通道允许发送端的数据发送和接收端的数据获取处于异步状态,就是说发送端发送的数据可以放在缓冲区里面,可以等待接收端去获取数据,而不是立刻需要接收端去获取数据。但是缓存大小是有限的,如果缓冲区满了,则发送端就无法再次发送数据。这时候如果通道不再接收数据,我们可以通过close()函数进行对Channerl(通道)关闭。

我们通过一个例子来讲解协程的应用:

src := make(chan int)             //定义两个通道分别为无缓存通道和有缓存通道
dest := make(chan int, 3)
go func() {
    defer close(src)      //当后续for循环结束不再传输数据,关闭src通道
    for i := 0; i < 10; i++ {
         src <- i         // 通过for循环将0~9数字值发送到src通道中
    }
}()
go func() {
   defer close(dest)      //当后续for循环结束不再传输数据,关闭dest通道
   for i := range src {   //通过for循环遍历src通道中的数字的平方值发送到dest通道中
        dest <- i * i     // 如果上面的src不进行关闭,range函数就不会停止将会遇到阻塞
   }
}()

其输出代码结果如下:

0,1,4,9,16,25,36,49,64,81

2. 依赖管理

在Go语言的开发复杂项目时,工程项目不可能基于标准库0~1编码搭建,随着版本的不断更迭,Go语言依赖管理方面也在不断的完善

以下为Go语言主要的三个依赖管理演进

GOPATH -> Go Vendor -> Go Module

1. GOPATH

首先GOPATH是Go语言支持的一个环境变量,是Go项目的一个工作区,在工作区中有bin,pkg,src三个文件目录

bin -- 负责存放项目编译的二进制文件

pkg -- 负责存放项目编译的中间产物,加速编译

src -- 负责存放项目源码

在GOPATH中项目代码直接依赖于src下的代码

可以通过go get 下载最新版本的包到src目录下

但是GOPATH的弊端也十分的明显

如果两个项目依赖于某一个package的不同版本,GOPATH就无法实现package的多版本控制

2. Go Vendor

随着Go语言的迭代,Go语言从 1.5 版本开始开始引入 vendor 模式

这个模式在原有的项目目录下增加了vendor文件,所有依赖包副本形式放在$ProjectRoot/vendor

依赖寻址方式:vendor目录 => GOPATH

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

当然Go Vendor也会有相应的弊端:

1.无法控制依赖的版本

2.更新项目又可能出现依赖冲突,导致编译出错。

归根结底Vendor不能很清晰的表示依赖的版本概念

3. Go Module

为了解决Go Vendor遗留的弊端问题,Go Module就应运而生了

Go Module是Go语言官方退出的依赖管理系统,解决了之前依赖管理系统存在的诸如无法依赖同一个库的多个版本等问题。

Go Module从Go 1.11开始实验性引入,Go 1.16默认开启。

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

依赖管理三要素

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

3. 测试

在实际工程开发中,另一个重要概念就是单元测试,这一节我们主要讲解Go测试相关的内容,包括单测规范,测试模拟,以及基准测试。测试关系着系统的质量,质量则决定着线上系统的稳定性,一但出现bug漏洞,就会造成事故,因此测试就是避免事故的最后一个屏障。

测试一般分为 : 回归测试,集成测试,单元测试

回归测试一般用于手动通过终端回归一些固定的主流程场景,集成测试是对系统功能维度做测试验证,而单元测试为开发阶段开发者对单独的函数与模块做功能验证,它们的层级如下:

image.png

因此单元测试的覆盖率一定程度上决定了整个代码的质量

单元测试

单元测试主要包括,输入,测试单元,输出,以及校对,单元的概念比较广,包括接口,函数,模块等;用最后的校对来保证代码的功能与我们的预期相符;单侧一方面可以保证质量,在整体覆盖率足够的情况下,一定程度上既保证了新功能本身的正确性,又未破坏原有代码的正确性。另一方面可以提升效率,在代码有bug的情况下,通过编写单测,可以在一个较短周期内定位和修复问题。

1.单元测试-规则

在Go语言下我们首先需要导入test包才能够实现测试操作,并且遵守有一些单元测试的基本规范:

1.所有的测试文件以_test.go结尾

image.png

2.func TestXxx(*tesing.T)作为被运行的方法

image.png

3.初始化逻辑放到TestMain当中

image.png

接下来我们通过代码进行测试,首先创建两个文件,一个为HelloTom.go文件,一个为HelloTom_test.go的测试文件 首先我们在HelloTom.go文件中写上一个函数,让其返回Jerry

func HelloTom() string{
	return "Jerry"
}

然后我们在HelloTom_test.go文件中写上测试运行方法,并让预期结果设置为Tom

func TestHelloTom(t *testing.T) {
	output := HelloTom()
	expectOutput := "Tom"
	if output != expectOutput {
		t.Errorf("Expected %s do not match actual %s",expectOutput,output)
	}
}

然后我们通过运行测试文件,测试结果输出是:

Expected Tom do not match actual Jerry

很按照预想的一样输出了错误信息,显然我们的测试是通过的,接下来我们再把HelloTom的函数输出改为Tom,然后我们再一次运行HelloTom_test.go文件。这次结果输出为:

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

说明了当两个值相同,并且执行完了整个程序。并且为了更好的显示测试信息我们可以通过引入assert包来进行更好的测试分析,我们需要在终端使用以下代码下载该包

go get github.com/stretchr/testify/assert

然后通过import将包引入到代码当中,引入该包的代码如下

import(
    "github.com/stretchr/testify/assert"
)

2.单元测试-覆盖率

在单元测试当中,一个代码测试覆盖率,代表了一个代码是否经过了足够的测试,也是评定一个项目的测试水准,更能评估一个项目是否达到了高水准的测试等级

我们这次利用一个判断是否及格的函数来实现代码测试覆盖率的操作 首先我们需要通过go get引入一个我们需要的包

go get github.com/stretchr/testify/assert

首先我们依旧建立两个文件,一个命名为 Judge.go,一个命名为Judge_test.go的文件。在Judeg.go中我们写入以下函数用来检测分数是否为合格60分。

func Judge(score int16) bool {
	if score >= 60 {
		return true
	}
	return false
}

接下来我们在Judge_test.go的文件中写入以下测试代码:

func TestJudgeTrue(t *testing.T) {
	isPass := Judge(70)
	assert.Equal(t, true, isPass) //通过assert包进行打印
}

最后我们在终端界面输入以下代码并用指定cover参数来查看代码运行的覆盖率

 go test Judge_test.go Judge.go --cover

最后得出了结果为:

ok      command-line-arguments  2.913s  coverage: 66.7% of statements

从上述代码得出,我们使用分数为70的值进入Judge代码后,只覆盖到了2/3的代码,这是因为,当我们判断分数后,分数大于等于60,函数就返回了true值,剩下的函数返回false值并没有被覆盖到,所以只执行了2/3。

接下来我们往Judge_test.go文件中再写入一个不及格分数的测试用例后,再次在终端进行执行我们的指令,这次的覆盖率就能够达到100%了

func TestJudgeFalse(t *testing.T) {         //测试返回False函数
	isPass := Judge(70)
	assert.Equal(t, false, isPass)
}
//终端执行命名输出结果
ok      command-line-arguments  3.679s  coverage: 100.0% of statements

3.单元测试-依赖

在一些复杂的工程项目中,一边都会用到外部依赖等其他依赖,而我们的单元测试需要保证稳定性和幂等性,稳定指的是要互相隔离,能在任何时间,任何环境下运行测试。幂等是指每一次测试运行的结果应该都与之前一样的结果。而实现这一目的就要用到Mock机制。

例如,如果我们想要将文件中的字符串的11替换成00,执行单元测试,测试通过,而我们的单元测试需要依赖本地的文件,如果文件被修改或者删除测试就会失败。 我们测试代码如下:

package test                 //TestProcessFirstLine_test.go
import (
	"testing"

	"github.com/stretchr/testify/assert"
)
func TestProcessFirstLine(t *testing.T) {
	firstLine := ProcessFirstLine()
	assert.Equal(t, "line00", firstLine)
}

package test                 //ProcessFirstLine.go
import (
	"bufio"
	"os"
	"strings"
)
func ReadFirstLine() string {
	open, err := os.Open("log")
	defer open.Close()
	if err != nil {
		return ""
	}
	scanner := bufio.NewScanner(open)
	for scanner.Scan() {
		return scanner.Text()
	}
	return ""
}
func ProcessFirstLine() string {
	line := ReadFirstLine()
	destLine := strings.ReplaceAll(line, "11", "00")
	return destLine
}

由此可见我们的TestProcessFirstLine_test.go的文件会对"log"文件进行外部依赖,因此我们为了保证测试的case的稳定性,我们对读取文件函数进行mock,屏蔽对文件的依赖

测试的文件依赖 image.png

这里我们用了monkey,monkey是一个开源的mock测试库,可以对method,或者实例的方法进行mock反射,指针赋值 Mockey Patch的作用域在Runtime,在运行时通过通过Go的unsafe包,能够将内存中函数的地址替换为运行时函数的地址,将待打桩函数或方法的实现跳转到。

接下来我们进行展示一次对mock的使用样例,首先import来导入相应的包,

import	"github.com/bouk/monkey"

通过patch 对ReadfirstLine进行打桩mock,默认返回line110,这里通过defer卸载mock,这样整个函数就摆脱了本地文件的束缚和依赖

func TestProcessFirstLine(t *testing.T) {
	monkey.Patch(ReadFirstLine, func() string {
		return "line110"
	})
	defer monkey.Unpatch(ReadFirstLine)
	firstLine := ProcessFirstLine()
	assert.Equal(t, "line00", firstLine)
}