本文主要讲解:go语言进阶操作、依赖管理的演进过程
01语言进阶
从并发编程的视角带大家了解高性能的本质
并发Vs并行
1.1 Goroutine
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)
1.3 Channel
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
在前面的代码中,我们等待子协程执行都是用的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语言依赖管理的演进路线
背景
实际开发中,通常更关注业务逻辑的实现,常使用被封装好、经过验证的开发组件或工具提升开发效率如框架、日志等。
Go依赖管理演进
GOPATH
本地有两个项目A和B
A 依赖于PKG的V1版本 V1 有func A()
B依赖于PKG的V2版本 V2 有func B()
A项目和B项目由于依赖于同一个GoPath所以无法同时构建成功
即GOPATH无法实现包的多版本控制
Vendor
由于vendor这种解决方式,每个项目都有自己独立的环境此时A项目和B项目都能构建成功了
但是如果出现项目A依赖的不同包进一步依赖于像相同包的不同版本,依然可能构建失败
即在一个项目中vendor无法解决依赖于同一个包的不同版本的问题
Go Module
依赖管理三要素
1.配置文件,依赖描述 go.mod
go.mod
- 首先模块路径用来标识一个模块,从模块路径可以看出从哪里找到该模块,如果是github前缀则表示可以从Github 仓库找到该模块,依赖包的源代码由github托管,如果项目的子包想被单独引用,则需要通过单独的init go.mod文件进行管理。
- 下面是依赖的原生sdk版本
- 最下面是单元依赖,每个依赖单元用模块路径+版本来唯一标示。
version
- 不同的MAJOR一般代码可以不用兼容,同一个MAJOR下面的不同MINOR一般是兼容的。PATCH一般表示:对一些bug修复的版本。
- 基于commit则是commit的时间戳和12位的哈希前缀校验码,每次commit就默认生成一个版本号
特殊标识符
indirect
- 首先是indirect后缀,表示go.mod对应的当前模块,没有直接导入该依赖模块的包,也就是非直接依赖,标示间接依赖。
- indirect:非直接依赖
incompatible
-
下一个常见是的是incompatible,主版本2+模块会在模块路径增加/vN后缀,这能让go module按照不同的模块来处理同一个项目不同主版本的依赖。如下:
example/lib5 v2.2.2 example/lib5/v3 v3.0.2 -
由于gomodule是1.11实验性引入所以这项规则提出之前已经有一些仓库打上了2或者更高版本的tag了,为了兼容这部分仓库,对于没有go.mod文件并且主版本在2或者以上的依赖,会在版本号后加上+incompatible 后缀。例如上面的lib6
-
前面讲语义化版本提到,对于同一个库的不同的major版本,需要建立不同的pkg目录,用不同的go.mod文件管理,如下面仓库为例,V1版本go.mod在主目录下,而对于V2版本,则单独建立了V2目录,用另一个go.mod文件管理依赖路径,来表明不同major的不兼容性。
-
那对于有些V2+tag版本的依赖包并未遵循这一定义规则,就会打上incompatible 标志。
依赖图
v1.3和v1.4同属一个major在版本上是兼容的 构建项目时go module的算法会选择最低的兼容版本即1.4
2.中心仓库管理依赖库 Proxy
下面讲一下gomodule的依赖分发。也就是从哪里下载,如何下载的问题~
github是比较常见给的代码托管系统平台,而Go Modules 系统中定义的依赖,最终可以对应到多版本代码管理系统中某一项目的特定提交或版本,这样的话,对于go.mod中定义的依赖,则直接可以从对应仓库中下载指定软件依赖,从而完成依赖分发。
但直接使用版本管理仓库下载依赖,存在多个问题:
- 首先无法保证构建确定性:软件作者可以直接代码平台增加/修改/删除 软件版本,导致下次构建使用另外版本的依赖,或者找不到依赖版本。
- 无法保证依赖可用性:依赖软件作者可以直接代码平台删除软件,导致依赖不可用;
- 大幅增加第三方代码托管平台 压力。
而go proxy就是解决这些问题的方案,Go Proxy 是一个服务站点,它会缓源站中的软件内容,缓存的软件版本不会改变,并且在源站软件删除之后依然可用,从而实现了供“immutability”和“available”的依赖分发;使用 Go Proxy 之后,构建时会直接从 Go Proxy 站点拉取依赖。类比项目中,遇见解决不了的问题是可以加一层结构。
依赖优先从proxy1中查找,proxy1中没有就去proxy2中查找,proxy中都找不到再去源站中查找。
3. 本地工具go get/mod
go get
go mod
总结
依赖管理的三要素:
- 配置管理,描述依赖 go.mod
- 中心仓库管理依赖库 Proxy
- 本地工具go get/mod
03 测试
从单元测试实践出发,提升大家的质量意识。
质量就是生命
测试可极大避免事故发生,是避免事故的最后一道屏障。
只要做好完备的测试就能避免事故的发生。
测试一般分为:
回归测试一般是QA(质量保证)同学手动通过终端回归一些固定的主流程场景,例如刷一下抖音看视频推荐和评论列表ok不ok。
集成测试是对系统功能维度做测试验证。理解:是多个单元的组合进行测试。
而单元测试测试开发阶段,开发者对单独的函数、模块做功能验证。
层级从上至下,测试成本逐渐减低,而测试覆盖率确逐步上升。
所以单元测试的覆盖率一定程度上决定这代码的质量。
单元测试
单元测试主要包括,输入,测试单元,输出,以及校对。
单元的概念比较广,包括接口,函数,模块等;用最后的校对来保证代码的功能与我们的预期相符;
单侧一方面可以保证质量,在整体覆盖率足够的情况下,一定程度上既保证了新功能本身的正确性,又未破坏原有代码的正确性(以前的代码,也有对应的单元测试)。
另一方面可以提升效率,在代码有bug的情况下,通过编写单测,可以在一个较短周期内定位和修复问题。问题越往后发现,修复的代价就越大。
规则
- 所有的测试文件都以_test.go结尾
- 函数格式func TestXxx(*testing.T)
- 初始化逻辑放到TestMain中
例子
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` 类型的指针,我们可以编写丰富和详细的测试用例,并获得测试的运行结果和详细信息。测试框架会根据断言和错误报告来判断测试是否通过,并在测试过程中记录日志和输出。
结果;
使用go test [flags][packages] 执行
结果:
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)
}
结果:
改变一下返回值:重新测试
覆盖率
代码覆盖率越高,对代码的结果的正确性就更有保证
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
在实际项目中,一般的要求是50%~60%覆盖率。 而对于资金型服务,覆盖率可能要求达到80%及以上。
一些最佳实践:
- 测试分支相互独立、全面覆盖(基于分支测试)
- 开发保证函数的单一职责,函数体足够小
依赖
工程中复杂的项目,一般会依赖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初步理解为副本、复制
这里我们用了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,这样整个测试函数就摆脱了本地文件的束缚和依赖。
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)
}
测试结果:
基准测试
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()
}
})
}
结果:
优化
将rand包改为fastrand包
而公司为了解决这一随机性能问题,开源了一个高性能随机数方法fastrand,下面有开源地址;我们这边再做一下基准测试,性能提升了百倍。主要的思路是牺牲了一定的数列一致性,在大多数场景是适用的,同学在后面遇到随机的场景可以尝试用一下。
go get github.com/Moonlight-Zhao/go-project-example/benchmark
这个包目前可能不在了。
04项目实战
通过项目需求、需求拆解、逻辑设计、代码实现带领大家感受真实的项目开发
大家应该都是从掘金的 社区话题入口报名的,都看到过这个页面,页面的功能包括话题详情,回帖列表,支持回帖,点赞,和回帖回复,我们今天就以此为需求模型,开发一个该页面交涉及的服务端小功能。
需求设计
- 话题
- 回帖的列表
整体分为三层,repository数据层,service逻辑层,controoler视图层, 数据层关联底层数据模型,也就是这里的model,封装外部数据的增删改查,我们的数据存储在本地文件,通过文件操作拉取话题,帖子数据;数据层面向逻辑层,对service层透明,屏蔽下游数据差异,也就是不管下游是文件,还是数据库,还是微服务等,对service层的接口模型是不变的。 Servcie逻辑层处理核心业务逻辑,计算打包业务实体entiy,对应我们的需求,就是话题页面,包括话题和回帖列表,并上送给视图层; Controller视图层负责处理和外部的交互逻辑,以view视图的形式返回给客户端,对于我们需求,我们封装json格式化的请求结果,api形式访问就好。
下面介绍下开发涉及的基础组件和工具,首先是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
- 需要实现通过话题id查询到topic
- 通过topic查询到回帖列表
好的,一方面查询我们可以用全扫描遍历的方式,但是这虽然能达到我们的目的,但是并非高效的方式,所以这里引出索引的概念,索引就像书的目录,可以引导我们快速查找定位我们需要的结果;这里我们用map实现内存索引,在服务对外暴露前,利用文件元数据初始化全局内存索引,这样就可以实现0(1)的时间复杂度查找操作。