Go语言进阶 | 青训营

107 阅读5分钟

今天是青训营伴学笔记创作的第三天!

下面将介绍Go语言进阶的相关内容,具体可以包含以下几项 :

  • 并发编程
  • 依赖管理
  • Go依赖管理演进
  • 测试
  • 项目实践

并发编程

并发 vs 并行

image-20230727214737873.png

Go可以充分发挥多核优势,高效运行

Goroutine

image-20230727215210343.png

协程 : 用户态,轻量级线程,栈KB级别

线程 : 内核态 ,线程跑多个协程,栈MB级别

go开启协程 : 只需要在函数前面加上一个go关键字

例 : 快速打印hello goroutine 0-4 (快速 : 开多个协程去打印)

package main
​
import (
    "fmt"
    "time"
)
​
func hello(i int) {
    fmt.Println("hello goroutine : " + fmt.Sprint(i))
}
​
func HelloGoRoutine() {
    for i := 0; i < 5; i++ {
        go func(j int) {
            hello(j)
        }(i)
    }
    time.Sleep(time.Second)
}
​
func main() {
    HelloGoRoutine()
}
函数 HelloGoRoutine 包含一个 for 循环,它会创建 5 个 goroutine 并发执行。每个 goroutine 都会调用一个匿名函数,并传递一个整数参数 j。在匿名函数中,它会调用 hello(j) 函数。
​
使用 go 关键字可以将一个函数调用包装为一个 goroutine,使其在独立的执行线程中运行。对于循环中的每个迭代,使用匿名函数和参数 i 创建一个新的 goroutine。
​
这种方式可以实现并发执行,但不保证多个 goroutine 的执行顺序。因此,hello(j) 函数的执行顺序可能是随机的。
​
为了等待所有 goroutine 执行完毕,代码使用 time.Sleep(time.Second) 来暂停主 goroutine 的执行 1 秒钟。这样做是为了确保所有创建的 goroutine 有足够的时间完成执行。

CSP

  • CSP(Communicating Sequential Processes)是一种并发计算的模型和理论
  • go提倡通过通信共享内存而不是通过共享内存而实现通信

image-20230727222226721.png

Channel

Channel是一种引用类型

语法 :

make(chan 元素类型,[缓冲大小])

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

image-20230727222541068.png

image-20230727222926303.png

package main
​
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)
    }
}
​
func main() {
    CalSquare()
}

并发安全 Lock

image-20230727223958738.png

WaitGroup

sync.WaitGroup 类型是用于等待一组 goroutine 完成执行的同步原语。WaitGroup 提供了一种简单而有效的方法来等待并发操作的完成。

sync.WaitGroup 提供了三个主要的方法:

  1. Add(delta int):向 WaitGroup 中添加 delta 值,代表需要等待的 goroutine 的数量。每次启动一个新的 goroutine 时,应该调用 Add(1) 来增加等待的数量。
  2. Done():通知 WaitGroup 一个已完成的 goroutine。每个 goroutine 在执行完任务后,应调用 Done() 来通知 WaitGroup 完成一个任务。
  3. Wait():阻塞运行的 goroutine,直到 WaitGroup 中的计数器归零。当等待的所有任务都完成时,Wait() 返回,允许程序继续执行。

以下是一个示例,演示如何使用 sync.WaitGroup

package main
​
import (
    "fmt"
    "sync"
    "time"
)
​
func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done() // 在函数结束时通知 WaitGroup,完成一个任务
​
    fmt.Printf("Worker %d starting\n", id)
    time.Sleep(time.Second) // 模拟工作耗时
    fmt.Printf("Worker %d done\n", id)
}
​
func main() {
    var wg sync.WaitGroup // 创建一个 WaitGroupfor i := 1; i <= 3; i++ {
        wg.Add(1) // 增加等待的任务数量
        go worker(i, &wg)
    }
​
    wg.Wait() // 等待所有任务完成
​
    fmt.Println("All workers done")
}

在上述示例中,worker 函数模拟一个具体的工作任务。在每个 goroutine 的开始和结束处,使用 wg.Done() 来通知 WaitGroup 完成一个任务。

main 函数中,我们创建了一个 WaitGroup 对象 wg。然后,启动了三个 goroutine,每个 goroutine 执行一个 worker 任务,并通过传递 &wg 来让 worker 函数使用同一个 WaitGroup 对象。

最后,调用 wg.Wait() 等待所有的任务完成。一旦所有的任务都完成,主 goroutine 继续执行并打印 “All workers done”。

使用 sync.WaitGroup 可以方便地等待一组 goroutine 的完成,保证了并发操作的同步和顺序。

image-20230727224420630.png

hello goroutine 0-4 由 WaitGroup 改写

package main
​
import (
    "fmt"
    "sync"
)
​
func hello(i int) {
    fmt.Println("hello goroutine : " + fmt.Sprint(i))
}
​
func HelloGoRoutine() {
    var wg sync.WaitGroup
    wg.Add(5)
    for i := 0; i < 5; i++ {
        go func(j int) {
            defer wg.Done()
            hello(j)
        }(i)
    }
    wg.Wait()
}
​
func main() {
    HelloGoRoutine()
}

依赖管理

背景

  • 工程项目不可能基于标准库0~1编码搭建
  • 管理依赖库

image-20230727225053419.png

Go依赖管理演进

GOPATH -> Go Vendor -> Go Module

  • 不同环境(项目)依赖的版本不同
  • 控制依赖库的版本

GOPATH

GOPATH 是 GO 语言支持的一个环境变量

  • src : 存放go项目的地方
  • pkg : 存放编译的中间产物,加快编译速度
  • bin : 存放go项目编译生成的二进制文件

xx :

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

GOPATH-弊端

如图:

image-20230727230311610.png

同一个pkg,有2个版本,A->A(),B->B(),而src下只能有一个版本存在,那AB项目无法保证都能编译通过,也就是在gopath管理模式下,如果多个项目依赖同一个库,则依赖该库是同一份代码,所以不同项目不能依赖同一个库的不同版本,这很显然不能满足我们的项目依赖需求。为了解决这问题,govender出现了。

Go Vendor

Vendor是当前项目中的一个目录,其中存放了当前项目依赖的副本。在Vendor机制下,如果当前项目存在vendor目录,会优先使用该目录下的依赖,如果依较不存在,会从GOPATH中寻找;这样***。

  • 项目目录下增加vendor文件,所有依赖包副本形式放在$ProjectRoot/vendor
  • 依赖寻址方式 : vender => GOPATH

image-20230727230913822.png

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

但vendor无法很好解决依赖包的版本变动问题和一个项目依赖同一个包的不同版本的问题,下面我们看一个场景。

Go Vendor - 弊端

image-20230727231152039.png

问题 :

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

Go Module

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

终极目标 : 定义规则和管理项目的依赖关系

依赖管理三要素

类比java中的Maven

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

依赖配置 - go.mod

image-20230728061835646.png

依赖标识 :

[Module Path] [Version/Pseudo-version]
  • 首先模块路径用来标识一个模块,从模块路径可以看出从哪里找到孩模块,如果是gthub前缀则表示可以从cithub仓库找到孩模块,依赖包的源代码由gthub托管,如果项目的子包想被单独引用,则需要通过单独的init go。mod文件进行管理。
  • 下面是依赖的原生sdk版本
  • 最下面是单元依赖,每个依赖单元用模块路径+版本来唯─标示。

依赖配置 - version

  • 语义化版本 :

    ${MAJOR}.${MINOR}.${PATCH}
    
    V1.3.0
    V2.3.0
    
  • 基于commit伪版本

    vX.0.0-yyyymmddhhmmss-abcdefgh1234
    v0.0.0-20220401081311-c38fb59326b7
    v1.0.0-20201130134442-10cb98267c6c
    
  • gopath和govendor都是源码副本方式依赖,没有版本规则概念,而gomod为了放方便管理则定义了版本规则,分为语义化版本和"*;其中语义化版本包括,不同的MAJOR版本表示是不兼容的API,所以即使是同一个库,MAJOR版本不同也会被认为是不同的模块;MINOR版本通常是新增函擞或功能,向后兼容;而patcth版本一般是修复bug;而基于commit的为版本包括"**,基础版本前缀是和语义化版本一样的;时间戳(w.ymmddhmssl,也就是提交Commit的时间,最后是校验码(abcdefabcdef ,包含12位的哈希前缀;每次提交commit后Go 都会默认生成一个伪版本号。

测试

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

类型 :

  • 回归测试

  • 集成测试

  • 单元测试

image-20230728162007692.png

单元测试规则

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

image-20230728162524238.png

测试例子

package main
​
import (
    "testing"
)
​
func TestHelloTom(t *testing.T) {
    output := HelloTom()
    expectOutput := "Tom"
    if output != expectOutput {
        t.Errorf("Expected %s do not match actual %s", expectOutput, output)
    }
}
​
func HelloTom() string {
    return "Jerry"
}
  • 在vscode中在终端输入go test就好了
  • 在goland中直接右键运行就好了

效果 :

image-20230728164846730.png

image-20230728164907589.png

单元测试-assert

assert函数代替了之前的Errorf,用于测试文件方便

问题 :

image-20230728170606397.png

解决 :

go get github.com/stretchr/testify/assert

其它问题 :

blog.csdn.net/m0_67401417…

package main
​
import (
    "github.com/stretchr/testify/assert"
    "testing"
)
​
func JudgePassLine(score int16) bool {
    if score >= 60 {
        return true
    }
    return false
}
func TestJudgePassLineTrue(t *testing.T) {
    isPass := JudgePassLine(70)
    assert.Equal(t,true,isPass)
}

单元测试-代码覆盖率

judgement_test.go :

package main
​
import (
    "github.com/stretchr/testify/assert"
    "testing"
)
​
func TestJudgePassLineTrue(t *testing.T) {
    isPass := JudgePassLine(70)
    assert.Equal(t, true, isPass)
}

judgement.go :

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

运行(vscode) :

go test judgement_test.go judgement.go --cover

效果截图 :

image-20230728171844802.png

66.7的计算 :

在judgement.go中。有效代码(3行)为 :

    if score >= 60 {
        return true
    }
    return false

在单元测试中,单元测试执行了两行,故2/3=66.7%

如果再添加一个不及格的case,如 :

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

或者将judgement中的代码改为 :

return score >= 60

代码的覆盖率将达到100%

单元测试 -- tips

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

单元测试 -- 依赖

image-20230728180925408.png

工程中复杂的项目,一般会依赖数据库,本地文件,cache等等,单侧需要保持稳定性和幂等性。

  • 稳定 : 相互隔离,能在任何时间,任何环境,运行测试
  • 幂等性 : 每一次测试运行都应该产生与之前一样的结果,而要实现这一目的就要用到mock机制

单元测试 -- 文件处理

对本地文本测试简例 :

log (文本文件):

line11
line22
line33
line44

wj.go : (读取文本文件的第一行)

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

wj_test.go

package main
​
import (
    "github.com/stretchr/testify/assert"
    "testing"
)
​
func TestProcessFirstLine(t *testing.T) {
    firstLine := ProcessFirstLine()
    assert.Equal(t, "line00", firstLine)
}
​
​
​

在终端输入 :

 go test .\go_JingJie\WenJianChuLi_test\wj_test.go .\go_JingJie\WenJianChuLi_test\wj.go

完成wj_test.go对wj.go的测试

实现截图 :

image-20230728195439191.png

在这个例子中,将文件中的第一行字符中的11换成00,执行单例,测试通过。

但是这个单测需要依赖于本地文件,如果本地文件被修改或者删除就会fail。

为例保证测试case的稳定性,我们对读取文件进行mock,屏蔽对文件的依赖

单元测试 -- Mock

monkey : github.com/bouk/monkey

monkey是一个开源的mock测试库,可以对method,或则实例方法进行mock,反射,指针赋值。

快速 Mock 函数

  • 为一个函数打桩
  • 为一个方法打桩

image-20230728200722812.png

mock包获取 :

image-20230728204758402.png

go get bouk.ke/monkey

go get 命令是一个方便的工具,用于获取并安装 Go 语言的包、库或命令行工具。

将之前的wj_test.go代码换成 :

package main
​
import (
    "bou.ke/monkey"
    "github.com/stretchr/testify/assert"
    "testing"
)
​
func TestProcessFirstLineWithMock(t *testing.T) {
    // ReadFirstLine 函数被替换为一个匿名函数 func() string { return "line110" };
    // 该匿名函数总是返回字符串 “line110”
    monkey.Patch(ReadFirstLine, func() string {
        return "line110"
    })
    defer monkey.Unpatch(ReadFirstLine)// 通过defer卸载mock
    firstLine := ProcessFirstLine()
    assert.Equal(t, "line000", firstLine)
}

这样对ReadFirstLine打桩测试,就不会再依赖本地文件

然后测试,就成功了。

基准测试

go语言提供了基准测试框架

  • 优化代码,需对当前代码分析
  • 内置的测试框架提供了基准测试的能力

例子 :随机选择执行服务器

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)]
}
  • 基准测试以Benchmark开头,入参是testing.B,用b中的N值反复递增循环测试
  • (对一个测试用例的默认测试时间是1秒,当测试用例函数返回时还不到1秒,那么testingB中的N值将按1、2、5、10、20、.…递.增,并以递增后的值重新进行用例函数测试。)
  • Resttimer重置计时器,我们再reset之前做了init或其他的准备操作,这些操作不应该作为基准刻试的范围;numprale是多协程并发测试;执行2个基准知过,发现代码在并发情况下存在劣化,主要原因是rand为了保证全局的随机性和并发安全,持有了一把全局锁。

测试代码 :

package main
​
import "testing"
​
func BenchmarkSelect(b *testing.B) {
    InitServerIndex() // init服务器列表
    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()
        }
    })
}

运行结果 :

image-20230728232238065.png

image-20230728232300124.png

基准测试优化 : fastrand()

地址 :Page not found · GitHub

项目实践

社区话题页面 :

  • 展示话题(标题,文字描述) 和 回帖列表
  • 暂不考虑前端页面实现,仅仅实现一个本地web服务
  • 话题 和 回帖数据用文件存储

需求用例

image-20230728233519009.png

ER图

  • 话题 : Topic
  • 帖子 : Post

image-20230728233647201.png

分层结构

image-20230728233818314.png

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

组件工具

项目地址 :

Moonlight-Zhao/go-project-example (github.com)