青训营X豆包MarsCode 技术训练营第二课|Go语言工程实践

115 阅读10分钟

语言进阶

goroutine

如何使用

只需要在调用的函数或方法前加上go关键字

go func()    //创建一个新的 goroutine 运行函数fgo func(){
  // ...
}()          //匿名函数也支持使用go关键字创建 goroutine 去执行。

启动单个goroutine

package main
​
import "fmt"func hello() {
    fmt.Println("hello world")
}
​
func main() {
    for {
        go hello()
        fmt.Println("你好,世界")
    }
}

会交替输出这两句话

而如果没有加入for循环,重新执行程序后会输出

你好,世界
hello world

为什么会先打印 你好,世界?因为程序创建一个goroutine执行函数是是要花费时间的,而此时main函数会继续执行下去,所以会先打印下面的那句话,而如果当main函数执行完之后goroutine的创建还没有完成,那就会在打印了你好,世界 后直接退出程序,也就是只有这一句话

那我们如何做到确保所有的并发结束后才退出程序呢?我们可以使用sync包中的sync.WaitGroup来等待所有并发完成

下面是示例:

package main
​
import (
    "fmt"
    "sync"
)var wg sync.WaitGroupfunc hello() {
    fmt.Println("hello world")
    wg.Done()
}
​
func main() {
    wg.Add(1)
    go hello()
    fmt.Println("fuck you apex")
    wg.Wait()
}

启动多个goroutine

启动多个goroutine和单个同理,使用sync.WaitGroup来等待所有并发完成即可

package main
​
import (
    "fmt"
    "sync"
)
​
var wg2 sync.WaitGroup
​
func hello2(i int) {
    fmt.Println("hello world", i)
    wg2.Done()
}
​
func main() {
    for i := 0; i < 10; i++ {
        wg2.Add(1)
        go hello2(i)
    }
    wg2.Wait()
}

我们会发现程序输出时 i 是无序的(注意:每次程序的输出都会不一样)

hello world 2
hello world 7
hello world 8
hello world 5
hello world 9
hello world 6
hello world 1
hello world 0
hello world 4
hello world 3

这是因为10个 goroutine 是并发的,而 goroutine 的调度是随机的

channel

为什么要用channel

单纯的函数的并发是没有意义的,函数与函数交换数据才能体现并发执行函数的意义,这一般有内存共享和通信共享两种方式。go语言中采用的并发模型是CSP(Communicating Sequential Processes),提倡通过通信共享内存

channel就是可以让一个 goroutine 发送特定值到另一个 goroutine 的通信机制。

Go 语言中的通道(channel)是一种特殊的类型 。 通道像一个传送带或者队列,总是遵循先入先出的规则,保证收发数据的顺序。每一个通道都是一个具体类型的导管,也就是声明channel的时候需要为其指定元素类型。

声明和初始化

  • 声明:
var 变量名 chan 元素类型
//例子
var ch1 chan int   // 声明一个传递整型的通道
var ch2 chan bool  // 声明一个传递布尔型的通道
var ch3 chan []int // 声明一个传递int切片的通道
  • 初始化

声明的chan变量需要用make函数初始化后才能使用,未初始化的chan变量默认为nil

make(chan 元素类型, [缓冲大小])
ch4 := make(chan int)
ch5 := make(chan bool, 1)  // 声明一个缓冲区大小为1的通道

基本操作

channel有发送,接受,关闭三种操作,发送和接受都使用->符号

  • 发送

    ch <- 10 // 把10发送到ch中
    
  • 接收

    x := <- ch // 从ch中接收值并赋值给变量x
    <-ch       // 从ch中接收值,忽略结果
    
  • 关闭

    close(ch)
    
  • 取出所有值

    //ok判断
     for {
        v, ok := <-ch    // 从通道接收数据
        if !ok {        // 如果通道已经关闭,退出循环
            break
        }
        fmt.Println(v)  // 打印接收到的值
    }
    ​
    //使用range
    for v := range chan{
        fmt.Println(v)
    }
    

注意: 一个通道值是可以被垃圾回收掉的。通道通常由发送方执行关闭操作,并且只有在接收方明确等待通道关闭的信号时才需要执行关闭操作。它和关闭文件不一样,通常在结束操作之后关闭文件是必须要做的,但关闭通道不是必须的。

关闭后的通道有以下特点:

  1. 对一个关闭的通道再发送值就会导致 panic。
  2. 对一个关闭的通道进行接收会一直获取值直到通道为空。
  3. 对一个关闭的并且没有值的通道执行接收操作会得到对应类型的零值。
  4. 关闭一个已经关闭的通道会导致 panic。

下面的表格中总结了对不同状态下的通道执行相应操作的结果。

无缓冲通道

如果在初始化时不设置缓冲区,如下:

func main() {
    ch := make(chan int)
    ch <- 10
    fmt.Println("发送成功")
}

执行时 会报错表示 deadlock

这是因为无缓冲区的通道就表示接收值后无法接收其他值而是处于发送状态,只有在发送出之后才能继续接受值。就好比快递员一次只能送一个快递,而快递柜则可以容纳多个快递

要解决这个问题,就需要在传入channel的同时存在另一个接收者接收,这个接收必须是同步的,所以最好先创建接收者,在向channel中传值

package main
​
import "fmt"func receive(ch chan int) {
    v, ok := <-ch
    if !ok {
        fmt.Println("接收失败")
        return
    }
    fmt.Println(v)
}
​
func main() {
    ch := make(chan int)
    go receive(ch)
    ch <- 10
    close(ch)
    fmt.Println("发送成功")
}

使用无缓冲通道进行通信将导致发送和接收的 goroutine 同步化。因此,无缓冲通道也被称为同步通道

有缓冲通道

只要通道的容量大于零,那么该通道就属于有缓冲的通道,通道的容量表示通道中最大能存放的元素数量。当通道内已有元素数达到最大容量后,再向通道执行发送操作就会阻塞,除非有从通道执行接收操作。就像你小区的快递柜只有那么个多格子,格子满了就装不下了,就阻塞了,等到别人取走一个快递员就能往里面放一个。

我们可以使用内置的len函数获取通道内元素的数量,使用cap函数获取通道的容量,虽然我们很少会这么做。

多返回值模式

对一个通道执行接收操作时支持如下多返回值模式

value, ok := <- chan
  • value: 从通道中取出的值,如果通道被关闭则返回对应类型的零值
  • ok: 通道ch关闭时返回 false,否则返回 true

示例:

func f2(ch1 <-chan int, ch2 chan<- int) {
    for {
        v, ok := <-ch1
        if !ok {
            break
        }
        ch2 <- v * v
    }
    close(ch2)
}

依赖管理(Go Module)

go依赖管理演进路线

GOPATH

特点

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

弊端

  • 场景:A 和 B依赖于某一 package 的不同版本
  • 问题:无法实现 package 的多版本控制

Go Vender

在项目目录下增加 vendor 文件,所有依赖包副本形式存放在 vender 中

依赖寻址方式:vendor => GOPATH

解决了多个项目需要同一个 package 的问题

弊端

场景:

  • 无法控制依赖的版本
  • 更新项目有可能出现依赖冲突,导致编译出错

Go Module

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

依赖管理三要素

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

依赖配置

go.mod

version

  • 语义化版本

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

    major是两个不兼容的版本,minor在major下做到前后兼容,patch是修复一些bug

    例子:V1.3.0 V2.3.0

  • 基于commit伪版本

    每次提交commit,go mod 都会生成伪版本

indirect

  • 定义:标记为 indirect 的依赖是项目中并不直接引用的库。这些库是通过直接依赖的其他库间接引入的。
  • 示例:比如,项目 A 直接依赖库 B,而库 B 又依赖于库 C,当库 C 的版本在项目中被引入时,它会被标记为 indirect

incompatible

  • 定义incompatible 是指依赖的版本间存在不兼容的情况,即不同的包依赖于同一目标包的不同版本。这样可能导致运行时错误或程序崩溃。
  • 示例:假设库 A 依赖于库 B 的版本 1.0,而库 C 依赖于库 B 的版本 2.0,则会出现不兼容的问题,因为它们无法共存。

依赖分发

回源

使用管理版本仓库下载依赖,如 github,SVN等

但存在问题:无法保证稳定性,可用性,增加第三方压力

Proxy

代理会缓存已下载的模块,使用后会直接从proxy中拉取依赖,实现了稳定和可靠

变量 GOPROXY

GOPROXY="proxy1.cn, proxy2.cn ,direct' 服务站点URL列表,“direct”表示源站

请求依赖时,proxy1请求,在向proxy2请求,最后到源站direct

工具

go get

go get example.org/pkg + :

@update:默认

@none:删除依赖

@v1.1.2:tag版本,语义版本

@23dfdd5:特定的commit

@master:分支的最新commit

go mod

go mod init:初始化,创建go.mod文件

go mod download:下载模块到本地缓存

go mod tidy:增加需要的依赖,删除不需要的依赖


测试

Go语言中的测试依赖go test命令,所有测试文件都以 _test.go 结尾

*_test.go文件中有三种类型的函数,单元测试函数、基准测试函数和示例函数。

类型格式作用
测试函数函数名前缀为Test测试程序的一些逻辑行为是否正确
基准函数函数名前缀为Benchmark测试函数的性能
示例函数函数名前缀为Example为文档提供示例文档

单元测试

规则

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

例子:

package main
​
import (
    "github.com/go-playground/assert"
    "testing"
)
​
func HelloTom() string {
    return "false"
}
​
func TestHelloTom(t *testing.T) {
    output := HelloTom()
    expectOutput := "Tom"
    assert.Equal(t, expectOutput, output)
}

运行结果:

覆盖率

go test --cover

衡量代码测试水平

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

运行结果:

image-20241104202153585

因为我们的测试函数只经过了2/3的语句,所以显示了66.7%,这个时候我们就可以进行修改

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

一般项目中50%-60%

Mock

使测试可以在任何环境下进行,不依赖于本地文件,可以为函数,方法打桩,在最终测试时调用的是打桩函数

例子:

现在假设我们有一个处理文本的函数:

func ReadFirstLine() string {
    open, err := os.Open("log")
    defer open.Close()
    if err != nil {
        return ""
    }
    scanner := bufio.NewScanner(open)
    for scanner.Scan() {
        return scanner.Text()
    }
    return ""
}
​
func ProcessFirstLine() string {
    line := ReadFirstLine()
    destLine := strings.ReplaceAll(line, "11", "00")
    return destLine
}

正常的测试函数是:

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

这样存在的问题就是没有这个文件,就无法进行测试,这时候我们就可以在使用mock测试

mock的测试函数是:

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

我们使用 Patch() 将一个匿名函数替换掉了原来的函数,测试不再依赖于本地文件,可以在任何时间运行

func Patch(target interface{}, replacement interface{}) *PatchGuard  

基准测试

用于测试性能,和单元测试类似

例子:

随机选择服务器执行

package mainvar ServerIndex [10]int
​
func InitServerIndex() {
    for i := 0; i < 10; i++ {
        ServerIndex[i] = i
    }
}
​
func Select() int {
    return ServerIndex[0]
}

测试:

package main
​
import (
    "testing"
)
​
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()
        }
    })
}

测试后发现,并发情况下程序运行时间显著增加,原因是 rand 函数调用时会自动创建锁,从而降低并发速度,对此可使用 fastrand :

func FastSelect() int {
    return ServerIndex[fastrand.Intn(10)]
}
​
func BenchmarkSelectFastrandParallel(b *testing.B) {
    InitServerIndex()
    b.ResetTimer()
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            FastSelect()
        }
    })
}

三者时间对比如下: