ycgg的GO语言之路Day002——Go工程实践| 青训营笔记

46 阅读11分钟

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

Day02 -- 工程实践

1.语言进阶——并发编程

1.1 Goroutine(协程)

并发与并行

并发:多线程程序在一个核的cpu上的运行

image-20230117150627296.png

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

image-20230117150636149.png

而go语言可以充分发挥多核性能,解决高并发问题

线程与协程

线程:是内核态,消耗操作系统的性能,线程跑多个协程,栈KB级,非常消耗资源

协程:轻量级,创建协程不需要消耗操作系统的资源,编程语言自身就能完成这项操作,所以协程也被称作用户态线程。栈MB级

如何开启协程

实例:快速打印hello groroutine

go 语言开启协程的方法就是在函数名前加一个人go

func hello(i int){
    println("hello goroutine : " + fmt.Sprint(i))
}
func closure() {
   for i := 0; i < 3; i++ {
      go func(j int) {
          hello(j)
      }(i)
   }
   time.Sleep(time.Second)
}

time.Sleep(time.Second)的作用是暴力阻塞,保证子协程结束前主协程不退出

输出结果如图:

image-20230117152551962.png

可以发现输出是乱序,可以知道,是并行打印的

1.2 CSP

狗语言提倡通信来共享内存,通过chanel通道(类似于消息队列,遵循先入先出)

通过chanel通道让一个goroutine到另一个goroutine

image-20230117153105635.png

由于共享内存实现数据交换时,需要对数据加锁,存在数据竞争问题,因此选择了通过通信共享内存

1.3 Channel

通道通过make关键字创建,int是要传输数据的类型

image-20230117153248813.png

无缓冲通道不用指定缓存内存,因此数据的传输是同步的,因此可以通过缓存的方法来解决

同时缓存的方式也更贴切现实中生产与用户的关系

实例:A子协程发送0~9数字,B子协程计算输入数字的平方主协程输出最后的平方数

首先创建两个子协程

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
   }
}()

B子协程计算输入数字的平方:

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

输出结果:

for i := range dest {
   println(i)
}

这个地方的输出也可以换成其他的复杂操作

image-20230117154106650.png

从输出的结果中可以看出,数据的传输是有序的

1.4并发安全LOOK

go提倡通信共享内存,但是还是不可避免的会出现共享内存通信,多个协程抢占一个资源,为了避免这种问题,就要用到锁

互斥锁sync.Mutex

sync.Mutex是一个互斥锁,可以由不同的goroutine加锁和解锁。

sync.Mutex是Golang标准库提供的一个互斥锁,当一个goroutine获得互斥锁权限后,其他请求锁的goroutine会阻塞在Lock()方法的调用上,直到调用Unlock()方法被释放。

下面是加锁不加锁的结果对比

测试是对变量x进行2000次加一操作,由5个协程并发执行,这样就会出现资源抢占

image-20230117154830435.png

加锁的函数每次执行加法时都会将资源权限关闭,直到计算完成才释放权限

观察结果发现,加锁后的结果是正确的,而不加锁出现了奇怪的结果,这就是并发安全问题,会有一定概率导致结果奇怪,切问题不好排查

因此在开发时要避免共享内存时出现并发安全问题

1.5 WaitGroup

之前我们为了子协程结束前主协程不退出,使用Sleep函数进行暴力阻塞,但是这种方法并不是最优的,因为我们不知道子协程具体的执行时间,因此,狗语言提供了sync包下的WaitGroup来解决该问题

主要有以下3个方法

image-20230117155534193.png

主要使用计数器来解决这个问题

当启动了n个并发任务时,计数器初始为n,每完成一个并发任务,调用Done方法计数器-1,用Wait阻塞,当所有并发任务完成时,计数器值为0,结束

具体实现用法如下

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()
}

2.依赖管理

在开发时,我们不能所有东西都从0开始,而是更多的使用已经开发测试好,封装好的组件,专注于业务逻辑

GO的依赖管理经历了3个阶段GOPATH -> GO VENDER -> GO MODULE

GOPATH

image-20230117160302110.png

项目所有依赖的源码都放在src目录下,这样就会出现弊端

image-20230117160358345.png 项目A依赖v1,项目B依赖v2,而v2比v1进行了升级,可能发生了变化,导致启动两个项目时会出现错误

GO VENDER

image-20230117160532953.png

项目目录下增加vendor文件,所有依赖包副本形式放在$ProjectRoot/vendor

依赖寻址方式: vendor => GOPATH

通过每个项目引入一份依赖的副本,解决了多个项目需要同一个 package依赖的冲突问题。这样即使发生升级,也会有副本存在

但是这种方式也有弊端

image-20230117160748979.png

即A依赖了B,C,但是B,C又依赖了不同版本的D,无法控制依赖的版本

GO MODULE

GO 1.16默认开启

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

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

依赖管理的三要素:

1.配置文件,描述依赖 go.mod

2.中心仓库管理依赖库 Proxy

3.本地工具 go get/mod

类似java中maven对依赖的管理,导入依赖

依赖配置-go.mod

image-20230117161219860.png

依赖管理基本单元:标识了模块路径

单元依赖又两部分组成:模块路径和版本号

依赖配置-version

1.语义化版本

MAJOR.{MAJOR}.{MINOR}.${PATCH}

MAJOR:大版本,不同MAJOR可以不兼容

MINOR:新增函数与功能,相同MAJOR下的不同MINOR要兼容

PATCH:代码bug修复

如:V1.3.0

2.基于commit伪版本

vx.0.0-yyyymmddhhmmss-abcdefgh1234

中间部分是一个提交的时间戳

后面是一个12位哈希码

依赖配置-indirect

image-20230117162028726.png

image-20230117162113500.png

对于没有直接依赖的用indirect标注

依赖配置-incompatible

image-20230117162226536.png

主版本2+模块会在模块路径增加/N后缀。

对于没有go.mod文件并且主版本2+的依赖,会+incompatible

依赖最低兼容版本选择

image-20230117162724666.png

如果X项目依赖了A、B两个项目,且A、B分别依赖了C项目的v1.3、v1.4两个版本,最终编译时所使用的C项目的版本为哪个呢?

答案是v1.4,因为go对版本的选择使用最低兼容版本,因为1.3与1.4是兼容的所以会优先选择1.4

这就是依赖最低兼容版本选择

依赖分发-回源

可以直接从github上去下载依赖,但是这样会有几个问题

1.无法保证构建稳定性增加/修改/删除软件版本,作者可以随时修改代码,导致我们本地的代码出现问题

2.无法保证依赖可用性删除软件,作者可能会删除

3.增加第三方压力,代码托管平台负载问题

依赖分发-Proxy

image-20230117163247047.png

Proxy会缓存一些软件内容,缓存的版本不会改变,通过这个中间站点,保证了代码稳定性

我们可以直接去Proxy去选择一些依赖

依赖分发-变量GOPROXY

通过控制GOPROXY环境变量来控制Proxy配置

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

是url列表,direct表示,前两个地方都没有时,会回到源站去

以下是依赖查找路径示意图:

image-20230117163638581.png

工具1-go get

image-20230117163755394.png

默认加@update,拉取最新版本

工具2-go mod

image-20230117163923736.png

每个项目开始前init去初始化创建一个go.mod文件

经过代码的修改,可能有些依赖已经不需要了,可以每次上传代码时进行tidy操作,来更新依赖

3.测试

测试是避免事故必要的屏障

image-20230117175320651.png

单元测试一定程度上决定了代码的质量

通过输入到测试单元得到输出,如我们的预期输出进行校对来验证代码

这里的测试单元可以是函数,可以是模块,一些聚合函数等等

单元测试可以提升测试效率,可以快速定位错误

1.1单元测试--规则

1)所有测试文件以_test.go结尾

2)func TestXxx(*testing.T)函数名采用驼峰命名(注意如果命名有误可能无法正常运行)

3)初始化逻辑放到TestMain中

TestMain可以进行初始化,对数据装载等前置工作,测试结束后要进行释放资源等收尾工作

image-20230117180325309.png

run就是跑pakage下所有的单元测试

1.2 实例:

现在写好一个HelloTom函数要输出Tom

image-20230117180622200.png

然后编写测试函数对输出结果校对,看是不是Tom,如果不是,说明函数功能有误

image-20230117180631518.png

尝试去Run写好的测试函数,看看得到的结果

image-20230117180853400.png

提示错误,说明与预期功能不符

1.3 单元测试--assert

通过assert包 去实现一些比较功能较为方便

"github.com/stretchr/testify/assert"

image-20230117181409702.png

将刚刚的HelloTom函数修改,再次测试,如果正确,会直接PASS

image-20230117181356294.png

1.4 单元测试--覆盖率

表示测试的覆盖度,评价测试标准

覆盖率计算实例:

image-20230117181830417.png

被测试函数是一个查看及格率的函数

在测试函数中,我们传参70

在刚刚运行测试代码的后面加 -cover即可查看覆盖率,这里我们看到覆盖率为66.7%

那如何计算覆盖率呢??

在测试时传参了70,可以看到被测试函数主要代码是3行,70的参数执行了if语句,也就是2行,2/3也就是66.7%

如何提高覆盖率??

答案是补充测试分支,让测试用例尽可能完备

如图,补充一个60分一下的测试,这样所有的代码都被测试了,覆盖率为100%

image-20230117182235162.png

在实际开发时,覆盖率一般很难达到100%

一般50~60,对于一些较高要求的业务时,要求80+,比如涉及交易业务

测试的分支要相互独立,全面覆盖

测试的单元粒度要足够小,函数单一职责

2.单元测试--依赖

单元测试要求幂等和稳定

幂等就是多次测试得到的结果相同

稳定就是在任何时间,任何条件时都可以独立的测试

但是在测试时会要求网络,因此可能需要MOCK操作

3.1单元测试--文件处理

先来看一段文件操作的代码

image-20230117183450175.png

功能函数1主要是对文件进行遍历读入,第二个功能函数式将每行中的11换成00,下面是一个测试函数

但是对于文件的问题就是测试文件可能被修改,这样就无法满足在特定场景下测试

这就需要MOCK

3.2 单元测试--MOCK

常用的mock包,monkey

 https://github.com/bouk/monkey
什么是mock?

mock 测试,当待测试的函数/对象的依赖关系很复杂,并且有些依赖不能直接创建,例如数据库连接、文件I/O等。这种场景就非常适合使用 mock 测试。简单来说,就是用 mock 对象模拟依赖项的行为。

什么是打桩?

打桩是软件测试里单元测试的一种方法,单元测试涉及手工编写测试集、指定输入数据以及为缺少的函数提供桩函数。给桩函数提供返回值叫做打桩。

image-20230117184413353.png

其中Patch可以为函数打桩,包括2个方法

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

replcement是需要打桩的函数

UnPatch是测试结束后,将桩卸载的函数

在运行时我们运行的是打桩函数,去模拟功能

我们去修改刚刚对文件操作的测试函数

image-20230117185449171.png

可以看到,打桩函数是每次都只让读line110,这样测试完全不需要依赖文件也可以了

4.基准测试

对当前代码进行分析,优化代码,而GO内置的框架提供了基准测试

测试方法类似于单元测试

一下是一个负载均衡的模拟代码,模拟随机10个服务器并进行选择

image-20230117192105991.png

下面是对服务器选择的串行,并行基准测试

基准测试函数以Benchmark开头和单元测试类似

由于服务器初始化的时间不是我们测试业务的时间范围,在开始选择前,都进行了定时器重置

image-20230117192500948.png

然后得到测试结果发现并行的时间较长

image-20230117193013550.png

这是因为并行时使用了rand函数考虑随机数量的一致性并发问题,持有了全局锁,降低了并发性能

这里调整使用FastRand函数

包:

https://github.com/bytedance/gopkg

image-20230117193523072.png

时间效率会大大提高,不过使用FastRand会使随机一直性损失,不过无伤大雅

4.项目实践

需求背景:有话题详情,帖子,回帖,点赞,恢复等

需求描述:社区话题页面,展示话题(标题,文字描述)和回帖列表

,暂不考虑前端页面实现,仅仅实现一个本地web服务,话题和回帖数据用文件存储(这里简化操作,不和数据库相连,方便理解)

对需求分析后可以画出话题topic和帖子post实体类 的E-R图

image-20230117194142216.png

分层结构:

image-20230117194332484.png

类比于java开发的三层结构模式,所做的功能类似,实际开发根据具体功能拆分结构

数据层:数据Model,外部数据的增删改查

逻辑层:业务Entity,处理核心业务逻辑输出

视图层:视图view,处理和外部的交互逻辑

Gin高性能web框架:

https://github.com/gin-gonic/gin#installation

Go Mod:

go mod init go get gopkg.in/gin-gonic/g…

1.Repository

Topic实体需要实现的操作时根据id查询话题QueryTopicById

Post实体需要实现的是根据话题id去查询相关的帖子

这里使用索引去检索数据

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

然后去初始化话题数据

打开文件,并迭代读取,存储到map中

func initTopicIndexMap(filePath string) error {
   open, err := os.Open(filePath + "topic")
   if err != nil {
      return err
   }
   scanner := bufio.NewScanner(open)
   topicTmpMap := make(map[int64]*Topic)
   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
}

回帖初始化数据与之类似,可以类比实现

func initPostIndexMap(filePath string) error{
   open, err := os.Open(filePath + "post")
   if err != nil {
      return err
   }
   scanner := bufio.NewScanner(open)
   postTmpMap := make(map[int64][]*Post)
   for scanner.Scan() {
      text := scanner.Text()
      var post Post
      if err := json.Unmarshal([]byte(text), &post); err != nil {
         return err
      }
      posts, ok := postTmpMap[post.ParentId]
      if !ok {
         postTmpMap[post.ParentId] = []*Post{&post}
         continue
      }
      posts = append(posts, &post)
      postTmpMap[post.ParentId] = posts
   }
   postIndexMap = postTmpMap
   return nil
}

接下来是查询的操作,根据话题id作为索引去查询话题

这里使用sync.Once单例模式,和java中spring的操作类似

var (
   topicDao  *TopicDao
   topicOnce sync.Once
)
func NewTopicDaoInstance() *TopicDao {
   topicOnce.Do(
      func() {
         topicDao = &TopicDao{}
      })
   return topicDao
}
func (*TopicDao) QueryTopicById(id int64) *Topic {
   return topicIndexMap[id]
}

回帖部分类似,此处不再展示

2.Service

实体类的创建

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

流程:

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

对应的主流程如下

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
}

3.Controller

构建返回信息,类似java的Result类,包含code,message,data

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

构建view视图,并对业务错误进行处理

func QueryPageInfo(topicIdStr string) *PageData {
   topicId, err := strconv.ParseInt(topicIdStr, 10, 64)
   if err != nil {
      return &PageData{
         Code: -1,
         Msg:  err.Error(),
      }
   }
   pageInfo, err := service.QueryPageInfo(topicId)
   if err != nil {
      return &PageData{
         Code: -1,
         Msg:  err.Error(),
      }
   }
   return &PageData{
      Code: 0,
      Msg:  "success",
      Data: pageInfo,
   }
​
}

4.框架搭建

初始化数据索引,舒适化引擎配置,创建陆青,发送请求,启动服务

func main() {
   if err := Init("./data/"); err != nil {
      os.Exit(-1)
   }
   r := gin.Default()
   r.GET("/community/page/get/:id", func(c *gin.Context) {
      topicId := c.Param("id")
      data := cotroller.QueryPageInfo(topicId)
      c.JSON(200, data)
   })
   err := r.Run()
   if err != nil {
      return
   }
}
​
func Init(filePath string) error {
   if err := repository.Init(filePath); err != nil {
      return err
   }
   return nil
}