Go语言的依赖管理 | 青训营笔记

69 阅读6分钟

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

Go语言并发编程

并发与协程

在Java语言中,更多地会强调进程、线程和线程池的概念,而在Go语言中,经常会使用比线程更轻量级的协程,线程与协程的对比如下所示,Go语言一次可以创建上万级别的协程。

  • 协程:用户态,轻量级线程,栈KB级别。
  • 线程:内核态,线程跑多个协程,栈MB级别。

Go将协程和并发简化到了仅需一个 go 关键字即可完成,而不像其他语言的协程一样及其繁琐复杂。

package main
​
import (
    "fmt"
    "time"
)
​
func say(s string) {
    for i := 0; i < 5; i++ {
        time.Sleep(100 * time.Millisecond)
        fmt.Println(s)
    }
}
​
func main() {
    go say("world")
    say("hello")
    time.Sleep(time.Second)
}

主协程main是一个特殊的协程,如果主协程执行完毕,那么其他子协程也会停下手上的活,直接退出。这里的go协程有点类似于Java中的守护线程的意思,因此,当我们有多个子协程执行时,应该等待这些协程全部执行完毕后,再结束主协程

WaitGroup

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()
}
  • var wg sync.WaitGroup 声明了一个名为 wgWaitGroup 变量;
  • 调用 WaitGroup 实例的 Add 方法,传入数字 5
  • 在匿名函数开头,延迟调用 WaitGroup 实例的 Done 方法;
  • 在函数尾部调用 WaitGroup 实例的 Wait 方法。

defer 关键字代表“在函数末尾执行”,被 defer 关键字标注的代码会以 先进后出 的顺序被移动到函数末尾执行。比如,我们打开了一个 IO 流,需要在函数结束时释放流(调用 Close 方法),这时,我们便可以直接在打开的流实例下直接填写,来确保流一定会在函数结束时释放,避免遗忘。

WaitGroup 其实内部维护了一个计数器,并以如下方式工作:

  • 通过调用 Add 方法,向 WaitGroup 的计数器添加指定值;
  • 通过调用 Wait 方法阻塞当前协程,这会使得协程陷入无限的等待;
  • 通过调用 Done 方法使 WaitGroup 内部的计数器 -1,直到计数器值为 0 时,先前被阻塞的协程便会被释放,继续执行接下来的代码或是直接结束运行。

因此,上述代码为 WaitGroup 的计数器 +5,随后阻塞主协程,当所有 5 个子协程纷纷调用 Done 方法后,主协程便会被释放,然后结束程序运行。

Channel

通道是协程之间用来传递数据的一个数据结构,通过传递一个指定类型的值来同步运行和通讯。操作符<-用于指定通道的方向,实现发送or接收。

通道分为有缓存的通道和无缓存的通道,无缓存 Channel 意味着,一个数据的发送必须等待另一端代码的接收,如果没有人接收发送的数据,那么发送端便会被永远阻塞。这意味着,Channel 内可以有最多N个数据未被接收方接收,此时,发送方可以直接发送数据而不必收到阻塞,如果超出缓冲区,则依旧会被阻塞。

在使用通道的过程中,可以通过使用 for range 的方式来从一个 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依赖管理

Go Module

Go Module 通过项目路径中的 go.mod 文件来配置文件,描述依赖;然后,通过 go.sum 文件记录项目实际使用的依赖和版本。我们没有必要像 Java 的 Maven那样手动编辑配置文件指定依赖,Go 为我们提供了 go getgo mod 两条指令来方便的添加和移除项目中的依赖。

Go Module在GoLand 1.16以后默认开启。

依赖分发

依赖分发就是这个依赖要去哪里下载,如何下载的问题。实际上是用Proxy来缓存,保证了依赖的稳定性。通过指定 Proxy 服务器,优先从 Proxy 服务器拉取依赖,这不仅减轻了源站负担,也保证了依赖的可用性,避免依赖库开发者删库跑路。

go mod 指令

go mod 是一个命令行指令,可用于初始化项目和管理依赖,在项目目录执行它以为项目配置依赖:

  • go mod init,初始化项目,这将创建 go.mod 文件
  • go mod download,下载模块到本地缓存
  • go mod tidy,添加需要的依赖,删除不需要的依赖

单元测试

通常,有多种测试方法可以使用,例如回归测试,集成测试,单元测试,而单元测试(Unit Test) 是成本最低,覆盖率最高的测试方法。所谓单元测试,便是为代码的每一个模块,函数定制单独的测试,保证输入指定值后输出正常值。通过多个单元测试合并运作,我们便可得知项目的每一个细节都在正确运行,最终得知项目整体运作正常。

测试规则

  • 所有测试文件以_test.go结尾
  • func TestXxx(*testing.T):一个单元测试函数的函数名应当以 Test 开头,并包含 *testing.T 形参
  • 初始化逻辑放到TestMain中

我们也可以引入社区提供的依赖库来加快单元测试开发,诸如通过 testify/assert 库进行覆盖率测试,或通过 bouk/monkey 库对数据进行 Mock。

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

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

func HelloTom() string {
    return "Tom"
}

// 用函数A去替换函数B,B就是原函数,A就是打桩函数

func Patch(target, replacement interface{}) *PatchGuard {
    // target就是原函数,replacement就是打桩函数
	t := reflect.ValueOf(target)
	r := reflect.ValueOf(replacement)
	patchValue(t, r)
	return &PatchGuard{t, r}
}

func Unpatch(target interface{}) bool {
    // 保证了在测试结束之后需要把这个包卸载掉
	return unpatchValue(reflect.ValueOf(target))
}

func TestProcessFirstLineWithMock(t *testing.T) {
	monkey.Patch(ReadFirstLine, func() string {
		return "line110"
	})
	defer monkey.Unpatch(ReadFirstLine)
	line := ProcessFirstLine()
	assert.Equal(t, "line000", line)
}
// 通过patch对ReadFirstLine进行打桩mock,默认返回line110,通过defer卸载mock
// 这样整个测试函数就摆脱了本地文件的束缚和依赖

基准测试

基准测试是指测试一段程序的性能及耗费CPU的程度;

在实际的项目开发中,经常会遇到代码性能瓶颈,为了定位问题,经常要对代码做性能分;

这时就用到了基准测试,其使用方法与单元测试类似。