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

176 阅读3分钟

这是我参加青训营的第 2 天

工程实践

语言进阶

GoRoutine

在Golang语言机制中,实现高并发需要一个重要概念,协程(Goroutine)。

协程:属于用户态,轻量级线程,他的创建、完成由Golang语言本身去调动,比线程轻量很多。栈内存在KB级别

线程:比较昂贵的系统资源,属于内核态。他的创建,切换,停止都属于很重的系统操作,比较消耗资源。栈内存在MB级别。线程可以并发的跑多个协程

协程的使用(快速打印)

只需要在调用函数时,在函数的前面加一个go关键字,这就为一个函数创建了一个协程来运行

time.Sleep(time.Scond) :使用暴力的sleep方法来保证子线程执行完之前,主线程不退出

func hello(i int) {
    println("hello goroutine : " + fmt.Sprint(i))
}
​
func HelloGoRoutine() {
    for i := 0; i < 5; i++ {
        go func (j int) {   // 协程
            hello(j)
        }(i)
    }
    time.Sleep(time.Scond)
}

CSP

协程之间的通信,Golang提倡使用通信来共享内存,而不是使用共享内存来实现通讯。但是Golang本身也还保留了使用共享内存来实现通讯的机制。

通道(Channel)

左图是通过通讯来实现共享内存的示意图

通道就相当于将协程做了一个连接,传输队列。遵循先进先出,保证收发数据的顺序

通道实现的是从一个Goroutine发送数据到另一个Goroutine中

右图是通过共享内存实现通讯的示意图

image-20230116112126512

Channel

使用make创建缓冲通道,格式:make(chan 元素类型, [缓冲大小])

由因为缓冲大小,可以将缓冲通道分为无缓冲通道有缓冲通道

· 无缓冲通道 make(chan int)

无缓冲通道可以实现发送的Goroutine和接收的Goroutine同步化,所以又被称为同步通道

· 有缓冲通道 make(chan int, 2)

有缓冲通道是异步的,它不会保证顺序性,所以又被称为异步通道

image-20230116112625981

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)      // 结果为依次输出 0 ~ 9 的相乘
    }
}

并发安全Lock

当执行高并发程序时,我们可能在并发中失去准确性,所以要使用Lock来保证程序的安全性和完整性。

lock.Lock() 通过临界区控制实现加锁

lock.Unlock() 当操作执行结束之后,释放临界区资源,解除加锁

var (
    x int64
    lock sync.Mutex
)
​
func addWithLock() {
    for i := 0; i < 100; i++ {
        lock.Lock()
        x += 1
        lock.Unlock()
    }
}
​
func addWithoutLock() {
    for i := 0; i < 100; i++ {
        x += 1
    }
}

当上述程序调用并且执行完毕之后,加锁的函数会完整的返回所需要的结果,没有加锁的会出现失真

WaitGroup

Golang语言中,我们不知道子协程一个确切的结束时间,所以不能的设置睡眠时间,往前只能使用Sleep这种暴力方法实现主程序等待。

在学习WaitGroup之后,我们可以合理的使用WaitGroup来准确的控制主程序的等待时间。

使用WaitGroup来实现并发程序的同步

三个方法

· Add(delta int) 实现计数器+delta

· Done() 实现计数器-1

· Wait() 阻塞直到计数器为0

以往例子改进:

func hello(i int) {
    println("hello goroutine : " + fmt.Sprint(i))
}
​
func HelloGoRoutine() {
    var wg sync.WaitGroup
    wg.Add(5)   // 因为5个协程
    for i := 0; i < 5; i++ {
        go func (j int) {   // 协程
            defer wg.Done() // 子协程完成一个,计数器-1
            hello(j)
        }(i)
    }
    wg.Wait()
}

依赖管理

目前广泛应用的为 Go Module

GOPATH 弊端: 无法实现package的多版本控制

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

弊端: 无法控制依赖的版本、更新项目又可能出现依赖冲突,导致编译出错

Go Module

· 通过 go.mod 文件管理依赖包版本

· 通过 go get/go mod 指令工具管理依赖包

Go Module的最终目标:可以定义版本规则和管理项目依赖关系

依赖管理三要素

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

依赖配置

go.mod

version

GOPATH 和 Go Verdor都是源码副本进行的依赖,没有具体的版本规则。

Go Module 为了更好的做版本管理,定义了版本规则,主要分为语义化版本,基于commit伪版本

语义化版本:

MAJOR.{MAJOR}.{MINOR}.${PATCH}

MAJOR :属于大版本,不同的MAJOR版本可以不兼容

MINOR :做一些函数的时候,需要在MAJOR下做到前后兼容

PATCH :一般做一些代码版本的修复

基于 commit 伪版本

vX.0.0-yyyymmddhhmmss-abcdefgh12345

版本前缀——时间戳——哈希码前缀

incompatilble

· 主版本2+模块会在模块路径增加 /vN 后缀

· 对于没有go.mod文件并且主版本2+的依赖,会 +incompatilble

依赖分发

回源

· 无法保证构建稳定性

· 无法保证依赖可用性

· 增加第三方压力

Proxy

Proxy是一个服务站点,会缓存原栈中的软件内容,软件版本也不会改变。Proxy可以保证软件的一个稳定性可靠性

从Proxy直接拉去依赖

变量 GOPROXY

GOPROXY="proxy1.cn, proxy2.cn ,direct"

服务站点URL列表,“direct”表示源站

测试

单元测试

单元测试优势:保证质量提高效率

规则

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

· 测试定义**func TestXxx(testing.T)*

· 初始化逻辑放到 TestMain

m.Run() 代表跑package下的所有单元测试

例子

//...
func HelloTom() string {
    return "Jack"
}
​
func TestHelloTom(t *testing.T) {
    output := HelloTom()
    expectOutput := "Tom"
    if output != expectOutput {
        t.Errorf("Expected %s don't %s", expectOutput, output)
    }
    
}

assert

assert包中包含了EqualNotEqual等等,我们可以调用asset包来简化代码

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

然后将上述if判断语句可以改成:

assert.Equal(t, expectOutput, output)

代码覆盖率

可以通过代码覆盖率来判断代码功能是否完备,代码是否含有BUG

func JudgePassLine(score int16) bool {
    if score >= 60 {
        return true
    }
    return false
}
​
func TestJudgePassLineTrue(t *testing.T) {
    isPass := JudgePassLine(78)
    assert.Equal(t, true, isPass)
}

以上的执行代码,代码覆盖率可以达到66.7% ,因为第一个函数只执行了return true的分支

当再加一个单元测试来执行第二条分支的时候,代码覆盖率可以达到100%

· 一般覆盖率: 50% ~ 60%, 较高覆盖率80%+

· 测试分支相互独立、全面覆盖

· 测试单元粒度足够小,函数单一职责

依赖

单元测试强依赖File、DB、Cache等等

Mock

外部依赖可以通过Mock来实现,保证单元测试的稳定性

可以理解为:用函数A替换函数B,B是原函数,A是打桩函数

开源包: github.com/bouk/monkey

func Patch(target, replacement interface{}) *PatchGuard {
    t := refflect.ValueOf(target)
    r := reflect.ValueOf(replacement)
    patchValue(t, r)
    
    return &PatchGuard{t, r}
}
​
func Unpatch(target interface{}) bool {
    return unpatchValue(reflect.ValueOf(target))
}

targe是原函数

replacement是需要打桩的函数

Unpatch函数保证测试完成之后卸载掉桩

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

功能为寻找该文件中存不存在“line000“内容

完全不依赖本地的文件,可以在任何时候运行

基准测试

功能:测试一段程序运行时的性能和CPU的损耗

使用方法类似于单元测试

· *func BenchmarkXxx (b testing.B) {}

import (
    "math/rand"
)
​
var ServerIndex [10]intfunc InitServerIndex() {
    for i := 0; i < 10; i++ {
        ServerIndex[i] = i+100
    }
}
​
func Select() int {
    return ServerIndex[rand.Intn(10)]
}

对上述代码进行基准测试

// 串行实现基准测试
func BenchmakeSelect(b *testing.B) {
    InitServerIndex()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        Select()
    }
}
​
//并行实现基准测试
func BenchmakeSelectParallel(b *testing.B) {
    InitServerIndex()
    b.ResetTimer()
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            Select()
        }
    })
}

因为InitServerIndex() 不属于我们测试函数损耗,所以要将它的时间抛掉

b.ResetTimer() :对定时器重置

并行做基准测试时,性能会进行劣化。因为前面Select函数中使用了rand函数。

rand函数为了保证全局的随机性和并发安全,持有了全局锁,在一定情况下就降低了并行的性能

Fastrand函数

在工程中使用随机函数非常频繁,但又因为rand函数对并发效率不是很高,所以出现了Fastrand函数

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

fastrand牺牲了一些随机数列的一致性,但影响不大