(二)Go语言进阶和依赖管理 | 青训营

87 阅读15分钟

本文主要讲解:go语言进阶操作、依赖管理的演进过程

01语言进阶

从并发编程的视角带大家了解高性能的本质

并发Vs并行

image-20230728235841674.png

1.1 Goroutine

image-20230728235915000.png

go是基于协程的。goroutine使用简单,只需在调用函数(普通或匿名函数)前加go关键字。

例子:快速打印 hello goroutine 0 ~ hello gorotine 4

package main

import (
	"fmt"
	"time"
)

func hello(i int) {
	fmt.Println("hello goroutine ", i)
}
func main() {
	for i := 0; i < 5; i++ {
		go hello(i)
	}
	time.Sleep(time.Second)//睡眠 等待子协程执行结束
}

结果:

hello goroutine  4
hello goroutine  0
hello goroutine  3
hello goroutine  2
hello goroutine  1

1.2 CSP(Communicating Sequential Process)

image-20230729102617284.png

1.3 Channel

image-20230729102914503.png

image-20230729102927498.png

package main

import "fmt"

// A 子协程发送0-9数字
// B 字协程计算输入数字的平方
// 主协程最后输出最后的平方数
func main() {
	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 {
		fmt.Println(i)
	}
}

1.4并发安全Lock

对变量执行2000次+1操作,五个协程并发执行

package main

import (
	"fmt"
	"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 main() {
	x = 0
	for i := 0; i < 5; i++ {
		go addWithoutLock()
	}
	time.Sleep(time.Second) //阻塞主协程 等待子协程执行完毕
	fmt.Println("addWithoutLock: ", x)

	x = 0
	for i := 0; i < 5; i++ {
		go addWithLock()
	}
	time.Sleep(time.Second) //阻塞主协程 等待子协程执行完毕
	fmt.Println("addWitLock: ", x)
}

1.5WaitGroup

image-20230728160208454.png

在前面的代码中,我们等待子协程执行都是用的time.Sleep()函数来阻塞main线程这样是很不优雅

package main

import (
	"fmt"
	"sync"
)

func hello(i int) {
	fmt.Println("hello goroutine: ", i)
}
func main() {
	var wg sync.WaitGroup
	wg.Add(5)
	for i := 0; i < 5; i++ {
		go func(j int) {
			//函数执行完成之后 会主席那个defer定义的
			defer wg.Done() //wg的delta-1
			hello(j)
		}(i)
	}
	wg.Wait() //当wg的delta=0 时停止阻塞
	//time.Sleep(time.Second)//使用waitGroup来代替
}

WaitGroup和Mutex都是在sync包下面的

总结

好的,以上就是对go并发编程相关概念的介绍,这里简单做个小结,整个章节主要涉及3个方面:

  • 一个是协程,通过高效的调度模型实现高并发操作
  • 一个是通道channel,通过通信实现共享内存
  • 最后sync相关关键字,实现并发安全操作和协程间的同步。

02 依赖管理

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

背景

image-20230728223115467.png

实际开发中,通常更关注业务逻辑的实现,常使用被封装好、经过验证的开发组件或工具提升开发效率如框架、日志等。

Go依赖管理演进

image-20230728223155583.png

GOPATH

image-20230728223257299.png

image-20230728223424673.png

本地有两个项目A和B

A 依赖于PKG的V1版本 V1 有func A()

B依赖于PKG的V2版本 V2 有func B()

A项目和B项目由于依赖于同一个GoPath所以无法同时构建成功

即GOPATH无法实现包的多版本控制

Vendor

image-20230728223745870.png

由于vendor这种解决方式,每个项目都有自己独立的环境此时A项目和B项目都能构建成功了

image-20230728223908760.png

但是如果出现项目A依赖的不同包进一步依赖于像相同包的不同版本,依然可能构建失败

即在一个项目中vendor无法解决依赖于同一个包的不同版本的问题

Go Module

image-20230728224315040.png

依赖管理三要素

image-20230728224436932.png

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

go.mod

image-20230728224457930.png转存失败,建议直接上传图片文件

  1. 首先模块路径用来标识一个模块,从模块路径可以看出从哪里找到该模块,如果是github前缀则表示可以从Github 仓库找到该模块,依赖包的源代码由github托管,如果项目的子包想被单独引用,则需要通过单独的init go.mod文件进行管理。
  2. 下面是依赖的原生sdk版本
  3. 最下面是单元依赖,每个依赖单元用模块路径+版本来唯一标示。

version

image-20230728224632955.png

  1. 不同的MAJOR一般代码可以不用兼容,同一个MAJOR下面的不同MINOR一般是兼容的。PATCH一般表示:对一些bug修复的版本。
  2. 基于commit则是commit的时间戳和12位的哈希前缀校验码,每次commit就默认生成一个版本号

特殊标识符

indirect
  1. 首先是indirect后缀,表示go.mod对应的当前模块,没有直接导入该依赖模块的包,也就是非直接依赖,标示间接依赖。
  2. indirect:非直接依赖
incompatible
  1. 下一个常见是的是incompatible,主版本2+模块会在模块路径增加/vN后缀,这能让go module按照不同的模块来处理同一个项目不同主版本的依赖。如下:

    example/lib5 v2.2.2
    example/lib5/v3 v3.0.2
    
  2. 由于gomodule是1.11实验性引入所以这项规则提出之前已经有一些仓库打上了2或者更高版本的tag了,为了兼容这部分仓库,对于没有go.mod文件并且主版本在2或者以上的依赖,会在版本号后加上+incompatible 后缀。例如上面的lib6

  3. 前面讲语义化版本提到,对于同一个库的不同的major版本,需要建立不同的pkg目录,用不同的go.mod文件管理,如下面仓库为例,V1版本go.mod在主目录下,而对于V2版本,则单独建立了V2目录,用另一个go.mod文件管理依赖路径,来表明不同major的不兼容性。

  4. 那对于有些V2+tag版本的依赖包并未遵循这一定义规则,就会打上incompatible 标志。

依赖图

image-20230728225532901.png

v1.3和v1.4同属一个major在版本上是兼容的 构建项目时go module的算法会选择最低的兼容版本即1.4

2.中心仓库管理依赖库 Proxy

image-20230728225734652.png

下面讲一下gomodule的依赖分发。也就是从哪里下载,如何下载的问题~

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

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

  1. 首先无法保证构建确定性:软件作者可以直接代码平台增加/修改/删除 软件版本,导致下次构建使用另外版本的依赖,或者找不到依赖版本。
  2. 无法保证依赖可用性:依赖软件作者可以直接代码平台删除软件,导致依赖不可用;
  3. 大幅增加第三方代码托管平台 压力。

image-20230728225907028.png

而go proxy就是解决这些问题的方案,Go Proxy 是一个服务站点,它会缓源站中的软件内容,缓存的软件版本不会改变,并且在源站软件删除之后依然可用,从而实现了供“immutability”和“available”的依赖分发;使用 Go Proxy 之后,构建时会直接从 Go Proxy 站点拉取依赖。类比项目中,遇见解决不了的问题是可以加一层结构。

image-20230728230613004.png

依赖优先从proxy1中查找,proxy1中没有就去proxy2中查找,proxy中都找不到再去源站中查找。

3. 本地工具go get/mod

go get

image-20230728230655289.png

go mod

image-20230728230737425.png

总结

依赖管理的三要素:

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

image-20230728230918139.png

image-20230728231036892.png

03 测试

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

质量就是生命

image-20230729111705577.png

测试可极大避免事故发生,是避免事故的最后一道屏障。

image-20230728234732959.png

只要做好完备的测试就能避免事故的发生。

测试一般分为:

回归测试一般是QA(质量保证)同学手动通过终端回归一些固定的主流程场景,例如刷一下抖音看视频推荐和评论列表ok不ok。

集成测试是对系统功能维度做测试验证。理解:是多个单元的组合进行测试。

而单元测试测试开发阶段,开发者对单独的函数、模块做功能验证。

层级从上至下,测试成本逐渐减低,而测试覆盖率确逐步上升。

所以单元测试的覆盖率一定程度上决定这代码的质量。

image-20230728231433578.png

单元测试

image-20230729112129461.png

单元测试主要包括,输入,测试单元,输出,以及校对。

单元的概念比较广,包括接口,函数,模块等;用最后的校对来保证代码的功能与我们的预期相符;

单侧一方面可以保证质量,在整体覆盖率足够的情况下,一定程度上既保证了新功能本身的正确性,又未破坏原有代码的正确性(以前的代码,也有对应的单元测试)。

另一方面可以提升效率,在代码有bug的情况下,通过编写单测,可以在一个较短周期内定位和修复问题。问题越往后发现,修复的代价就越大。

规则

  1. 所有的测试文件都以_test.go结尾
  2. 函数格式func TestXxx(*testing.T)
  3. 初始化逻辑放到TestMain中

image-20230729112711118.png

例子

image-20230729113542541.png

package main

import "testing"

func HelloTom() string {
	return "jerry"
}
func TestHelloTom(t *testing.T) {
	output := HelloTom()
	expectOutPut := "Tom" //预期
	if expectOutPut != output {
		t.Errorf("Expected %s not match actual %s!", expectOutPut, output)
	}
}
`*testing.T` 是 Go 语言中的测试框架 `testing` 包中的类型。它是一个指向 `testing.T` 类型的指针,用于表示测试用例的上下文和状态。

在 Go 语言中,我们可以使用 `testing.T` 类型来编写单元测试。测试函数必须具有以下签名:`func TestXxx(t *testing.T)`,其中 `Xxx` 可以是任何标识符,表示测试函数的名称。在测试函数中,我们可以使用 `*testing.T` 类型的指针 `t` 来进行断言、日志记录和错误报告等操作,以验证代码的正确性和性能。

`*testing.T` 提供了一系列方法,比如:

- `t.Errorf(format string, args ...interface{})`: 在测试中输出错误信息,标记测试失败。
- `t.Fatalf(format string, args ...interface{})`: 输出错误信息并中止当前测试函数的执行。
- `t.Log(args ...interface{})`: 输出日志信息,不标记测试失败。
- `t.Logf(format string, args ...interface{})`: 输出格式化的日志信息,不标记测试失败。

通过使用 `*testing.T` 类型的指针,我们可以编写丰富和详细的测试用例,并获得测试的运行结果和详细信息。测试框架会根据断言和错误报告来判断测试是否通过,并在测试过程中记录日志和输出。

结果; image-20230729113625175.png

使用go test [flags][packages] 执行

结果:image-20230729113912354.png

assert

获取对应的包

go get github.com/stretchr/testify

得到错误

go: module github.com/stretchr/testify: Get "https://proxy.golang.org/github.com/stretchr/testify/@v/list": dial tcp 142.251.43.17:443: connectex: A connection attempt failed because the connected party did not properly respond after a period of time, or established connection failed because connected host has failed to respond.

原因:和go的代理服务器建立连接失败

解决办法:设置环境变量 ,也可以使用图形界面设置环境变量

setx GOPROXY "https://goproxy.cn"

引入assert优化的代码

package main

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

func HelloTom() string {
	return "jerry"
}
func TestHelloTom(t *testing.T) {
	output := HelloTom()
	expectOutPut := "Tom" //预期
	assert.Equal(t, expectOutPut,output)
}

结果: image-20230729115705736.png

改变一下返回值:重新测试 image-20230729115800685.png

覆盖率

image-20230729115839496.png

代码覆盖率越高,对代码的结果的正确性就更有保证

​ go test 测试文件 被测试文件 --cover 计算被测试文件的代码测试覆盖率

PS E:\Go\ByteDance\GoLearn\5\UnitTesting> go test judge_test.go judge.go --cover 
ok      command-line-arguments  0.474s  coverage: 66.7% of statements

judge.go

package main

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

judge_test.go

package main

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


//此时代码还有一个分支没有被测试
func TestJudgePassLineTrue(t *testing.T) {
	isPass := JudgePassLine(70)
	assert.Equal(t, true,isPass)
}

分析:

被测代码一共三行,执行了两行所以最终结果是66.7%

judge_test.go:

package main

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


//此时代码还有一个分支没有被测试
func TestJudgePassLineTrue(t *testing.T) {
	isPass := JudgePassLine(70)
	assert.Equal(t, true,isPass)
}

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

PS E:\Go\ByteDance\GoLearn\5\UnitTesting> go test judge_test.go judge.go --cover
ok      command-line-arguments  0.456s  coverage: 100.0% of statements

Tips

image-20230729121624102.png

在实际项目中,一般的要求是50%~60%覆盖率。 而对于资金型服务,覆盖率可能要求达到80%及以上。

一些最佳实践:

  1. 测试分支相互独立、全面覆盖(基于分支测试)
  2. 开发保证函数的单一职责,函数体足够小

依赖

image-20230729122036960.png

工程中复杂的项目,一般会依赖DB、Cache等,而我们的单测需要保证稳定性和幂等性, 幂等是指每一次测试运行都应该产生与之前一样的结果。

稳定是指单元测试之间是相互隔离,能在任何时间,任何环境,独立运行测试。 A测试不会影响到B测试。

如果我们直接写多个测试去调用相同的File、DB、Cache, 那么他可能不是幂等的,而且不是稳定的。如果调用的是同一份资源那么A测试和B测试肯定不是隔离的。

使用Mock机制就可以解决上述问题。

下面举个栗子,将文件中的第一行字符串中的11替换成00,执行单测,测试通过,而我们的单测需要依赖本地的文件,如果文件被修改或者删除测试就会fail。为了保证测试case的稳定性,我们对读取文件函数进行mock,屏蔽对于文件的依赖。

log

line11
line22

fistline.go

package main

import (
	"bufio"
	"log"
	"os"
	"strings"
)

func ReadFirstLine() string {
	//返回一个指向文件的指针
	open, err := os.Open("log")
	defer open.Close()

	if err != nil{
		log.Fatal(err)
	}
	scanner := bufio.NewScanner(open)

	for scanner.Scan(){
		//读取一行
		return scanner.Text()
	}
	return ""
}

func ProcessFirstLine() string {
	line := ReadFirstLine()
	destline := strings.ReplaceAll(line,"11","00")
	return destline
}

firstline_test.go

package main

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

func TestProcessFirstLine(t *testing.T) {
	line := ProcessFirstLine()
	assert.Equal(t, "line00",line)
}

Mock测试

mock初步理解为副本、复制

image-20230729150742838.png

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

mock函数即用函数A去替换函数B 函数A是打桩函数 函数B是原函数。

monkey.go部分函数

// Patch replaces a function with another
func Patch(target, replacement interface{}) *PatchGuard {
	t := reflect.ValueOf(target)
	r := reflect.ValueOf(replacement)
	patchValue(t, r)

	return &PatchGuard{t, r}
}

// Unpatch removes any monkey patches on target
// returns whether target was patched in the first place
//打桩结束之后把桩卸载
func Unpatch(target interface{}) bool {
	return unpatchValue(reflect.ValueOf(target))
}

例子

下面是一个mock的使用样例,通过patch对Readfineline进行打桩mock,默认返回line110,这里通过defer卸载mock,这样整个测试函数就摆脱了本地文件的束缚和依赖。

image-20230729154638533.png

package mock

import (
	"GoLearn/5/unit_testing"
	"bou.ke/monkey"
	"github.com/stretchr/testify/assert"
	"testing"
)

func TestProcessFirstLineWithMock(t *testing.T) {
	//将ReadFirstLIne()函数替换成自定义函数 称为打桩
	//target has to be a Func
	monkey.Patch(unit_testing.ReadFirstLine, func() string{
		return "line110"
	})
	//将打的桩卸载
	defer monkey.Unpatch(unit_testing.ReadFirstLine)

	line := unit_testing.ProcessFirstLine()
	assert.Equal(t, "line000",line)
}

测试结果: image-20230729155041026.png

基准测试

image-20230729155118495.png Go 语言还提供了基准测试框架,基准测试是指测试一段程序的运行性能及耗费 CPU 的程度。而我们在实际项目开发中,经常会遇到代码性能瓶颈,为了定位问题经常要对代码做性能分析,这就用到了基准测试。使用方法类似于单元测试。

例子

package base_testing

import "math/rand"

var ServerIndex [10]int

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

// Select 负载均衡 随机选择一个服务器
func Select() int {
	return ServerIndex[rand.Intn(10)]
}

package base_testing

import "testing"

//Benchmark基准
func BenchmarkInitServerIndex(b *testing.B) {
	InitServerIndex()
	b.ResetTimer() //定时器重置
	for i := 0; i < b.N; i++ {
		//串行压力测试
		Select()
	}
}

func BenchmarkInitServerIndexParallel(b *testing.B) {
	InitServerIndex()
	b.ResetTimer()
	//为什么并发会更慢,因为rand有一个全局的并发锁
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			Select()
		}
	})
}

结果: image-20230729160308459.png

优化

将rand包改为fastrand包 image-20230729160611571.png

而公司为了解决这一随机性能问题,开源了一个高性能随机数方法fastrand,下面有开源地址;我们这边再做一下基准测试,性能提升了百倍。主要的思路是牺牲了一定的数列一致性,在大多数场景是适用的,同学在后面遇到随机的场景可以尝试用一下。

go get github.com/Moonlight-Zhao/go-project-example/benchmark

这个包目前可能不在了。

04项目实战

通过项目需求、需求拆解、逻辑设计、代码实现带领大家感受真实的项目开发

image-20230729161941704.png

大家应该都是从掘金的 社区话题入口报名的,都看到过这个页面,页面的功能包括话题详情,回帖列表,支持回帖,点赞,和回帖回复,我们今天就以此为需求模型,开发一个该页面交涉及的服务端小功能。

需求设计

image-20230729162025291.png

image-20230729162051896.png

  • 话题
  • 回帖的列表

image-20230729162136057.png

image-20230729162240295.png

整体分为三层,repository数据层,service逻辑层,controoler视图层, 数据层关联底层数据模型,也就是这里的model,封装外部数据的增删改查,我们的数据存储在本地文件,通过文件操作拉取话题,帖子数据;数据层面向逻辑层,对service层透明,屏蔽下游数据差异,也就是不管下游是文件,还是数据库,还是微服务等,对service层的接口模型是不变的。 Servcie逻辑层处理核心业务逻辑,计算打包业务实体entiy,对应我们的需求,就是话题页面,包括话题和回帖列表,并上送给视图层; Controller视图层负责处理和外部的交互逻辑,以view视图的形式返回给客户端,对于我们需求,我们封装json格式化的请求结果,api形式访问就好。

image-20230729162435359.png

下面介绍下开发涉及的基础组件和工具,首先是gin,高性能开源的go web框架,我们基于gin 搭建web服务器,在课程手册应该提到了,这里我们只是简单的使用,主要涉及路由分发,不会涉及其他复杂的概念。

因为我们引入了web框架,所以就涉及go module依赖管理,如前面依赖管理课程内容讲解,我们首先通过go mod是初始化go mod管理配置文件,然后go get下载gin依赖,这里显示用了V1.3.0版本。

有了框架依赖,我们只需要关注业务本身的实现,从reposity->service_->controller,我们一步步实现。希望大家能跟上我的节奏,从0~1 实现这个项目,如果时间问题,大家可以一步步copy一下,主要是走一半开发思路。

代码开发

创建一个新的文件夹拉取gin依赖

go get github.com/gin-gonic/gin  

创建data文件夹

创建文件post

{"id":1,"parent_id":1,"content":"小姐姐快来1","create_time":1650437616}
{"id":2,"parent_id":1,"content":"小姐姐快来2","create_time":1650437617}
{"id":3,"parent_id":1,"content":"小姐姐快来3","create_time":1650437618}
{"id":4,"parent_id":1,"content":"小姐姐快来4","create_time":1650437619}
{"id":5,"parent_id":1,"content":"小姐姐快来5","create_time":1650437620}
{"id":6,"parent_id":2,"content":"小哥哥快来1","create_time":1650437621}
{"id":7,"parent_id":2,"content":"小哥哥快来2","create_time":1650437622}
{"id":8,"parent_id":2,"content":"小哥哥快来3","create_time":1650437623}
{"id":9,"parent_id":2,"content":"小哥哥快来4","create_time":1650437624}
{"id":10,"parent_id":2,"content":"小哥哥快来5","create_time":1650437625}

创建文件topic

{"id":1,"title":"青训营来啦!","content":"小姐姐,快到碗里来~","create_time":1650437625}
{"id":2,"title":"青训营来啦!","content":"小哥哥,快到碗里来~","create_time":1650437640}

Repository

image-20230729163015122.png

  • 需要实现通过话题id查询到topic
  • 通过topic查询到回帖列表

image-20230729163137477.png

好的,一方面查询我们可以用全扫描遍历的方式,但是这虽然能达到我们的目的,但是并非高效的方式,所以这里引出索引的概念,索引就像书的目录,可以引导我们快速查找定位我们需要的结果;这里我们用map实现内存索引,在服务对外暴露前,利用文件元数据初始化全局内存索引,这样就可以实现0(1)的时间复杂度查找操作。

Service

Controller

测试运行