【超万字详细笔记】Go 语言上手-工程实践(2) | 青训营笔记

407 阅读7分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第2篇笔记

1. 语言进阶

1.1 并发与并行

  • 并发:多线程程序在CPU的一个核上运行

    image.png

  • 并行:多线程程序在CPU的多个核上运行

    image.png

1.2 进程、线程与协程

  1. 进程:程序在操作系统中的一次执行过程,是系统进行资源分配和调度的基本单位。
  2. 线程:又称轻量级进程,是进程的一个执行实例,是CPU调度和程序执行的最小单位。存在于内核态,内存消耗是MB级别。
  3. 协程:在线程中细分出的单位,调度不受操作系统内核所管理,完全由用户控制。存在于用户态,内存消耗是KB级别。

image.png

image.png

协程对比线程的优势:

  1. 存在于用户态,可操作性强,调度可由自己控制。
  2. 更轻量,所需资源更少。

1.3 goroutine

goroutine是 Go 语言并行设计的核心,本质上就是协程。Go 语言内部帮你实现了 goroutine 之间的内存共享。执行goroutine只需极少的栈内存(大概是4~5KB),当然会根据相应的数据伸缩。也正因为如此,可同时运行成千上万个并发任务。goroutine比thread更易用、更高效、更轻便。

1.3.1 创建 goroutine

只需在函数调⽤语句前添加 go 关键字,就可创建并发执⾏单元。开发⼈员无需了解任何执⾏细节,调度器会自动将其安排到合适的系统线程上执行。

在并发编程里,我们通常想讲一个过程切分成几块,然后让每个goroutine各自负责一块工作。当一个程序启动时,其主函数即在一个单独的goroutine中运行,我们叫它main goroutine。新的goroutine会用go语句来创建。

示例:快速打印hello goroutine: 0 ~ hello goroutine: 4

package main
​
import (
    "fmt"
    "time"
)
​
func hello(i int) {
    fmt.Println("hello goroutine:" + fmt.Sprint(i))
}
​
func main() {
    for i := 0; i < 5; i++ {
        go func(j int) {
            hello(j)
        }(i)
    }
    time.Sleep(time.Second)
}

image.png

1.4 CSP (Communicating Sequential Processes)

并发程序之间的通信,有通过通信共享内存,和通过共享内存实现通信两种。

go 语言的协程之间提倡通过通信共享内存,因为通过共享内存实现通信需要在临界区加锁,可能会影响性能。

image.png

1.5 Channel (通道)

通过通信共享内存涉及到了通道Channel

创建通道的语句:make(chan 元素类型, [缓冲大小])

通道分为两种:

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

image.png

1.5.1 Channel的使用

A 子协程发送0~9数字

B 子协程计算输入数字的平方

主协程输出最后的平方数

package main
​
func main() {
    var src chan int
    src = make(chan int)      //无缓冲通道
    dest := make(chan int, 3) //有缓冲通道
    // A子协程 生产者
    go func() {
        defer close(src)
        for i := 0; i < 10; i++ {
            src <- i
        }
    }()
    // B子协程 消费者
    go func() {
        defer close(dest)
        for i := range src { //消费者1
            dest <- i * i
        }
    }()
    // 主协程
    for i := range dest { //消费者2
        println(i)
    }
}

image.png

这个例子中,A子协程可以看作生产者,B子协程可以看做消费者。

B子协程的运算逻辑比A子协程复杂,执行速度比A子协程慢。使用有缓冲的通道可以避免由于B子协程(消费者)的消费速度影响A子协程(生产者)的执行效率。

1.5.2 有缓冲的通道的优点

在生产者和消费者的效率存在差异的情况下,使用有缓冲的通道可以解决由于生产和消费速度不均衡带来的执行效率问题。

1.6 并发安全 Lock

go 语言中保留了通过共享内存实现通信的机制,这种情况会存在多个 goroutine 同时操作同一块内存资源的情况,可能导致并发安全的问题。

这种情况需要通过加锁的方式解决并发安全问题。

go 语言的互斥锁:sync.Mutex

示例:5个协程并发对变量执行2000次+1操作

package main
​
import (
    "fmt"
    "sync"
    "time"
)
​
var (
    x    int
    lock sync.Mutex // 互斥锁
)
​
// 无互斥锁的版本
func addWithoutLock() {
    for i := 0; i < 2000; i++ {
        x++
    }
}
​
// 有互斥锁的版本
func addWithLock() {
    // 给临界区的资源加锁
    lock.Lock()
    for i := 0; i < 2000; i++ {
        x++
    }
    // 完成+1操作后释放临界区的资源
    lock.Unlock()
}
​
func main() {
    // 使用5个协程并发对x执行2000次+1操作
    // 无锁版本
    for i := 0; i < 5; i++ {
        go addWithoutLock()
    }
    // 等待上面的协程执行结束
    time.Sleep(time.Second)
    fmt.Printf("无锁版本:%d", x)
    fmt.Println()
​
    // 重置x = 0
    x = 0
    // 有锁版本
    for i := 0; i < 5; i++ {
        go addWithLock()
    }
    // 等待上面的协程执行结束
    time.Sleep(time.Second)
    fmt.Printf("有锁版本:%d", x)
}

image.png

对比无锁版本和有锁版本的结果可以知道,在并发程序执行过程中如果没有对资源的访问权限加以保护,可能会造成并发安全的问题导致结果异常。

因此需要对临界区的资源加锁来保证共享内存过程中的并发安全。

1.7 WaitGroup

1.7.1 为什么要使用 WaitGroup

下面是一段常见的代码:

package main
​
import (
    "fmt"
    "time"
)
​
func main(){
    for i := 0; i < 100 ; i++{
        go fmt.Println(i)
    }
    time.Sleep(time.Second)
}

主线程为了等待goroutine都运行完毕,不得不在程序的末尾使用time.Sleep() 来睡眠一段时间,等待其他线程充分运行。对于简单的代码,100个for循环可以在1秒之内运行完毕,time.Sleep() 也可以达到想要的效果。

但是对于实际生活的大多数场景来说,1秒是不够的,并且大部分时候我们都无法预知for循环内代码运行时间的长短。这时候就不能使用time.Sleep() 来完成等待操作了。

可以考虑使用通道来完成上述操作:

func main() {
    c := make(chan bool, 100)
    for i := 0; i < 100; i++ {
        go func(i int) {
            fmt.Println(i)
            c <- true
        }(i)
    }
​
    for i := 0; i < 100; i++ {
        <-c
    }
}

使用通道确实能达到我们的目的的,但是显得有些大材小用,因为它被设计出来不仅仅只是在这里用作简单的同步处理,在这里使用管道实际上是不合适的。而且假设我们有一万、十万甚至更多的 for 循环,也要申请同样数量大小的管道出来,对内存也是不小的开销。

对于这种情况,go语言中有一个的工具sync.WaitGroup 能更加方便的帮助我们达到这个目的。

WaitGroup 对象内部有一个计数器,最初从0开始,它有三个方法Add(), Done(), Wait() 来控制计数器的数量。Add(n) 把计数器设置为nDone() 每次把计数器-1wait() 会阻塞代码的运行,直到计数器地值减为0

使用WaitGroup ,上述代码可以修改为:

func main() {
    wg := sync.WaitGroup{}
    wg.Add(100)
    for i := 0; i < 100; i++ {
        go func(i int) {
            fmt.Println(i)
            wg.Done()
        }(i)
    }
    wg.Wait()
}

这里首先把wg 计数设置为 100, 每个 for 循环运行完毕都把计数器减一,主函数中使用Wait() 一直阻塞,直到wg为 0 ,也就是所有的 100 个 for 循环都运行完毕。相对于使用管道来说,WaitGroup 轻巧了许多。

1.7.2 使用WaitGroup的注意事项

  1. 计数器不能为负值

    我们不能使用Add()wg 设置一个负值,否则代码会报错。同样的,使用Done() 也要注意不要把计数器设置成负数。

  2. WaitGroup 对象不是一个引用类型

    所以在通过函数传值的时候需要使用地址,否则程序会进入死锁。

    func main() {
        wg := sync.WaitGroup{}
        wg.Add(100)
        for i := 0; i < 100; i++ {
            go f(i, &wg)
        }
        wg.Wait()
    }
    ​
    // 一定要通过指针传值,不然进程会进入死锁状态
    func f(i int, wg *sync.WaitGroup) { 
        fmt.Println(i)
        wg.Done()
    }
    

1.7.3 使用 WaitGroup 修改 1.3 中的代码

package main
​
import (
    "fmt"
    "sync"
)
​
func hello(){
    fmt.Println("hello")
}
​
func main() {
    var wg sync.WaitGroup
    wg.Add(5)  // 计数器设为5
    for i := 0; i < 5; i++ {
        go func() {
            defer wg.Done()  // 每个协程执行结束后对计数器 -1
            hello()
        }()
    }
    wg.Wait() // 通过 Wait 进行阻塞
}

2. 依赖管理

2.1 Go 依赖管理的演进

graph LR
A(GOPATH) --> B(Go Vendor) --> C(Go Module)

由于不同环境(项目)依赖的版本不同,为了控制依赖库的版本,Go 依赖管理经历了从 GOPATH 到 Go Vendor 到 Go Module 的演进。

2.1.1 GOPATH

GOPATH 是 go 语言支持的环境变量,GOPATH下会生成以下三个文件夹:

  • bin:项目编译的二进制文件
  • pkg:项目编译的中间产物,加速编译
  • src:项目源码

项目直接依赖src下的代码,go get命令下载的软件包都会在src目录下。

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

在 GOPATH 管理模式下,如果多个项目依赖同一个库,src下只能存在该库的一个版本,所以不同项目不能依赖同一个库的不同版本。当对某个项目的依赖进行升级后,可能会出现兼容问题。

image.png

2.1.2 Go Vendor

为了解决 GOPATH 中多版本控制的问题,go 语言增加了 Go Vendor 的方式管理依赖。

  • 在每个项目下增加 Vendor 目录,里面存放该项目需要的依赖包副本。
  • 在 Go Vendor 机制下,会优先在 Vendor 目录下寻找依赖,如果没有再到 GOPATH 中寻找。

Go Vendor 的弊端:不能清晰地表示依赖的版本,无法解决依赖的依赖的冲突问题。

image.png

2.1.3 Go Module

Go Module是 Go 语言 1.11 版本推出的依赖管理系统,解决了之前的依赖管理存在的问题,实现了定义版本规则和管理项目依赖关系的功能。

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

2.2 依赖管理三要素

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

2.3 依赖配置

2.3.1 go.mod文件:

module example/project/app  // 依赖管理基本单元go 1.18  // 原生库
​
require (  // 单元依赖
    example/lib1 v1.0.2
    example/lib2 v1.0.0 // indirect
    example/lib3 v0.1.0-20190725025543-5a5fe074e612
    example/lib4 v0.0.0-20180306012644-bacd9c7ef1dd // indirect
    example/lib5/v3 v3.0.2
    example/lib6 v3.0.2+incompatible
)

依赖标识:[Module Path][Version/Pseudo-version]

2.3.2 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

对于语义化版本有如下规则:

  • MAJOR:表示是不兼容的 API,所以即使是同一个库,MAJOR 版本不同也会被认为是不同的模块。
  • MINOR:通常是新增函数或功能,向后(向下)兼容。
  • PATCH:修复 bug。

2.3.3 特殊标识符

  • indirect:表示间接依赖

  • incompatible:

    主版本在 2 以上的模块会在模块路径增加 /vN 后缀。对于没有 go.mod 文件并且主版本在 2 以上的依赖,会 +incompatible

2.3.4 选择题

image.png

go 语言会选择最低的兼容版本。B 选项的 v1.4 向后兼容,所以选B。

2.3.5 依赖分发 - 为什么要使用 Go Proxy

go.mod 中的依赖都来自于多版本代码仓库管理系统。

image.png

如果直接向代码托管平台进行依赖的请求,很快会发现有以下这些问题:

  • 无法保证构建的稳定性(作者可能增加/修改/删除了软件版本)
  • 无法保证可用性(作者可能删除软件)
  • 增加了平台压力(代码托管平台负载问题)

为了解决依赖分发的问题,go 语言使用了 Go Proxy 进行依赖分发。

image.png

Go Proxy 是一个服务站点,它会缓源站中的软件内容,缓存的软件版本不会改变,并且在源站软件删除之后依然可用,从而实现了供“immutability”和“available”的依赖分发;使用 Go Proxy 之后,构建时会直接从 Go Proxy 站点拉取依赖。

2.3.6 依赖分发 - Go Proxy 的使用

Go语言通过设置环境变量GOPROXY来设置具体的服务站点。可以通过逗号设置多个Proxy站点,最后如果这几个都没有找到,那么会通过direct进行回源,也就是回到本来的请求站点,而不是代理站。

有意思的是,当你此时从源站下载好依赖后,之前走过的Proxy站点也会将这个缓存下来。

image.png

PS:一个有趣的实践

通过go mod init创建一个项目,完成后提交到GitHub仓库里,然后通过 go get 对你的代码进行请求,注意在 GOPROXY 中的 direct要加上你的仓库的地址。最后你会发现你的Proxy站上,也有了你的代码!

通过这样的过程,使 go 语言的代码仓库非常的繁荣,各种库都可以 go get 到!

2.3.7 本地工具

  • go get

    image.png

  • go mod

    image.png

3. 测试

3.1 为什么要测试

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

image.png

3.2 测试的类型

  • 回归测试:是指修改了旧代码后,重新测试以确认修改没有引入新的错误或导致其他代码产生错误。
  • 集成测试:集成测试的目的是在集成这些不同的软件模块时揭示它们之间交互中的缺陷。
  • 单元测试:单元测试测试开发阶段,开发者对单独的函数、模块做功能验证。

层级从上至下,测试成本逐渐减低,而测试覆盖率确逐步上升,所以单元测试的覆盖率一定程度上决定这代码的质量。

image.png

3.3 单元测试

image.png

单元测试主要包括输入、测试单元、输出、以及校对。

单元的范围比较广,包括接口,函数,模块等。

单元测试通过最后的校对来保证代码的功能与我们的预期相符;单侧一方面可以保证质量,在整体覆盖率足够的情况下,一定程度上既保证了新功能本身的正确性,又未破坏原有代码的正确性。另一方面可以提升效率,在代码有bug的情况下,通过编写单测,可以在一个较短周期内定位和修复问题。

3.3.1 单元测试的规则

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

    image.png

  • 方法名为 func TestXxx(t *testing.T)

    image.png

  • 初始化逻辑放到 TestMain 中

    image.png

3.3.2 单元测试实例

主函数

package main

func HelloTom() string {
	return "Jerry"
}

func main() {
	HelloTom()
}

测试函数

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

运行测试的时候可能会报错,如下图

image.png

这是被测试的函数没有被编译导致的。

解决方法:

点击编辑配置

image.png

点击文件那一行右边的文件夹

image.png

选中被测试的文件,点击确定。

image.png

接下来测试函数就能正常运行啦。

image.png

3.3.3 assert包

可以实现字符的比较。

安装:

  • 在命令行窗口输入go get "github.com/stretchr/testify/assert"
  • 在 go.mod 中的require 加上github.com/stretchr/testify

示例:

package main
​
import (
    "github.com/stretchr/testify/assert"
    "testing"
)
​
func TestHelloTom(t *testing.T) {
    output := HelloTom()
    expectOutput := "Tom"
    assert.Equal(t, expectOutput, output)
}

image.png

3.3.4 单元测试 覆盖率

覆盖率是衡量单元测试的标准。

测试覆盖率可以在go test 命令末尾加上 --cover,也可以在 Goland 中选择使用覆盖率运行

image.png

示例:

主函数

package main

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

测试函数

package main

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

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

image.png

显示覆盖率为66.7%。

下面提升覆盖率。可以增加一个不合格的case,重新执行单测。

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

image.png

Tips

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

3.4 单元测试 - 打桩(Mock)

3.4.1 单测的稳定性和幂等性

image.png

  • 稳定:相互隔离,能在任何时间,任何环境,运行测试。
  • 幂等:指每一次测试运行都应该产生与之前一样的结果。

如果程序有外部依赖,在不同的测试环境,外部依赖信息可能会发生变化。比如程序需要打开某个文件,如果把相同的程序放在不同的环境测试,该文件的路径可能不一致。这就不符合稳定和幂等两个条件。

通过打桩(Mock)可以解决这个问题。

3.4.2 打桩的概念

打桩就是用桩函数代替原本的函数。

打桩的目的主要有:隔离、补齐、控制。

  • 隔离是指将测试任务从产品项目中分离出来,使之能够独立编译、链接,并独立运行。隔离的基本方法就是打桩,将测试任务之外的,并且与测试任务相关的代码,用桩来代替,从而实现分离测试任务。例如函数A调用了函数B,函数B又调用了函数C和D,如果函数B用桩来代替,函数A就可以完全割断与函数C和D的关系(隔离了A和C、D,而不是隔离A和B)。
  • 补齐是指用桩来代替未实现的代码,例如,函数A调用了函数B,而函数B由其他程序员编写,且未实现,那么,可以用桩来代替函数B,使函数A能够运行并测试。补齐在并行开发中很常用。
  • 控制是指在测试时,人为设定相关代码的行为,使之符合测试需求。

3.4.3 开源Mock测试库-monkey

monkey库:github.com/bouk/monkey

monkey库实现打桩的原理:

在运行时通过通过 Go 的 unsafe 包,将内存中函数的地址替换为运行时函数的地址,使测试时调用的函数是打桩函数,实现了Mock的功能。

示例:

image.png

3.5 基准测试(Benchmark)

Go 语言还提供了基准测试框架 Benchmark 。

基准测试是指测试一段程序的运行性能及耗费 CPU 的程度。而我们在实际项目开发中,经常会遇到代码性能瓶颈,为了定位问题经常要对代码做性能分析,这就用到了基准测试。使用方法类似于单元测试。

基准测试需要遵循以下语法规定:

  1. go语言中的基准测试也是基于单元测试,所以还是需要遵循 *_test.go 的命名规则。
  2. 用于基准测试的函数名要以 Benchmark 开头。
  3. 函数的入参需要是 *testing.B

3.5.1 基准测试-示例

负载均衡中随机选择执行服务器。

server_select.go

package main
​
import (
    "math/rand"
)
​
var ServerIndex [10]int// InitServerIndex 初始化服务器的描述符
func InitServerIndex() {
    for i:=0;i<10;i++{
        ServerIndex[i] = i+100
    }
}
​
// RandSelect 随机选择一个服务器
func RandSelect() int  {
    return ServerIndex[rand.Intn(10)]
}

server_select_test.go

package main
​
import "testing"func BenchmarkSelect(b *testing.B){
    InitServerIndex()
    b.ResetTimer()
    for i:=0;i<b.N;i++{
        RandSelect()
    }
}
​
func BenchmarkSelectParallel(b *testing.B) {
    InitServerIndex()
    b.ResetTimer()
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next(){
            RandSelect()
        }
    })
}

image.png

关于上述基准测试:

  1. 对一个测试用例的默认测试时间是 1 秒,当测试用例函数返回时还不到 1 秒,那么 testing.B 中的 N 值将按 1、2、5、10、20、50……递增,并以递增后的值重新进行用例函数测试。
  2. Resttimer重置计时器,我们在reset之前做了init或其他的准备操作,这些操作不应该作为基准测试的范围。
  3. runparallel是多协程并发测试。
  4. 执行2个基准测试,发现代码在并发情况下存在劣化,主要原因是rand为了保证全局的随机性和并发安全,持有了一把全局锁。

3.5.2 基准测试-优化

为了解决上述的性能问题,字节跳动开源了fastrand库。

安装:

  • 在命令行窗口输入go get "github.com/bytedance/gopkg/lang/fastrand"
  • 在 go.mod 中的require 加上github.com/bytedance/gopkg/lang/fastrand

优化过程:

server_select.go

package main
​
import (
    "github.com/bytedance/gopkg/lang/fastrand"
    "math/rand"
)
​
var ServerIndex [10]int// InitServerIndex 初始化服务器的描述符
func InitServerIndex() {
    for i:=0;i<10;i++{
        ServerIndex[i] = i+100
    }
}
​
// RandSelect 随机选择一个服务器
func RandSelect() int  {
    return ServerIndex[rand.Intn(10)]
}
​
// FastRandSelect 用开源的的fastrand包
func FastRandSelect() int {
    return ServerIndex[fastrand.Intn(10)]
}

server_select_test.go

package main
​
import "testing"func BenchmarkSelect(b *testing.B){
    InitServerIndex()
    b.ResetTimer()
    for i:=0;i<b.N;i++{
        RandSelect()
    }
}
​
func BenchmarkSelectParallel(b *testing.B) {
    InitServerIndex()
    b.ResetTimer()
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next(){
            FastRandSelect()
        }
    })
}

image.png

使用fastrand优化后再做基准测试,可以看到性能提升了百倍。

fastrand的缺点是牺牲了一定的数列一致性,在大多数场景是适用的,后续遇到随机的场景可以尝试用一下。

4. 项目实战

4.1 需求描述

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

4.2 需求用例

用户浏览:

image.png

话题和帖子:

image.png

4.3 项目分层结构

image.png

项目结构整体分为三层:repository数据层,service逻辑层,controoler视图层

  • 数据层Repository:关联底层数据模型 Model ,封装外部数据的增删改查。数据层面向逻辑层,对service层透明,屏蔽下游数据差异,也就是不管下游是文件,还是数据库,还是微服务等,对service层的接口模型都是不变的。
  • 逻辑层Service:处理核心业务逻辑,计算打包业务实体 Entity ,利用数据层得到封装好的数据再次封装并传到视图层。Service层不关心底层数据的存储形式,只关心核心业务输出。
  • 视图层Controller:处理和外部的交互逻辑,以 View 视图的形式返回给客户端。Controller层负责和客户端交互的过程,只关心返回给客户端的数据格式。