Go进阶编程与工程实践| 青训营笔记

122 阅读11分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 3 天

1,Go语言并发编程

1.1 Go为啥这么快

  • 并发VS并行

并发指的是多线程程序在个核的CPU上运行,主要依靠时间片的切换来实现同时运行。

并行指的是多线程程序在个核的CPU上运行,直接利用多核来实现多线程的运行。

实际上,并行可以理解为实现并发的一个手段。Go语言实现了一个并发性能极高的调度模型,通过高效的调度可以极大限度地调动资源,充分发挥计算机的多核优势。

1.2 Goroutine

协程Goroutine是Go语言中实现高并发模型的一个重要概念。与线程相比较,线程属于操作系统层面的内核态,其创建,切换,停止都属于比较耗时的一种操作,其栈空间可以达到KB级别。而协程可以简单理解为轻量级的线程,其创建和删除由Go语言完成,属于操作系统层面的用户态,其栈空间可以达到MB级别。

image.png Go一次性可以创建上万左右的协程,这也是为啥Go适合高并发应用场景的原因所在。下面是创建goroutinue协程的一个例子

//快速打印hello goroutinue :0 ~ hello goroutinue :4
func hello(i int) {
println("hello goroutinue : " + fmt.Sprint(i))
}
func HelloGoRoutinue() {
    for i := 0; i < 5; i++ {
        go func(j int){
            hello(j)
        }(i)
    }
    time.Sleep(time.Second)//使用了暴力的sleep做了阻塞,防止子协程打印完之前主协程不会退出
}

1.3 Goroutinue之间如何通信?

  • 通过通信来共享内存

首先了解一个重要概念--通道channel:类似于消息队列的方式实现协程之间的通信,Goroutinue主协程通过通道将信息传递给channel,其采用先进先出的方式,将消息传递给其他Goroutinue协程,从而实现了共享内存。

image.png

  • 通过共享内存来实现通信

这种实现方式必须通过互斥量对内存实现一个加锁,也就是需要获取临界区的权限才能实现通信。但是这样实现就容易在不同的go空间发生数据竞赛的问题,某种程度上会影响程序的性能。所以Go语言选择第一种方式实现协程间通信,即通过通信来实现共享内存。

image.png

1.4 通道Channel

  • Channel是一种 引用类型,其创建需要使用make关键字,根据其是否存在缓冲区大小,Channel又可以分为有缓冲通道和无缓冲通道。
    make(chan int)      //无缓冲通道
    make(chan int, 2)   //有缓冲通道

image.png

下面通过例子看一下channel的具体使用:

funcCalSquare() {
    src := make(chan int)     //无缓冲队列
    dest := make(chan int, 3) //有缓冲队列
    
    //A协程用于生产
    go func() {
        defer close(src)
        for i := 0; i < 10; i++ {
            src <- i
        }
    }()
    
    //B协程用于消费
    go func() {
        defer close(dest)
        for i := range src {
            //有缓冲的通道可以解决生产者消费者速度不匹配的问题。
            dest <- i*i
        }
    }()
    
    //最终主协程遍历有缓冲通道,输出0~9的平方。
    for i := range dest {
        //复杂操作
        println(i)
    }
}

1.5 并发安全Lock(),给协程穿上裤子!

直接上代码,加锁实现协程之间的并发安全。

// 使用5个协程并发地对1个变量执行2000次+1操作
var(
    x int64
    lock sync.Mutex
)

func addWithLock() {
    for i := 0; i < 1000; 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) //WithoutLock:8382
    
    x = 0
    for i := 0; i < 5; i++ {
        go addWithLock()
    }
    time.Sleep(time.Second)
    println("WithLock:", x)    //WithLock:10000

}

由此可见,在实际项目开发中,并发安全问题有很大概率导致错误结果出现。在实际开发时,应尽量避免对共享内存采用一些不安全的读写操作。

1.6 WaitGroup优雅实现协程阻塞,让每一个协程都不掉队

前面我们的例子都使用了Sleep实现了暴力的阻塞,但这肯定不是一个优雅的实现方式。由于我们不知道子协程精确的执行时间,所以我们无法精确设置协程Sleep的时间。

Go为我们提供了WaitGroup实现协程之间的同步,内部维持了一个计数器,可以增加和减少。当计数器为0的时候,即为所有的并发任务都已经完成。其有三个方法:

Add(delta int) //用于给计数器增加指定的值;

Done()         //用于给计数器内部的值-1;

Wait()         //实现了程序阻塞直到计数器的值为0。

下面使用WaitGroup优化之前的例子:

func hello(i int) {
    println("hello goroutinue : " + 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()//每个协程执行完毕以后对计数器的值-1
            
            hello(j)
        }(i)
    }
    
    wg.Wait()//程序执行完毕后对协程进行阻塞
    time.Sleep(time.Second)

1.7 小结

  • Goroutine

Go可以通过高效率的调度模型Goroutine实现高并发的操作

  • Channel

Go提倡通过通信实现共享内存

  • Sync

介绍了协程间通信中需要的Sync包下的加锁关键字Lock和阻塞关键字WaitGroup,用于实现协程之间的并发安全操作和同步。

2,依赖管理

2.1 依赖--站在巨人的肩膀上

  • 工程项目不可能基于标准库0~1编码搭建
  • 管理依赖库

Go依赖管理的演进阶段

1,GOPATH

2,Go Vendor

3,Go Module

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

2.1.1 GOPATH

GOPATH是Go语言支持的一个环境变量,是Go项目的一个工作区,其目录由三个文件组成:

1,bin : 项目编译的二进制文件

2,pkg : 项目编译的中间产物,加速编译

3,src : 项目源码

  • 项目源码直接依赖src下的代码
  • go get下载最新版本的包到src目录下

GOPATH的弊端

假设本地拥有两个项目,分别命名为项目A和项目B,其都依赖了同一个包Pkg,包Pkg拥有v1和v2两个版本。但两个版本之间并不兼容,即v1中实现的函数func A()在v2中升级成为了func B(),当这种情况出现时,GOPATH就无法兼容两个项目。

image.png 为了解决这个问题,升级到了下一个版本Go Vendor

2.1.2 Go Vendor

  • 相比于GOPATHGo Vendor在项目目录下增加了vendor文件,用于存放项目依赖包的副本形式。
  • 在构建项目时,依赖包的寻址方式为:vendor --> GOPATH,再遇到上面所述的问题时,Go会首先在A项目的目录下寻找包pkg的v1版本的副本对A进行构建,如果没有找到再去GOPATH目录下进行查找。
  • 通过每一个项目引入一份依赖的副本,解决了多个项目需要同一个package依赖冲突的问题。

GO Vendor的弊端

当我们有一个项目A,它同时依赖了两个包pkg_B和pkg_C,而pkg_B和pkg_C又分别依赖了pkg_D的两个不同版本pkg_D_v1和pkg_D_v2,其中pkg_D_v1和pkg_D_v2版本存在不兼容的问题。此时使用Vendor的模式,就不好控制版本的选择问题,即我们一旦更新了项目,有可能会出现依赖冲突导致编译错误。Vendor出现此问题的根本原因在于它还是依赖了项目的源码,无法很清晰地标识版本的概念。

2.1.3 Go Module

为了解决上述问题,Go Moudle应运而生。其解决了之前的依赖管理系统无法管理依赖库的多个版本的问题。

Go Moudlego1.11引入,从go1.16默认开启。

  • 通过go.mod文件管理依赖包版本
  • 通过go get/go mod指令工具管理依赖包
  • 终极目标:定义版本规则和管理项目依赖关系

2.2 依赖管理三要素

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

2.2.1 依赖配置--go.mod

go.mod文件主要由三部分组成:

  • 1,依赖管理基本单元

    模块路径,主要标识了一个模块。可以在某个路径下找到这个模块

    如果项目很复杂,引用的包比较多,而我们想每个包的依赖文件都可以被引用的话,那么需要在每个包下都创建一个go.mod文件。

  • 2,原生库

标识go的版本号

  • 3,单元依赖

由两部分组成:mod path + 版本号

2.2.2 依赖配置--version

GOPATHgo vendor其实是源码副本的方式进行的依赖,没有版本规则的概念,go module为了更好的管理,定义了自己的一套版本规则。分为两种类型:

  • 语义化版本

主要包括三个部分:

  • ${MAJOR} :是一个大版本,不同的MAJOR版本是不兼容的。

  • ${MINOR} :通常做一些新增函数或功能,需要保持兼容。

  • ${PATCH} :一般做一些代码BUG的修复。

  • 例:V1.3.0 V2.3.0

  • 基于commit的伪版本

主要由三部分组成:

  • 版本前缀 :和语义化版本规则含义相同

  • 时间戳 :提交commit的时间戳

  • 哈希前缀:提交commit的12位哈希码的前缀

每次提交代码时go都会生成一个伪版本号。

2.2.3 依赖配置--indirect

假设项目A依赖B,B依赖C。则A对B是直接依赖,A对C是间接依赖。对于非直接依赖,会使用indirect标注出来

2.2.4 依赖配置--incompatible

go mod的版本规则中,MAJOR版本认为主版本是v2以上,其模块路径都需要添加/vN后缀。其允许不同的版本规则间不相互兼容。对于没有go.mod文件并且主版本2+的依赖,会+imcompatible

2.2.5 依赖配置--版本图

选择满足项目构建的最低兼容版本

2.2.6 依赖配置--回源

image.png

2.2.7 依赖分发--Proxy

image.png

----没有任何问题是一层Proxy解决不了的,如果有,那就两层Proxy

2.2.8 依赖分发--变量 GOPROXY

go.mod是通过控制GOPROXY环境变量来控制PROXY的配置的,GOPROXY是一个url列表,使用逗号分隔,最后添加一个direct,表示如果前面的站点都没有依赖的话会回源到代码平台上。

image.png

表示优先从Proxy 1中寻找依赖,如果不存在则会下放到Proxy 2中寻找,若仍不存在,则到第三方源站寻找依赖。

2.2.9 工具--go get

image.png

image.png

做项目时提交代码之前都先执行一遍go tidy的指令。

2.2.10 依赖管理三要素

  • 1,配置文件,描述依赖 go.mod

  • 2,中心仓库管理依赖库Proxy

  • 3,本地工具go get/mod

2.3 小结

  • Go 依赖管理演进
  • Go Moudle 依赖管理方案

3,单元测试

测试就是生命!

3.1 测试的三种类型

  • 1,回归测试

质量保证的同学手动通过终端回归一些固定的主流场景,比如刷一下抖音,看一下抖音的评论等。

  • 2,集成测试

对系统功能维度做一些自动化的测试验证,通过服务暴露的某个缺口进行测试。会集成一个功能维度。

  • 3,单元测试

主要是面对测试开发阶段,开发者对特定的函数功能模块进行开发验证。

以上三个阶段的测试从上到下,覆盖率逐层变大,成本逐层降低。故单元测试的覆盖率一定程度上决定了代码的质量。

3.1.1 单元测试

  • 单元测试主要包含啥?

1,输入, 2,测试单元(接口,函数模块,等等), 3,输出, 4,校对。

image.png

  • 单元测试的好处:

1,保证质量。在代码覆盖率足够的情况下,编写单元测试可以一方面保证新功能本身的正确性和新代码没有破坏原有功能的正确性。

2,在代码出现BUG时,通过编写单元测试可以在一个较短的周期内发现问题并修复问题。

3.1.2 单元测试-规则

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

  • func TestXxx(*testing.T)

  • 初始化逻辑放到TestMain中。

单元测试样例:

func HelloTom() string {
    return "Jerry"
}

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

上述代码我们希望输出Tom,但是由于代码逻辑错误,我们运行输出了Jerry,也就是说我们的测试结果是fail的。

有了问题,在进行定位后我们就可以修改上述函数,再次进行比较输出。上面的代码中我们采用了!=运算符进行比较,实际上有很多开源的assert包可以给我们提供一些很方便的比较函数,用来判断两个值是否相等。

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

func HelloTom() string {
    return "Tom"
}

func TestHelloTom(t *testing.T) {
    output := HelloTom()
    expectOutput := "Tom"
    assert.Equal(t, exptctOutput, output)
    }  
}

运行代码,我们得到了PASS的结果,说明我们的代码是正确的。

3.1.3 单元测试-覆盖率

现在我们通过了一个测试,那么问题来了,

1,如何衡量代码是否经过了足够的测试?

2,如何评价项目的测试水准?

3,如何评估项目是否达到了高水准测试等级?

评估单元测试的一个重要指标是:代码覆盖率

如何计算代码覆盖率呢?我们先看一段代码:

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

func TestJudegPassLineTrue(t *testing.T) {
    isPass := JudegPassLine(70)
    assert.Equal(t, true, isPass)
} 

经过运行,上述单元测试的代码覆盖率是66.7%,这个值是如何得出的呢?

因为我们传入的值是70,也就是函数JudgePassLine的前两行被成功执行并返回。而return false这一行并没有被执行验证。所以我们的代码覆盖率为2/3 = 66.7%

为了使代码覆盖率达到100%,我们只需要再加入一个测试用例即可。

func TestJudegPassLineTrue(t *testing.T) {
    isPass := JudegPassLine(50)
    assert.Equal(t, false, isPass)
} 

加入上面的代码后,return false这一行也会被执行,从而代码覆盖率会达到100%

然而,在实际开发生产中,代码覆盖率达到100%可以说是一个可望不可及的目标,保险起见,我们的单元测试至少要满足以下条件:

  • 1,一般覆盖率:50% ~ 60%,较高覆盖率80%+。

  • 2,测试分支相互独立,全面覆盖。

  • 3,测试单元粒度足够小,函数单一职责。

3.2 单元测试-依赖

一般来说,一些复杂项目会依赖一些本地文件,数据库和Cache等,单元测试主要有两个目标:

1,幂等:即每次重复运行一个测试的时候得到的结果和之前是一样的。

2,稳定性:指单元测试是能够相互隔离的。也就是可以在任何时间,任何的函数运行。

由于我们在开发过程中会对很多资源进行依赖,此时如果直接编写一个单元测试,那么它的测试肯定是不稳定的。需要在单元测试的过程中用到一个Mock机制。

首先来看一个例子,我们依赖本地的文件资源编写了一段代码,修改了文件的内容。然而如果文件被篡改或者删除,我们的测试程序将无法运行。此时就用到了Mock机制。

Mock的包有很多,这里选择了mokey

Monkey是一个开源的Mock包,可以实现对实例的方法的一个mock,也就是打桩。 打桩可以理解为,我们用一个函数A替换另一个函数B,B就是原函数,那么A就是一个打桩函数。

以为函数打桩为例,其方法有两个,分别是PatchUnpatch

//target表示我们的原函数,也就是被替换的函数;
//replacement表示我们的打桩函数
func Patch(target, replacement interface{}) *PathGuard {
    t := reflect.ValueOf(target)
    r := reflect.ValueOf(replacement)
    patchValue(t, r)
    
    return &PathGuard(t, r)
}

//为了保证我们在打桩结束后把桩卸载掉
func Unpath(target interface{}) bool {
    return unpathValue(reflect.ValueOf(target))
}

关于monkey的实现,主要是在函数运行时,通过Go的Unsave包将函数在内存中的地址替换成打桩函数的地址,这样我们实际调用的是一个打桩函数,这样就实现了Mock的功能。

这样,通过Mock就可以解除对文件资源环境的强依赖。

快速Mock函数:

  • 为一个函数打桩

  • 为一个方法打桩

3.3 基准测试

Go语言还提供了基准测试的框架,所谓基准测试就是指测试一段程序在运行时的性能和CPU的损耗。在实际项目开发中会经常遇到一些热点代码或者代码的性能瓶颈问题,为了优化代码,通常会对代码进行基准测试。

基准测试的规则和单元测试是一致的,我们来看一个例子:

import(
    "math/rand"
)

var SeverIndex [10]int

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

func Select() int {
    return ServerIndex[rand.Intn(10)]
}

我们对Select函数进行一个基准测试。

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(pb *testing.PB)){
        for pb.Next() {
            Select()
        }
    })
    
}

由于Select函数里使用了随机数函数,需要用到全局锁,因此在基准测试时我们的并行性能打了折扣。

我们可以使用库函数fastrand()函数提高函数的性能。

func FastSelect() int {
    return ServerIndex[fastrand.Intn(10)]
}

fastrand()牺牲了部分随机数列的一致性,但是实际开发中影响不大。反而性能提升明显。

3.4 小结

  • 单元测试

  • Mock测试

  • 基准测试

4,项目实战

4.1 需求描述

社区话题页面

  • 1,展示话题(标题,文字描述)和回帖列表

  • 2,暂不考虑前端页面实现,仅仅实现一个本地web服务

  • 3,话题和回帖数据用文件存储

4.2 用例分析

  • 浏览消费用户

太困了,明天写。

4.3 测试运行