Go工程实践 | 青训营

91 阅读7分钟

3-工程实践

3.1 语言进阶

3.1.1 并发 VS并行

并发:多线程程序在一个核的CPU上运行,实质是时间片的切换

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

并行可以被理解为是实现并发的一个手段

GO能够实现高并发

3.1.2 Goroutine

协程:用户态,轻量级线程,栈是KB级别;创建和调用由go语言本身完成

例子:简单开启一个协程,在调用函数前加go关键字

输出为乱序输出,因为是并行的

package main

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

附:建立一个项目,进入到项目文件夹后建立.mod文件

参考(53条消息) 使用VS code快速搭建一个Golang项目_vscode创建go项目_酷尔。的博客-CSDN博客

屏幕截图 2023-07-31 212741.png

3.1.3 CSP

CSP:communicating sequential processes

协程间通信:通过通信来共享内存 而不是 通过共享内存而实现通信

通道channel:把协程进行连接,相当于一个传输队列,遵循先进先出,保证数据的收发顺序。将数据从一个goroutine传输给另一个

也保留使用共享内存进行通信:通过互斥量,临界区

3.1.4 channel

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

无缓冲通道make(chan int) :同步通道

有缓冲通道make(chan int, 2) :解决同步问题,典型的生产消费模型

package main

import (
	"fmt"
)

func main() {
	src := make(chan int)//无缓冲队列
	dest := make(chan int, 3)//有缓冲,消费者的消费速度可能更慢,慢于生产中;解决生产和消费速度不均衡的问题
	//A协程生产0~9数字
	go func() {
		defer close(src)//延迟的自行关闭
		for i := 0; i < 10; i++ {
			src <- i	//把生产的数字发送到src这个channel
		}
	}()
	//B协程接收A,并计算
	go func() {
		defer close(dest)
		for i := range src { //遍历通道只有一个参数i
			dest <- i * i
		}
	}()
	//接收B协程
	for i := range dest {
		fmt.Println(i)
	}
}

输出:0 1 4 9 16 25 36 49 64 81

3.1.5 并发安全Lock

通过共享内存来实现通信,存在多个核同时操作一个内存地方的情况

开发过程中,要避免对共享内存的非并发安全的读写操作

例子:5个协程并发执行对同一变量做两千次操作

package main

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

var(
	x int64
	lock sync.Mutex
)

func addWithLock(){
	for i:=0;i<2000;i++{
		lock.Lock()	//加锁,通过临界区控制
		x++
		lock.Unlock()	//解锁
	 }
}

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

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

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

输出:

withLock: 10000 withoutLock: 8096 (随机)

3.1.6 WaitGroup

前面用Sleep做暴力阻塞,不够优雅

不明确子协程精确地结束时间,因此无法精确地Sleep

WaitGroup包括3个方法:

Add(delta int) 计数器+delta

Done() 计数器-1

Wait() 阻塞直到计数器为0

例子:n个并发协程任务,计数器可以+n,每个任务结束后可以调用Done方法使计数器-1,计数器为0表示所有并发任务都已经完成。

package main

import (
	"sync"
	"fmt"
)

func main(){
	var wg sync.WaitGroup
	wg.Add(5)
	for i := 0; i < 5; i++{
		go func(j int){
			defer wg.Done()	//每个协程执行完后调用done方法对计数器-1
			fmt.Println("hello ",j)
		}(i)
	 }
	 wg.Wait()	//阻塞
}

输出:乱序随机

hello 3 hello 1 hello 2 hello 0 hello 4

3.2 依赖管理

3.2.1 背景

依赖指各种开发包,他人已经开发好的包或经过验证的工具。

工程项目不可能基于01编码搭建

管理依赖库

3.2.2 Go的依赖管理

Go的依赖管理演进:GOPATH->Go Vendor->Go Module(广泛应用)

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

GOPATH

  • go语言支持的环境变量,go项目的工作区
  • 包括bin -项目编译的二进制文件,pkg -项目编译的中间产物,加速编译,src -项目源码
  • 项目代码直接依赖src下的源码
  • go get 下载最新版本的包到src目录下
  • 弊端 :项目A和B依赖于同一package的不同版本,但是该package没有前后兼容,因此无法在本地实现package的多版本控制,AB无法同时实现

Go Vendor

  • 项目目录下增加vendor文件夹,与main.go同级,vendor存放当前项目依赖的副本
  • 项目的依赖会优先从vendor目录获取,若vendor没有,再到GOPATH下寻找
  • 通过每个项目引入一份依赖的副本,解决多个项目需要同一个package依赖的冲突问题
  • 弊端 :项目A依赖于包B包C,而包BC分别依赖于包D的版本1版本2,这两个版本不兼容。又可能出现依赖冲突,导致编译错误。

Go Mudule 新版本默认开启

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

3.2.3 依赖管理3要素

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

3.2.4 依赖配置-go.mod

三部分组成

屏幕截图 2023-08-01 175048.png

①模块路径

②依赖的go的原生库的版本号,不同项目需要的不同

③单元依赖Module Path Version/Pseudo-version ,可以唯一地定位仓库的某个版本或某次提交

3.2.5 依赖配置-version

两种版本规则

①语义化版本${MAJOR}.${MINOR}.${PATCH}

​ MAJOR:属于大版本,不同MAHOR可以不兼容

​ MINOR:新增函数等,兼容

​ PATCH:做代码bug的修复

​ 例:V1.2.0,V1.3.0

②基于commit伪版本xX.0.0-yyyymmddhhmmss-abcdefgh1234

​ 版本前缀:与语义化版本一样

​ 时间戳

​ 哈希码前缀

​ 提交代码时go默认生成伪版本

3.2.6 依赖配置-关键字

indirect :A->B->C,则A对B直接依赖,A对C间接依赖;没有直接导入的模块会有indirect关键字

incompatible :主版本2+模块会在模块路径增加**/vX后缀** 如example/lib5/v3 v3.0.2;没有go.mod文件且主版本2+的依赖,会**+incompatible** 如example/lib6 v3.2.0+incompatible

3.2.7 依赖配置-依赖图

运行版本的算法:选择最低的兼容版本

(已保证1.3 1.4兼容)选择v1.4

屏幕截图 2023-08-01 183422.png

3.2.8 依赖分发

去哪下载,如何下载的问题

回源

Github、SVN、···

直接使用版本仓库下载依赖出现的问题:

①无法保证构建稳定性:增加/修改/删除软件版本。例如:之前依赖的版本找不到了

②无法保证依赖可用性:删除软件。例如:作者对软件仓库进行了删除

③增加第三方压力:代码托管平台负载问题。

示意图:

屏幕截图 2023-08-01 183429.png

Proxy

是个服务栈,会缓存原栈中的软件内容,缓存的版本不变。若作者删除或修改某个版本,可以保证依赖的稳定性。

直接从GOPROXY拉取依赖

示意图:

屏幕截图 2023-08-01 183436.png

变量GOPROXY

GOPROXY是url列表,用逗号分隔,direct表示源站-前面站点都没有依赖的话回源到第三方平台

例:GOPROXY="https://proxy1.cn,https://proxy2.cn,direct" 查找依赖的路径P1->P2->direct

go.mod通过控制GOPROXY的环境变量来控制proxy的配置

3.2.9 工具

go get

​ go get example.org/pkg默认MAJOR版本的最新

​ @update 默认

​ @none 删除依赖

​ @v1.1.2 tag版本,会拉取特定的语义版本

​ @23dfdd5 特定的commit

​ @master 拉取特定的分支,分支的最新commit

go mod

​ init 初始化项目时需要用到,用来创建go.mod文件 每个项目开始前必须的步骤

​ download 下载模块到本地缓存,即把所有依赖拉下来

​ tidy 增加需要的依赖,删除不需要的依赖。每次提交代码时都可以执行该命令,可以减少非必要的依赖,减少整个项目的时间

3.3 测试

类型

①回归测试:终端

②集成测试:对系统功能进行验证

③单元测试:测试开发阶段,开发者对单个函数功能进行测试

覆盖率逐渐增加,但成本逐渐降低。

3.3.1 单元测试

保证质量:新代码不影响旧代码的测试

提升效率:有bug,在本地就可以定位问题

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

  • 测试函数命名规范func TestXxx(t *testing.T)

    • 初始化逻辑放到 TestMain 中,如func TestMain(m *testing.M){ }

      ​测试前:数据装载、配置初始化等前置工作

      ​测试后:释放资源等收尾工作

Ⅱ例子
package main
import (
	"testing"	//导入包
)

func HelloTom() string {
	return "Jerry"
 }

func TestHelloTom(t *testing.T){
	output := HelloTom()
	expectOutput := "Tom"
	if output != expectOutput {
		t.Errorf("Expected %s do not match actual %s",expectOutput,output)
	 }
}
func main(){

}
Ⅲ 运行

屏幕截图 2023-08-01 190159.png

Ⅳ assert

使用assert包可以直线equal或not equal的比较,而不用自己写不等符和打印

格式assert.Equal(t,expectOutput,output)

==所有的包需要go get之后import才能使用==

package main
import (
	"testing"
	"github.com/stretchr/testify/assert"	//无法访问--原因是没有go get,但assert写法如下
)

func HelloTom() string {
	return "Jerry"
 }

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

}

屏幕截图 2023-08-01 213723.png

Ⅴ 覆盖率

go test judgment_test.go judgment.go --cover

计算:代码被执行了多少

提升覆盖率:不同分支的函数

对各个分支进行测试,保证了测试的完备性

Ⅵ Tips
  • 一般覆盖率50%~60%保证主流程,较高覆盖率80%
  • 测试分支相互独立,全面覆盖
  • 测试单元粒度要足够小,则要求函数单一职责

3.3.2 单元测试-依赖

依赖数据库、cache、本地文件

单元测试的目标:幂等&&稳定

幂等:重复运行一个测试时每次结果相同

稳定:单元测试是相互隔离的,单元能在任何时间运行

Ⅰ 文件处理

本地文件,若被修改那么测试文件就失效了

main.go

package main

import (
	"bufio"
	"os"
	"strings"
)

func ReadFirstLine() string {
	open, err := os.Open("log")	//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
}

main_test.go

package main
import(
	"testing"
	"assert"
)

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

3.3.3 单元测试-Mock(打桩)

有很多,常用的包 如mockey : github.com/bouk/monkey

快速Mock函数

  • 为一个函数打桩
  • 为一个方法打桩
  • 打桩:用函数A去替换函数B,那么B是原函数,A就是打桩函数

Patch(target, replacement interface{})

target是原函数,目标被替换的函数

replacement是需要打桩的函数

Unpatch(target interface{}) 打桩结束后需要把桩卸载掉

package main
import(
	"testing"
	"github.com/stretchr/testify/assert"
	"bou.ke/monkey"
)

func TestProcessFirstLineWithMock(t * testing.T) {
	monkey.Patch(ReadFirstLine, func() string {	//对ReadFirstLine打桩测试,不再依赖本地文件
		return "line110"
	})
	defer monkey.Unpatch(ReadFirstLine)
	firstLine := ProcessFirstLine()
	assert.Equal(t, "line000",firstLine)
 }

不再依赖本地文件,该测试可以任何时间任何地点进行。

屏幕截图 2023-08-01 213927.png

3.3.4 基准测试

  • 优化代码,需要对当前代码进行分析
  • 内置的测试框架提供了基准测试的能力
  • ==代码文件要以_test.go==
  • 运行终端命令:go test -bench=.
Ⅰ例子

并行基准测试 性能有劣化,原因在于rand

package main
import(
	"math/rand"
	"fmt"
	"testing"
)

var ServerIndex [10] int

func InitServerIndex(){
	for i := 0; i < 10; i++{
		ServerIndex[i] = i+100
	 }
}

func Select() {
	
	var n = rand.Intn(10)
	fmt.Println(ServerIndex[n])
 }

func BenchmarkSelect(b *testing.B){
	InitServerIndex()//初始化
	b.ResetTimer()	//定时器的重置,init不属于测试时间之内,因此需要除去
	for i := 0; i < b.N; i++{
		Select()
	 }
}
//并行基准测试 性能有劣化,原因在于rand
func BenchmarkSelectParallel(b *testing.B){
	InitServerIndex()
	b.ResetTimer()
	b.RunParallel(func (pb * testing.PB) {
		for pb.Next() {
			Select()
		 }
	 })
}
Ⅱ 优化

?fastrand

3.4 项目实战

项目开发的流程:需求设计-代码开发-测试运行

3.4.1 需求设计

Ⅰ 需求描述

社区话题页面

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

浏览消费用户:Topic,PostList

Ⅲ ER图 实体类关系图

一对多的关系

3.4.2 分层结构

  • 数据层:关注数据Model,封装外部数据的增删改查
  • 逻辑层:业务Entity,处理核心业务逻辑输出,不关心底层数据的存储,service对数据进行封装
  • 视图层:视图view,处理和外部的交互逻辑

3.4.3 组件工具

Gin 高性能go web框架 github.com/gin-gonic/g…

Go Mod

创建新的文件夹-终端进入到该项目目录-go mod init- 终端go get gopkg.in/gin-gonic/gin.v1@v1.3.0

3.4.4 Repository

对于两个结构体需要实现基本的查询操作 QueryTopicById 和QueryPostsByParentId

因为有文件,所以可以使用全扫描遍历的方式,一个个对比,类似于MySQL的全面扫描。可以达到目的,但不是最高效的。

Ⅰ 索引

将数据行映射成内存Map,Map时间复杂度是O(1),因此可以很快定位到复杂的数据

两个索引

//两个索引
var (
	topicIndexMap map[int64] *Topic
  	postIndexMap mao[int64][]*Post
)

初始化 话题 数据索引

func initTopicIndexMap(filePath string) error{
  open, err := os.Open(filePath+"topic")//打开文件
  if err!=nil{
    return err
  }
  scanner := bufio.NewScanner(open)
  topicTmpMap := make(map[int64]*Topics)
  //迭代器的方式把数据行做一个遍历,转化为结构体,存储到内存Map里
  for scanner.Scan(){
    text := scanner.Text()
    var topic Topic
    if err := json.Unmarshal([]byte(text),&topic); err!=nil{
      return err
    }
    topicTmpMap[topic.Id] = &topic //存储到内存map里
  }
  topicIndexMap = topicTmpMap
  return nil
}

初始化 回帖 数据索引

Ⅱ查询

索引:话题ID

数据:话题

索引:话题ID

数据:回帖list

代码完成后就可以给逻辑层

3.4.5 Service

实体

type PageInfo struct{
  Topic *repository.Topic
  PostList []*reepository.Post
}

流程:参数校验->准备数据->组装实体

运行:go run server.go