Go工程实践,并发编程,依赖管理,单元测试 | 青训营笔记

27 阅读6分钟

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

本文内容从工厂实践的角度,分析实际开发所遇到的难题

GO语言工程实践

并发编程

并发vs并行

通过上篇文章了解到,go的多线程性能十分优秀,go可以充分发挥多核优势高效运行。 image.png 并发是多个线程在一个核上的cpu上运行,多线程是多个线程在多个核得到cpu上并发执行。go语言主要优化了线程的调度模型。通过高效调度更好的利用调度资源。

image.png go的高并发里有一个重要概念协程

  • 协程:用户态、轻量级线程、栈MB级别。
  • 线程:内核态、线程可以跑多个协程、栈kb级别。

协程的使用可以减少对线程的反复创建和使用。从而减少了资源消耗。

go使用协程案例

func HelloGoRoutine(){
    for i:=0;i<5;i++{
    
        go func(j int){
            println(j)
        }(i)
    
    }
    time.Sleep(time.Second)
}

go通过 go关键词来实现协程,使用go关键词后会异步执行go后面的函数,为了防止在创建完成协程后函数瞬间执行结束,从而导致无法正常的打印结果,所以我们需要让主线程睡眠一秒后再结束。

打印结果为:4、1、0、2、3 可以看出执行结果是并发的不是按照传入顺序执行的。

协程之间的通信

image.png go提倡使用通信实现共享内存,而不是共享内存实现通信。go协程之间的通信使用的是channel进行的,虽然go也可以使用共享内存实现通信但是这种方式在一定程度上影响性能。

channel

image.png channel的创建方式使用,make(chan 元素类型,[缓冲大小])

  • 无缓冲管道 make(chan int)
  • 有缓冲管道 make(chan int,2)

channel通信案例

func CalSquare(){
   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 {
      println(i)
   }
}

go语言通过<-->来实现对通道的输入输出,一个生产者消费者案例,案例中协程A负责发送数组到通道src,协程B负责计算输入数字的平方。然后输入结果到dest管道中,最后打印出结果。defer是对资源的关闭。

并发安全Lock

通过锁与无锁实现多线程累加

func Add(){
   x=0
   for i := 0; i < 5; i++ {
      go addWithLock()
   }
   time.Sleep(time.Second)
   println("addWithLock",x)
   for i := 0; i < 5; i++ {
      go addWithoutLock()
   }
   time.Sleep(time.Second)
   println("addWithOutLock",x)
}

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
   }
}

输出结果:

addWithLock 10000

addWithOutLock 19126

可以明显的看出,在多线程竞争的情况下导致计算结果错误。而加锁强制进行单线程执行不会出现多线程错误。

waitGroup案例

在等待协程的使用我们使用的sleep来实现,可以明显看出这样并不优雅,我们并不知道子协程何时结束,通过等待指定时间显然是不合理的。

func GoWait(){
   waitGroup := sync.WaitGroup{}
   waitGroup.Add(5)
   for i := 0; i < 5; i++ {
      go func() {
         defer waitGroup.Done()
         time.Sleep(time.Second)
      }()
   }
   waitGroup.Wait()
   println("123")
}

image.png 在go中我们使用WaitGroup来实现同步。开启时计数器+1执行结束-1,当计数器为0时就结束运行。使用wait方法函数就会进入等待状态,当计数器为0后会继续往下执行。


依赖管理

在项目的开发过程中,我们会用到各种依赖包,对包的管理也是十分重要的一个过程,这关系到我们项目的构建。

工程类项目不可能基于标准库从0进行编码搭建,所以我们需要学会对依赖库的管理。

go的依赖管理的演进:GOPATH -> GO Vendor -> GO Module

image.png GOPATH实现原理是所有依赖都在src下,通过go get下载最新的源码到src目录下。

这样使用也会存在弊端。

image.png 在图中的场景中,A和B依赖于某个包的不同版本。无法实现包的多版本控制。

image.png 项目目录下增加vendor文件,所有依赖包副本形式放在vendor文件夹下,依赖寻找方式:vendor=>GOPATH。每个项目引入一个依赖副本,解决了多个项目需要同一个package依赖冲突问题。

这种依赖方式仍然存在问题。

image.png 项目A依赖项目B和项目C,但是项目B和C分别依赖D项目的不同版本。对于这种情况我们不能控制依赖的版本。更新项目可能出现依赖冲突,导致编译出错。vendor实际上还是直接依赖于项目的源码,不能很好的处理依赖的版本问题。

最佳的处理方式使用 go module

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

从而实现定义版本规则和管理项目依赖关系。

依赖管理的三要素:

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

go.mod文件

module juejin #依赖管理基本单元

go 1.19 #原生库

require (  #单元依赖
   github.com/gin-gonic/gin v1.8.2
   gorm.io/driver/mysql v1.4.5
   gorm.io/gorm v1.24.3
)

依赖标识:项目路径/版本号

语义化版本:v1.0

基于commit伪版本:v1.0-230203aacd0d0

后面带有indirect的是非直接依赖。通过@可以指定版本。

go get后面可以跟:

命令作用
@update默认
@none删除依赖
@v1.2tag版本
@23ff5..commit版本
@master分支最新的commit

go mod 命令:

命令作用
init初始化创建go.mod文件
download下载模块到本地缓存
tidy增加需要的依赖,删除不需要的依赖

单元测试

单元测试在整个开发中占有相当重要的地位

graph LR
开发 --->B(测试)--避免--> 事故

测试是避免事故的最后一道屏障。

测试分为:

image.png 从上到下覆盖率逐层变大,成本却逐层降低。

单元测试的流程:

image.png 进行输入输出,然后对输出和期望值的校对。

单元测试

go的单元测试要遵循以下规则:

  • 所有测试文件以_test.go结尾
  • func TestXxx(*testing.T)
  • 初始化逻辑放到TestMain中

单元测试案例:

func GetTom()string{
   return "Jerry"
}

func TestGetTom(t *testing.T) {
   tom := GetTom()
   expect:="Tom"
   if tom!=expect {
      t.Errorf("error match")
   }
}

对期望结果进行执行来查看期望结果和最终结果是否一致。

单元测试-assert

func TestGetTom(t *testing.T) {
   output:=GetTom()
   expectOutput:="Tom"
   assert.Equal(t,expectOutput,output)
}

单元测试-覆盖率

评估代码的标准,是否经过了足够的测试。如何评价项目的测试水准?如何评估项目是否达到了高水准测试等级?

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

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

执行覆盖率测试 go test judgment_test.go judgment.go --cover 得到测试结果。测试结果为66.7%,因为测试共三行代码成功执行两行代码,得到的覆盖率就是66.7%。

  • 一般覆盖率:50-60%,较高覆盖率80%。
  • 测试分支相互独立,全面覆盖
  • 测试单元粒度足够小,函数单一职责

单元测试-mock

image.png 单元测试包:>monkey

对实例的方法进行打桩。用函数A去替换函数B,A是打桩函数B是原函数。在实际测试的时候实际上使用的是打桩函数。函数返回的内容是打桩函数指定的内容。

mock示例。

func TestProcessFirstLineWithMock(t *testing.T){

    monkey.Patch(ReadFirstLine,func() string{
        return "line110"
    })
    defer monkey.Unpatch(ReadFirstline)
    line:=ProcessFirstLine()
    assert.Equal(t,"line000",line)
}

基准测试

基准测试主要进行代码的性能测试。和对资源的损耗。

示例:

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

测试代码

func BenchmarkSelect(b *testing.B){
    b.ResetTimer()
    for i:=0;i<b.N;i++{
        Select()
    }
}

//并行测试
func BenchmarkSelectParallel(b *testing.B){
    b.ResetTimer()
    b.RunParallel(func(pb *testing.PB){
        for pb.Next(){
            Select()
        }
    })
}

测试结果:

BenchmarkSelect ----------18 ns/op

BenchmarkSelectParallel --79 ns/op

并行反而慢的原因是rand函数为了随机性和安全性在生成随机数时会进行加锁操作,会影响生成速度。并行进行串行化会影响性能。 为了解决这种情况,使用字节开源的fastrand得到结果的速度就会大幅提升。

项目实践

image.png 开发一个包括话题详情,回帖列表,支持回帖,点赞,和回帖回复,我们今天就以此为需求模型,开发一个该页面交涉及的服务端小功能。

社区话题页面-需求描述

  • 展示话题和回帖
  • 暂不考虑前端页面实现,仅仅实现一个本地web服务器
  • 话题和回帖数据用文件存储

image.png 功能涉及用户浏览,包括话题内容和回帖的列表。

image.png 这时实体类的er图,有了模型实体,属性之间的联系,对我们后续开发就提供更加清晰的思路。

image.png 分层结构分析,整体分为三层,repository数据层,service层,controller层。

  • 数据层:数据Model,外部数据的增删改查
  • 逻辑层:业务Entity,处理核心业务逻辑输出
  • 视图层:视图view,处理和外部的交互逻辑

开发使用Go的Gin框架

image.png 我们需要通过话题id查询到话题,通过话题id查询到帖子详细信息。

项目初始化topicOnce sysnc.OnceDo方法可以确保只会执行一次,可以用在单例模式中。这和java中实现的双重验证的单例模式是不一样的。使用单例模式可以提高系统性能。

整体设计:

graph LR
A( 读取文件 )-->B( 映射数据到Map )
B--存放数据-->E
E(map)
C(Get请求)-->D(Gin的service)--取数据-->E

对于Gin框架会在我的下一篇文章进行详细介绍。