Go依赖管理与工程实践 | 青训营笔记

109 阅读10分钟

Go依赖管理与工程实践 | 青训营笔记

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

一、本堂课重点内容:

go语言并发编程

同步与异步

  • 同步:一个线程调用另一个线程或者协程,被调用的线程执行完毕后,结果由主调线程来自己来获取结果,需要自身阻塞直至被调用的线程执行完毕后才能继续执行接下来的任务。
  • 异步:一个线程调用另一个线程或者协程,被调用的线程执行完毕后,由另外一个线程来接收结果,而不是主调线程自己来获取结果,主调线程无需等待被调用线程执行完毕,只需发布一个任务给被调线程,就可以继续去执行接下来的任务了,被调线程返回的结果由另外一个线程接收,随后通知主调线程处理结果或者存放到一个容器中等待主调线程处理。
  • 举个例子来说,小时候可能大家都有订过配送的牛奶吧,早期的配送的牛奶工需要把牛奶配送到每一个顾客的手中,这样的话,牛奶工配送的时候就需要等待客人自己来接收牛奶,如果顾客很久没有接收,那就没办法完成配送任务。
  • 后来牛奶公司想了个法子,就是给每一个订奶的顾客发放一个牛奶盒,给每个牛奶盒做上标记,牛奶工根据标记将各种牛奶配送到每一位顾客的牛奶盒中,这样就可以大大减少牛奶工的配送等待时间,一段时间后,牛奶工的工作效率提升了,但是存在少数情况下牛奶盒中的牛奶丢失和用户没有收到牛奶的情况。

image.png

  • 以上很容易分辨出第一种就是同步,第二种就是异步,两种优缺点显而易见:

  • 同步优点:按照顺序一步一步地执行,可以保证程序按照预想的顺序逐次执行,执行过程中结果不会出现空指针。(因为执行过程中主调线程需要等待被调线程执行完毕后才会继续执行)

  • 同步缺点:执行效率慢,主调线程需要等待被调线程执行完毕后才能继续运行

  • 异步优点:接收到任务后,只需执行完本身的职责,随后直接通知其他线程继续执行后续结果,无需关心最终结果,完成了职责的分割,使得程序在多核CPU下可以以流水线的方式运行程序,提高了CPU的利用率。

  • 异步缺点:执行过程中或者通知过程中容易丢失结果或者在某个工序中忘记处理结果,这会导致最终结果不如预期所想。异步产生的线程交错执行的场景下,某些公有变量可能被莫名的修改。

协程goroutine

Go语言高效的原因之一是对于协程的使用

  • 协程: 用户态, 轻量级线程, 栈只有KB级别
  • 线程: 内核态, 单个线程中可以运行多个协程, 栈MB级别

所以Go语言可以轻松创建上万级别的协程。 使用go 关键字来开辟一个goroutine goroutine是一个轻量级线程,其调度是由Golang运行时进行管理的。

例1

package concurrence

import (
   "fmt"
   "sync"
)

func hello(i int) {
   println("hello world : " + fmt.Sprint(i))
}

func ManyGo() {
   var wg sync.WaitGroup
   for i := 0; i < 5; i++ {
      wg.Add(1)
      go func(j int) {
         defer wg.Done()
         hello(j)
      }(i)
   }
   wg.Wait()
}
  1. defer:是用于当前线程或者协程执行完毕后调用的语句,类似于java中try-catch-finally中的finally方法,无论如何它都会执行
  2. WaitGroup:可用于并发控制,WaitGroup.add()表示添加并发任务,WaitGroup.Done()表示一个并发任务结束,WaitGroup.Wait()表示并发任务执行完毕,详细后面再介绍。

例2

package attention

import "time"

func closure() {
   // 新建一个协程,执行无参内部方法
   // 结果:=== RUN   TestClosure
   //2
   //3
   //3
   //--- PASS: TestClosure (2.00s)
   //PASS
   //主要原因是:因为新建了一个协程去执行与共享变量相关的操作,导致结果异常
   //1.Go底层做了优化 主线程并不同步等待每次println(i)结束再执行下一轮循环,而是每轮都异步地创建了一个协程异步来执行,
   //因为println(i)操作比较耗时,所以等到最后打印i的时候,i已经循环了很多次了,甚至已经循环结束了
   //2.同时在创建协程时并无传入参数,使得协程中共享变量i的数据(线程中数据,一个线程可以执行管理多个协程,其共享同一个线程中的数据)
   for i := 0; i < 3; i++ {
      go func() {
         println(i)
      }()
   }
   time.Sleep(2 * time.Second)
}

func closure1() {
   // 新建一个协程,执行有参内部方法
   // 结果:
   // === RUN   TestClosure1
   //0
   //1
   //2
   //--- PASS: TestClosure1 (2.00s)
   //PASS
   // 结果分析因为本次协程需要参数传入,参数在每一轮循环创建协程的时候已经传入协程的栈中了
   // 变量j是协程中数据(协程栈中数据)
   // 所以打印结果 0 1 2
   for i := 0; i < 3; i++ {
      go func(j int) {
         println(j)
      }(i)
   }
   time.Sleep(2 * time.Second)

}
  1. closure测试结果:
  • image.png
  1. closure结果分析:
  • 主要原因是:因为新建了一个协程去执行与共享变量相关的操作,导致结果异常
  • Go底层做了优化 主线程并不同步等待每次println(i)结束再执行下一轮循环,而是每轮都异步地创建了一个协程异步来执行,因为println(i)操作比较耗时,所以等到最后打印i的时候,i已经循环了很多次了,甚至已经循环结束了
  • 同时在创建协程时并无传入参数,使得协程中共享变量i的数据(线程中数据,一个线程可以执行管理多个协程,其共享同一个线程中的数据)
  1. closure1测试结果:
  • image.png
  1. closure1结果分析:
  • 主要原因是:因为新建了一个协程执行局部变量相关的操作,所以结果正常

通道channel

channel的逻辑图:

image.png

简单说明:

  • buf是有缓冲的channel所特有的结构,用来存储缓存数据。是个循环链表
  • sendxrecvx用于记录buf这个循环链表中的发送或者接收的index
  • lock是个互斥锁。
  • recvqsendq分别是接收(<-channel)或者发送(channel <- xxx)的goroutine抽象出来的结构体(sudog)的队列。是个双向链表

go中线程与协程之间的通讯方式

  1. 共享内存方式
  2. 基于通道方式

提倡使用channel方式进行协程间通讯的原因是:

  • 数据在通道中进行传递:在任何给定时间,一个数据被设计为只有一个协程可以对其访问,所以不会发生数据竞争。数据的所有权(可以读写数据的能力)也因此被传递。

  • 数据的流向和时间都是可知状态。

  • go中的channel类似于java netty中的PromiseFuture充当的都是一个线程或者协程之间进行数据通信的容器

  • Java中的PromiseFuture支持同步和异步的方式实现 其使用的策略是 同步:sync() 异步:addListener(new Runnable() -> {处理逻辑}) 其底层是使用了监听器模式

  • Go中的 同步:可以简单的加锁或者waitGroup来实现 异步:go中的具有特殊的方法调用方式,仅需在方法调用时加上回调函数就可以对返回的结果进行处理实现异步 应该也是使用监听器模式获取结果

例3:生产者消费者案例

package concurrence

func CalSquare() {
   // 创建一个src通道,用于协程1与协程2的数据通信
   src := make(chan int)
   // 创建一个dest通道,用于协程2与main主线程的数据通信
   // 为了匹配消费与生产速度之间的差异,dest通道是带缓冲区的通道,最多可以存放3个int类型的数据
   dest := make(chan int, 3)

   go func() {
      defer close(src)
      for i := 0; i < 10; i++ {
         // 将 i 的值输入到src通道中
         src <- i
      }
   }()

   go func() {
      defer close(dest)
      for i := range src{
         dest <- i * i
      }
   }()

   for i := range dest {
      println(i)
   }
}
  1. 使用 chan <- i 将数据i输入到通道chan中
  2. 使用range迭代从通道中取值
  3. 当生产者消费者速度不匹配时应该使用带缓冲的channel来解决

临界区与并发安全

  • 临界区:其中包含对共享变量的读写操作的代码块称之为临界区
  • 并发安全问题:并发安全问题就是为了解决共享变量被修改导致程序执行的幂等性问题,所以并发安全问题实际上就是对临界区代码的保护问题。
  1. 对于代码块 使用Mutex互斥锁来解决并发安全
package main

import (
	"fmt"
	"sync"
	"time"
)

// 除了使用channel实现同步之外,还可以使用Mutex互斥锁来解决并发安全

var (
	x    int64
	lock sync.Mutex
)

func AddWithLock() {
	for i := 0; i < 2000; i++ {
		lock.Lock()
		x += 1
		lock.Unlock()
	}
}

func AddWithoutLock() {
	for i := 0; i < 2000; i++ {
		x += 1
	}
}

func Add() {
	x = 0
	for i := 0; i < 5; i++ {
		go AddWithoutLock()
	}
	time.Sleep(time.Second)
	fmt.Println("WithoutLock :", x)

	x = 0
	for i := 0; i < 5; i++ {
		go AddWithLock()
	}
	time.Sleep(time.Second)
	fmt.Println("WithLock :", x)
}

func main() {
	Add()
}
/*
Output:
	WithoutLock : 8014
	WithLock : 10000
*/

2.对于单变量使用Atomic相关类搭配WaitGroup来实现并发安全

package main

import (
   "fmt"
   "sync"
   "sync/atomic"
)

func AtomicAdd() {
   var a int32 =  0
   var wg sync.WaitGroup
   for i := 0; i < 100000; i++ {
      wg.Add(1)
      go func() {
         defer wg.Done()
         atomic.AddInt32(&a, 1)
      }()
   }
   wg.Wait()
   fmt.Printf("use atomic a is %d\n", atomic.LoadInt32(&a))
}

func Add() {
   var a int32 =  0
   for i := 0; i < 100000; i++ {
      go func() {
         a++;
      }()
   }
   fmt.Printf("no-use atomic a is %d\n", atomic.LoadInt32(&a))
}

func main() {
   AtomicAdd()
   Add()
}

Go语言中解决并发安全的方法

  1. Mutex互斥锁
  2. Atomic类中的方法
  3. WaitGroup中的方法

2.依赖管理

GOPATH

GOPATH是go的环境变量,它包括:

  • bin: 项目编译产生的二进制文件
  • pgk: 项目编译的中间产物,后续编译器可以根据热点代码分析进行优化提速
  • src: 项目源码

弊端:无法实现多个终端的版本不一致问题

GO Vender

  • 项目路径下添加vender文件,所有依赖包副本形式会存放在$Project/vender路径下
  • 依赖寻址优先级: vender -> GOPATH
  • 通过每个项目引入一份依赖的副本,解决了多个项目需要同一个package的冲突问题
弊端:引用同名包产生版本差异问题,导致无法成功编译

Go Moudle

通过对每个包进行版本号,版本信息的标识区分各个不同的包,完成对各个同名包的区分\

通过go.mod对依赖包进行管理

go.mod常用指令

  • go mod init:初始化,创建go.mod文件
  • go mod download:下载所需的依赖包到本地仓库
  • go mod tidy:根据实际引入需求增删依赖

依赖管理三要素:

  • 配置文件 go.mod
  • 中心仓库管理依赖库 Proxy
  • 本地工具 go mod

indirect 表示当前包是间接依赖而不是直接依赖
incompatible 表示可能依赖包不兼容

依赖分发

  • 依赖分发简单来说就是如何下载依赖包,到何处下载依赖包的问题
  • 由于IP限制,有些地址我们可能无法直接访问或者访问速度很慢,所以我们需要在一些我们可访问的Proxy网站(或者称代码托管平台)去下载依赖包
  • 这实际上运用了缓存的思想

GOPROXY变量

查找依赖包的顺序:如果最后都没找到就会返回Direct

image.png

3.单元测试

测试分为:

  • 单元测试
  • 集成测试
  • 回归测试

测试规则

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

代码覆盖率

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

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

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

在没有测试50时代码覆盖率为66.7%因为有效代码为3行而运行70仅走了2行,所以2/3=66.7%

代码覆盖率的作用

  • 衡量代码是否经过了足够的测试
  • 评价项目的测试水准
  • 评估项目是否达到了高水准测试等级
  • 一般覆盖率:50%~60%,较高覆盖率:80%+
  • 测试分支相互独立、全面覆盖
  • 测试单元粒度足够小,函数单一职责

单元测试

单元测试是代码覆盖率最广的测试,所以说单元测试的质量反映了代码的质量

有效测试工具assert

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

单元测试依赖

image.png

幂等性:多次测试同一份代码,结果都一致 稳定性:测试环境相互隔离可以独立运行

单元测试文件例子

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") // 替换11为00
	return destLine
}

func TestProcessFirstLine(t *testing.T) { // 执行单元测试
	firstLine := ProcessFirstLine()
	assert.Equal(t, "line00", firstLine)
}

一旦测试文件被修改了,我们就无法重复进行测试来验证代码的正确性与稳定性,这时我们就需要Mock来处理

单元测试之Mock

  • Patch()为一个方法打桩
  • UnPatch()为一个方法卸载
// 用函数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的程度,在实际的项目开发中,经常会遇到代码性能瓶颈,为了定位问题,经常要对代码做性能分析,这时就用到了基准测试,其使用方法与单元测试类似。

  • 内置的测试框架提供了基准测试的能力(bench)
  • 优化代码,需要对当前代码分析
  • pprof工具也可以用于性能分析,且功能更丰富,可以快速定位问题代码位置

4.项目实战

需求:社区话题页面

  1. 实现一个展示话题(标题,文字描述)和回帖列表后端http接口
  2. 数据采用数据库存储

数据模型构建

image.png

E-R图

image.png

整体架构

MVC,即 Model 模型、View 视图,及 Controller 控制器。

View:视图,为用户提供使用界面,与用户直接进行交互。 Model:模型,承载数据,并对用户提交请求进行计算的模块。其分为两类: 一类称为数据承载 Bean:实体类,专门用户承载业务数据的,如 Student、User 等 一类称为业务处理 Bean:指 Service 或 Dao 对象,专门用于处理用户提交请求的。 Controller:控制器,用于将用户请求转发给相应的 Model 进行处理,并根据 Model 的计算结果向用户提供相应响应。 MVC 架构程序的工作流程: (1)用户通过 View 页面向服务端提出请求,可以是表单请求、超链接请求、AJAX 请求等 (2)服务端 Controller 控制器接收到请求后对请求进行解析,找到相应的 Model 对用户请求进行处理 (3)Model 处理后,将处理结果再交给 Controller (4)Controller 在接到处理结果后,根据处理结果找到要作为向客户端发回的响应 View 页面。页面经渲染(数据填充)后,再发送给客户端。

需要注意事项

  • 使用Gorm初始化时要注意数据库的账号密码是否按照正确的格式输入,否则会报错
  • 需要在go.mod中导入gin,gorm的依赖包
  • 导包的时候要注意同名包的问题

三、课后个人总结:

本节课后对并发编程有了更深入的理解,特别是对于channel,waitgroup,mutex互斥锁与atomic的使用与理解,此外go语言中的协程非常有特色,把线程又拆分为多个协程工作使得go程序在运行时可以使用多个goroutine来充当计算协程从而让程序运行地更加地高效
了解了Go语言中依赖管理的迭代背景和迭代流程,并初步了解了依赖管理,Proxy,go mod的一些常用的命令和基本的使用。同时了解了单元测试在实际开发中的重要性,它的测试结果决定了代码的质量优劣
最后实际开发了一个简单的web项目,初步了解了go中的常见web框架与常用的与sql交互的框架gorm

四、引用参考:

该文章部分内容引用自以下课程或者网站: