Go语言工程进阶 | 青训营笔记

98 阅读7分钟

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

这一章总结了Go语言工程进阶的内容,包括并发编程、依赖管理、单元测试和项目实战四个部分。

并发编程

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

  2. Goroutine协程

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

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

go func(参数定义){
}(传参)
  1. Go提倡通过协程间通信以共享内存。

  2. Channel通道

无缓冲通道make(chan int)

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

通过channel来实现不同协程直接的信息交互

示例:第一个协程产生数字,传入无缓冲通道src中;第二个协程从src中取出数字,平方操作后传入有缓冲通道dest中;最后在main函数中打印dest中的数字。

这里dest设置为有缓冲,是因为如果存入的数字还没来得及输出,就有新的数字生成,就会造成错误。

func main() {
	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("goroutine:", i)
	}
}

同一个程序不同协程共享同一内存,有一定概率触发并发安全问题,这里需要用到lock锁。

示例:两个函数,都是对x进行加一操作,一个无锁,一个有锁,分别执行五次,每次建立一个协程。

两个循环之间暂停一秒time.Sleep(time.Second),以免协程还未结束就直接打印输出。

var (
	x    int64
	lock sync.Mutex
)
func main() {
	x = 0
	for i := 0; i < 5; i++ {
		go withoutLock()
	}
	time.Sleep(time.Second)
	println("withoutLock:", x)
	x = 0
	for i := 0; i < 5; i++ {
		go withLock()
	}
	time.Sleep(time.Second)
	println("withLock:", x)
}
func withoutLock() {
	for i := 0; i < 20000; i++ {
		x += 1
	}
}
func withLock() {
	for i := 0; i < 20000; i++ {
		lock.Lock()
		x += 1
		lock.Unlock()
	}
}

最后输出:

withoutLock: 50543
withLock: 100000

说明没有锁的协程出现混乱。

  1. WaitGroup

上述代码成功执行主要靠time.Sleep暂停了一秒,但是实际工程中并不能准确估计需要几秒,这里需要WaitGroup。

WaitGroup实际上是一种计数器,它会阻止函数(或者一个goroutine)的执行,直到其内部计数器变为 0。

WaitGroup是并发安全的,有三个主要方法,add按给定的整数值增加 WaitGroup 计数器;done将 WaitGroup 计数器减 1,我们将使用它来指示 goroutine 的终止;wait阻止执行,直到它的内部计数器变为 0。

func main() {
	var wg sync.WaitGroup
	n := 5
	wg.Add(n)
	for i := 0; i < n; i++ {
		go func(j int) {
			defer wg.Done()
			println("goroutine:", j)
		}(i)
	}
	wg.Wait()
}

依赖管理

  1. Go Module

通过 go.mod 文件管理依赖包版本

通过 go get/go mod 指令工具管理依赖包

以达到终极目标,定义版本规则和管理项目依赖关系

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

依赖管理基本单元+原生库+单元依赖

indirect指非直接依赖,incompatible指没有go.mod文件且主版本在2以上的依赖

module github.com/Moonlight-Zhao/go-project-example

go 1.16

require (
	github.com/gin-contrib/sse v0.1.0 // indirect
	github.com/gin-gonic/gin v1.3.0 // indirect
	github.com/go-playground/validator/v10 v10.10.0 // indirect
	github.com/goccy/go-json v0.9.6 // indirect
	github.com/golang/protobuf v1.5.2 // indirect
}

某项目依赖于项目A和项目B,A依赖于1.3C,B依赖于1.4C,在C的1.3版本和1.4兼容的情况下选择1.4版本。

  1. 依赖分发proxy

GOPROXY="proxy1.cn, proxy2.cn ,direct"

proxy1=>proxy2=>direct

  1. go get
@update  默认
@none	 删除依赖
@v1.1.2	 tag版本,语义版本
@23dfdd5 特定的commit
@master	 分支的最新commit
  1. go mod
init		初始化,创建go.mod文件
download	下载模块到本地缓存
tidy		增加需要的依赖,删除不需要的依赖

Go Modules文档

单元测试

  1. 事故举例:
    1、营销配置错误,导致非预期用户享受权益,资金损失。
    2、用户提现,幂等失效,短时间可以多次提现,资金损失。
    3、代码逻辑错误,广告位被占,无法出广告,收入损失。
    4、代码指针使用错误,导致APP不可用,损失巨大。

  2. 测试举例:
    1、回归测试,在用户端进行场景测试。
    2、集成测试,对功能进行测试。
    3、单元测试,对单独的函数或模块等进行输入输出测试。

3.单元测试

所有测试文件命名以_test.go结尾;
测试文件命名.png
测试函数命名func TestXxx(*testing.T);

func TestAppendInt(t *testing.T) {
	AppendInt()
}

初始化逻辑放到 TestMain 中:

测远前的数据装载、配置初始化等前置工作;
code := m.Run();
测试后的释放资源等收尾工作

示例:

func AppendInt() {
	intArray := [3]int64{1, 2, 3}
	func(arr [3]int64) {
		arr[2] = 4
		fmt.Println("inner func array:",arr)
	}(intArray)


	fmt.Println("outer func array:",intArray)
}
import "testing"

func TestAppendInt(t *testing.T) {
	AppendInt()
}

在GoLand中可以直接运行测试文件。

=== RUN   TestAppendInt
inner func array: [1 2 4]
outer func array: [1 2 3]
--- PASS: TestAppendInt (0.00s)
PASS

可以导入assert包,使用assert.Equal来检测函数输出与期望输出是否相等。

import(
	"github.com/stretchr/testify/assert"
)
{
	assert.Equal(t, expectOutput, output)
}
  1. 评估项目单元测试测试水准的指标:代码覆盖率。

代码覆盖率指代码运行行数/没有运行的行数
在命令行输入。

PS D:\desktop\Go\go-project-example-main\attention> go test array_test.go array.go --cover
ok      command-line-arguments  1.459s  coverage: 100.0% of statements

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

  1. Mock

通过monkey.Patch对测试文件进行打桩测试,不再依赖本地file文件。

func TestProcessFirstLineWithMock(t *testing.T) {
	monkey.Patch(ReadFirstLine, func() string {
		return "line110"
	})
	defer monkey.Unpatch(ReadFirstLine)
	Line := ProcessFirstLine()
	assert.Equal(t, expected:"line000", line)
}
  1. 基准测试

可以对当前代码进行性能分析,从而优化代码。
基准测试与单元测试类似,测试函数命名成func BenchmarkXXX(b *testing.B)
示例:

var (
	ServerIndex [10]int
)

func InitServerIndex() {
	for i := 0; i < 10; i++ {
		ServerIndex[i] = i + 100
	}
}
func Select() int {
	return ServerIndex[rand.Intn(10)]
}

这里b.ResetTimer()重置时间,因为InitServerIndex()不是测试对象.
一个进行串行的基准测试,一个进行并行的基准测试b.RunParallel。

func BenchmarkSelect(b *testing.B) {
	InitServerIndex()
	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()
		}
	})
}

在命令行通过-bench命令进行基准测试

PS D:\desktop\Go\go-project-example-main\test\exe4> go test -bench=BenchmarkSelect
goos: windows
goarch: amd64
pkg: github.com/Moonlight-Zhao/go-project-example/test/exe4
cpu: Intel(R) Core(TM) i7-8550U CPU @ 1.80GHz
BenchmarkSelect-8               83171610                18.30 ns/op
BenchmarkSelectParallel-8       21975200                57.69 ns/op
PASS
ok      github.com/Moonlight-Zhao/go-project-example/test/exe4  6.341s

BenchmarkSelect-8表示逻辑CPU最多有8个,每次调用 BenchmarkSelect 函数花费 18.30 ns ,是执行 83171610次 的平均时间。

  1. fastrand

导入包

go get github.com/NebulousLabs/fastrand
import "github.com/NebulousLabs/fastrand"

将上示rand.Intn(10)改成fastrand.Intn(10)
命令行输入

PS D:\desktop\Go\go-project-example-main\test\exe4> go test -bench=BenchmarkSelect
goos: windows
goarch: amd64
pkg: github.com/Moonlight-Zhao/go-project-example/test/exe4
cpu: Intel(R) Core(TM) i7-8550U CPU @ 1.80GHz
BenchmarkSelect-8                6455589               186.0 ns/op
BenchmarkSelectParallel-8       19993602                56.66 ns/op
PASS
ok      github.com/Moonlight-Zhao/go-project-example/test/exe4  4.884s

感觉没有快百倍,但是在并发处理方面比math.rand要好

项目实战

做一个社区话题页面:
展示话题 (标题,文字描述) 和回帖列表
暂不考虑前端页面实现,仅仅实现一个本地web服务
话题和回帖数据用文件存储,不涉及使用数据库(简化使用)

  1. 对于用户user有两个行为,话题topic和帖子post

  2. ER 图-Entity Relationship Diagram

ER图.png

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

  2. 数据层repository
    创建topic和post两个结构体。

type Topic struct {
	Id         int64     `gorm:"column:id"`
	UserId     int64     `gorm:"column:user_id"`
	Title      string    `gorm:"column:title"`
	Content    string    `gorm:"column:content"`
	CreateTime time.Time `gorm:"column:create_time"`
}
type Post struct {
	Id         int64     `gorm:"column:id"`
	ParentId   int64     `gorm:"parent_id"`
	UserId     int64     `gorm:"column:user_id"`
	Content    string    `gorm:"column:content"`
	DiggCount  int32     `gorm:"column:digg_count"`
	CreateTime time.Time `gorm:"column:create_time"`
}
  1. 索引

topic需要通过post中的id查找该话题下的帖子,一种方法是遍历比对查询,一种方法是将数据行转化成内存Map,通过索引直接定位对应的post。

var (
	topicIndexMap map[int64]*repository.Topic
	postIndexMap  map[int64][]*repository.Post
)

初始化topic数据索引
读取文件,通过文件创建scanner,遍历scanner,将数据格式化成struct类型,存储在topicIndexMap中,索引为topic的id。

func initTopicIndexMap(filePath string) error {
	open, err := os.Open(filePath + "topic")
	if err == nil {
		return err
	}
	scanner := bufio.NewScanner(open)
	topicTmpMap := make(map[int64]*repository.Topic)
	for scanner.Scan() {
		text := scanner.Text()
		var topic repository.Topic
		err := json.Unmarshal([]byte(text), &topic)
		if err != nil {
			return err
		}
		topicTmpMap[topic.Id] = &topic
	}
	topicIndexMap = topicTmpMap
	return nil
}

post的数据索引与topic类似。
通过topic的id可以查询话题和帖子
这里用到sync.Once,这个适合并发状态下只执行一次的函数,即单例模式。

type Topic struct {
	Id         int64     `gorm:"column:id"`
	UserId     int64     `gorm:"column:user_id"`
	Title      string    `gorm:"column:title"`
	Content    string    `gorm:"column:content"`
	CreateTime time.Time `gorm:"column:create_time"`
}

func (Topic) TableName() string {
	return "topic"
}

type TopicDao struct {
}

var topicDao *TopicDao
var topicOnce sync.Once

func NewTopicDaoInstance() *TopicDao {
	topicOnce.Do(
		func() {
			topicDao = &TopicDao{}
		})
	return topicDao
}

func (*TopicDao) QueryTopicById(id int64) (*Topic, error) {
	var topic Topic
	err := db.Where("id = ?", id).Find(&topic).Error
	if err != nil {
		util.Logger.Error("find topic by id err:" + err.Error())
		return nil, err
	}
	return &topic, nil
}

查询到数据之后,将数据传送至逻辑层进行实体封装。

  1. 逻辑层service

创建结构体

type TopicInfo struct {
	Topic *repository.Topic
	User  *repository.User
}

type PostInfo struct {
	Post *repository.Post
	User *repository.User
}

type PageInfo struct {
	TopicInfo *TopicInfo
	PostList  []*PostInfo
}

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

func (f *QueryPageInfoFlow) Do() (*PageInfo, error) {
	if err := f.checkParam(); err != nil {
		return nil, err
	}
	if err := f.prepareInfo(); err != nil {
		return nil, err
	}
	if err := f.packPageInfo(); err != nil {
		return nil, err
	}
	return f.pageInfo, nil
}

在prepareInfo()中并行获取topic信息和回帖信息。

  1. 视图层controller

构建view对象,校验业务错误码

type PageData struct {
	Code int64       `json:"code"`
	Msg  string      `json:"msg"`
	Data interface{} `json:"data"`
}

func QueryPageInfo(topicIdStr string) *PageData {
	//参数转换
	topicId, err := strconv.ParseInt(topicIdStr, 10, 64)
	if err != nil {
		return &PageData{
			Code: -1,
			Msg:  err.Error(),
		}
	}
	//获取service层结果
	pageInfo, err := service.QueryPageInfo(topicId)
	if err != nil {
		return &PageData{
			Code: -1,
			Msg:  err.Error(),
		}
	}
	return &PageData{
		Code: 0,
		Msg:  "success",
		Data: pageInfo,
	}
}
  1. 启动服务Router

使用一个公开的goweb框架。
文档
在命令行输入:

go mod init
go get gopkg.in/gin-gonic/gin.v1@v1.3.0

就可以在go.mod里添加依赖,所有go.mod中缺少的包都可以用go get命令导入

PS D:\desktop\Go\go-project-example-main> go mod init
go: D:\desktop\Go\go-project-example-main\go.mod already exists
PS D:\desktop\Go\go-project-example-main> go get gopkg.in/gin-gonic/gin.v1@v1.3.0
go: downloading gopkg.in/gin-gonic/gin.v1 v1.3.0
go: downloading github.com/gin-contrib/sse v0.1.0
go: downloading github.com/gin-gonic/gin v1.3.0
......

GinWeb使用示例

package main

import "github.com/gin-gonic/gin"

func main() {
	r := gin.Default()
	r.GET("/ping", func(c *gin.Context) {
		c.JSON(200, gin.H{
			"message": "pong",
		})
	})
	r.Run() // 监听并在 0.0.0.0:8080 上启动服务
}

运行后访问 http://localhost:8080/ping 即可。

启动流程:初始化数据索引->初始化引擎配置->构建路由->启动服务

func main() {
	if err := Init(); err != nil {
		os.Exit(-1)
	}
	r := gin.Default()
	r.GET("/community/page/get/:id", func(c *gin.Context) {
		topicId := c.Param("id")
		data := handler.QueryPageInfo(topicId)
		c.JSON(200, data)
	})
	err := r.Run()
	if err != nil {
		return
	}
}
  1. 最后运行server.go。
go run server.go
curl --location --request GET http://0.0.0.0:8080/community/page/get/2
  1. 更多关于框架的学习

github.com/bxcodec/go-…
medium.easyread.co/golang-clea…

课后实践作业

1、支持发布帖子。
2、本地 ld 生成需要保证不重复、唯一性。
3、Append 文件,更新索引,注意 Map 的并发安全问题。