这是我参与「第五届青训营」伴学笔记创作活动的第 2 天
[ Go依赖管理与工程实践 | 青训营笔记 ]
零、前言:
本文记录和整理了本人在跟随字节青训营学习的一些我个人感觉比较重要的内容和知识,也有一部分内容是我认为自己比较难理解或记忆的,也一并记录于此文。
撰写本文的目的主要是方便我自己的复习和查阅,倘若各位读者有与我相似的问题,也可以参考之,如果对各位有帮助那就是我莫大的荣幸,也期望各位不吝赐教,多多指出我的问题,可以在下方留言或者私信我。
一、本堂课重点内容:
- 并发的深入
- 协程Goroutine
- 通道Channel
- 锁与并发安全
- 线程同步WaitGroup
- 依赖管理
- Gopath
- Go Vendor
- Go Module
- 单元测试
- 单元测试概念和规则
- Mock测试
- 基准测试
- 项目实战
二、详细知识点介绍:
1. 并发编程
并发与并行的直观理解
Go语言为什么这么快?
协程Goroutine
- 协程:用户态,轻量级线程,栈KB级别。
- 线程:内核态,线程跑多个协程,栈MB级别。
所以Go语言一次可以创建上万级别的协程。
通过go关键字来开启goroutine;
goroutine是一个轻量级线程,其调度是由Golang运行时进行管理的。
package main
import (
"fmt"
"time"
)
func HelloPrint(i int) {
// println("Hello goroutine : " + fmt.Sprint(i))
fmt.Println("Hello goroutine :", i)
}
// 效果就是快速且无序打印
func HelloGoroutine() {
for i := 0; i < 5; i++ {
// 匿名函数是为了保护变量(大概)
go func(j int) {
HelloPrint(j)
}(i)
}
// time.Sleep()的作用是:保证了子协程在执行完之前,主协程不退出。
time.Sleep(time.Second)
}
func main() {
HelloGoroutine()
}
通道Channel
- 无缓冲通道也被称为同步通道;
- 有缓冲通道可以类比红石中继器(但是有点不一样,只是效果类似);
通道是用来传递数据的一个数据结构,可以用于两个goroutine之间,通过传递一个指定类型的值来同步运行和通讯。
操作符<-用于指定通道的方向,实现发送or接收;
特别地,若未指定方向,则为双向通道;
通道在使用前必须先创建;
ch <- v // 把 v 发送到通道 ch
v := <-ch // 从 ch 接收数据 并把值赋给 v
package main
import (
"fmt"
)
func CalcPow() {
src := make(chan int)
dest := make(chan int, 3)
// 子协程src发送0~9数字
go func() {
defer close(src) // 当子协程src结束的时候再关闭,减少资源浪费
for i := 0; i < 10; i++ {
src <- i
}
}()
// 子协程dest计算输入数字的平方
go func() {
defer close(dest)
// 通过 range 关键字来实现遍历读取到的数据
for i := range src {
dest <- (i * i)
}
}()
// 主协程输出最后的答案
// 这里可以暂时认为子协程需要使用匿名函数
for i := range dest {
// 因为主协程可能会有更多的复杂操作,比较耗时,所以用带缓冲的通道可以避免问题
fmt.Println(i)
}
}
func main() {
CalcPow()
}
两个视频里没有提到的细节:
如果通道不带缓冲,发送方会阻塞直到接收方从通道中接收了值。如果通道带缓冲,发送方则会阻塞直到发送的值被拷贝到缓冲区内;如果缓冲区已满,则意味着需要等待直到某个接收方获取到一个值。接收方在有值可以接收之前会一直阻塞。
range 函数遍历每个从通道接收到的数据,因为 src 在发送完 5 个数据之后就关闭了通道,所以这里我们 range 函数在接收到 5 个数据之后就结束了。如果上面的 src 通道不关闭,那么 range 函数就不会结束,从而在接收第 6 个数据的时候就阻塞了。
fatal error: all goroutines are asleep - deadlock!
defer语句补充说明:
加上defer之后就会进入一个栈内,如下程序输出结果就是333 222 111
package main
import "fmt"
func main() {
defer fmt.Println("111")
defer fmt.Println("222")
defer fmt.Println("333")
}
init函数补充说明:
程序初始化顺序:变量初始化 -> init() -> main()
package main
import "fmt"
var v int = VarInit()
func init() {
fmt.Println("INITIALIZE!")
}
func VarInit() int {
fmt.Println("The initialize of var is completed!")
return 110
}
func main() {
fmt.Println(v)
}
/*
Output:
The initialize of var is completed!
INITIALIZE!
110
*/
并发安全Lock
当采用共享内存实现通信(上图的第二种)的时候,会发生一些Undefine问题,这时候就需要并发安全。
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
*/
WaitGroup实现同步:
package main
import (
"fmt"
"sync"
)
func HelloPrint(i int) {
fmt.Println("Hello WaitGroup :", i)
}
func ManyGoWait() {
var wg sync.WaitGroup
wg.Add(5)
for i := 0; i < 5; i++ {
go func(j int) {
defer wg.Done()
HelloPrint(j)
}(i)
}
wg.Wait()
}
func main() {
ManyGoWait()
}
2. 依赖管理
依赖管理的发展历史
GOPATH
是一个环境变量,其中有三个部分:
- bin:项目编译的二进制文件
- pkg:项目编译的中间产物,加速编译
- src:项目源码,项目代码直接依赖src下的代码
go get下载最新版本的包到src目录下
存在的弊端:无法实现package的多版本控制
Go Vender
项目目录下增加vender文件,所有依赖包副本形式放在$ProjectRoot/vender
依赖寻址方式:vender -> GOPATH
通过每个项目引入一份依赖的副本,解决了多个项目需要同一个package依赖的冲突问题。
存在的弊端:更新项目的时候可能导致编译错误的冲突;无法控制依赖的版本。
Go Module(1.16以后默认开启)
- 通过
go.mod文件管理依赖包版本 - 通过
go get/go mod指令工具管理依赖包
终极目标:定义版本规则和管理项目依赖关系
依赖管理的三要素
- 配置文件,描述以来——
go.mod - 中心仓库管理依赖库——
Proxy - 本地工具——
go get/mod
下面是详细一些的解释:
go.mod
version的两种类型:
- 语义化版本
- 基于commit伪版本
indirect
对于没有直接表示的模块会在go.mod中加上// indirect,例如(A->B->C)
incompatible
- 主版本2+模块会在模块路径增加
/vN后缀 - 对于没有
go.mod文件并且主版本2+的依赖,会加上+incompatible,表示可能会存在一些不兼容的代码逻辑
一个例子:
依赖分发-回源
说人话就是这个依赖要去哪里下载,如何下载的问题。
实际上是用Proxy来缓存,保证了依赖的稳定性;
依赖分发-变量 GOPROXY
查找依赖的逻辑:如果最后都没找到就会回到第三方Direct
工具 go get
3. 单元测试
单元测试
说明:此处尚未实操,各部分内容待补充和修正
所以说,单元测试的质量某种程度上决定了代码的质量。
测试规则
- 所有测试文件以_test.go结尾
- func TestXxx(*testing.T)
- 初始化逻辑放到TestMain中
参考文章:https://juejin.cn/post/6908938380114034701
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"
}
代码覆盖率
- 衡量代码是否经过了足够的测试
- 评价项目的测试水准
- 评估项目是否达到了高水准测试等级
func JudgePassLine(score int16) bool {
if score >= 60 {
return true
}
else {
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%~60%,较高覆盖率:80%+ - 测试分支相互独立、全面覆盖
- 测试单元粒度足够小,函数单一职责
单元测试-依赖
- 幂等:重复运行同一个case,结果与之前一致
- 稳定:指单元测试相互隔离,可以独立运行
单元测试-文件处理
这是一个学习Mock的前置例子
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
monkey: https://github.com/bouk/monkey
这是一个开源的mock测试库,可以对method或者实例的方法进行mock
Monkey Patch的作用域在Runtime,
运行时通过Go的unsafe包能够将内存中函数的地址替换为运行时函数的地址,将待打桩函数或方法的实现跳转。
快速Mock函数:
- 为一个函数打桩
- 为一个方法打桩
// 用函数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的程度;
在实际的项目开发中,经常会遇到代码性能瓶颈,为了定位问题,经常要对代码做性能分;
这时就用到了基准测试,其使用方法与单元测试类似。
- 优化代码,需要对当前代码分析
- 内置的测试框架提供了基准测试的能力
这里暂时不过多做展开。
4. 项目实战
需求模型与描述
青训营话题页forum.juejin.cn/youthcamp/p…
需求:社区话题页面
- 实现一个展示话题(标题,文字描述)和回帖列表的后端http接口;
- 本地文件存储数据(暂不考虑前端页面实现,仅实现一个本地web服务)
- 话题和回帖数据用文件存储
组件及技术点
- web框架:Gin - github.com/gin-gonic/g…
- 了解go web框架的简单使用
- 分层结构设计:github.com/bxcodec/go-…
- 了解分层设计的概念
- 文件操作:读文件pkg.go.dev/io
- 数据查询:索引www.baike.com/wikiid/5527…
具体逻辑与框架:
需求用例
知识点扫盲:
ER图(Entity Relationship Diagram)
分层结构
下面是一个比较通用的分层模型:
- 数据层:数据Model,封装外部数据的增删改查。我们的数据存储在本地文件,通过文件操作拉取话题和帖子数据。数据层面向逻辑层,对逻辑层透明,屏蔽下游数据差异,也就是无论下游是文件,还是数据库或者微服务,对于逻辑层的接口模型是不变的。
- 逻辑层:业务Entity,处理核心业务逻辑输出。对应我们的需求,也就是话题页面,包括话题和回帖列表,并上送给视图层。
- 视图层:试图View,处理和外部的交互逻辑,我们封装JSON格式化的请求结构,用API形式访问就可以了。
组件工具
- Gin:高性能Go web框架
- Go Mod
关于Gin,笔者也正在学习中,有关内容记载于学习记录丨GoLang基础 | 落雨乄天珀夜 (gitee.io)
实际操作部分:
Repository(数据层)
根据之前的ER图,定义结构体如图所示
// Topic
{
"id":1,
"title":"青训营开始了!",
"content":"欢迎踊跃报名!",
"create_time":1000000007
}
// Post
{
"id":1,
"parent_id":1,
"content":"欢迎欢迎热烈欢迎!",
"create_time":1000000007
}
如何实现查询?显然暴力遍历是可行的,但是不行;
可以采用索引的概念,类似于书的目录,可以引导我们快速查找定位我们需要的结果。
这里采用神奇的map来实现内存索引,在服务对外暴露前,利用文件元数据初始化全局内存索引,这样就可以实现O(1)的时间复杂度查找操作。
var (
topicIndexMap map[int64]*Topic
postIndexMap map[int64][]*Post
)
初始化话题内存索引具体实现:
func initTopicIndexMap(filePath string) error {
// 打开文件
open, err := os.Open(filePath + "topic")
if err != nil {
return err
}
// 基于file初始化scanner
scanner := bufio.NewScanner(open)
topicTmpMap := make(map[int64]*Topic)
// 通过迭代器方式遍历数据行,转化为结构体存储至内存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
}
topicIndexMap = topicTmpMap
return nil
}
查询操作:直接根据key获得map中的value
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
}
Service层(逻辑层)
流程:
部分参考代码如下:
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
}
func (f *QueryPageInfoFlow) checkParam() error {
if f.topicId <= 0 {
return errors.New("topic id must be larger than 0")
}
return nil
}
可用性:
Controller(视图层)
- 构建View对象
- 业务错误码
至此,业务逻辑部分完毕。
Router
通过Gin搭建外部框架:
- 初始化数据索引
- 初始化引擎配置
- 构建路由
- 启动服务
最后go run server.go测试运行
三、课后实践
- 支持对话题发布回帖。
- 回帖id生成需要保证不重复、唯一性。
- 新加回帖追加到本地文件,同时需要更新索引,注意Map的并发安全问题 。
笔者完成后会在此处进行补充内容,包括但不限于关键部分以及一些踩坑经验。
未完待续...(随时补充修改)
谢谢大家的阅读,欢迎互动,也欢迎访问我的博客!