Go语言上手-工程实践 | 青训营笔记

92 阅读9分钟

Go语言上手-工程实践 | 青训营笔记

这是我参与「第三届青训营 -后端场」笔记创作活动的的第2篇笔记

(一)语言进阶

从并发编程视角了解Go高性能的本质

1 并发 VS 并行

image-20220507100709044.png

  • 并发: 多个线程在单核CPU上运行,运用时间片切换实现,同一时刻只有一个线程占据CPU
  • 并行: 多个线程在多核CPU上同时运行,同一时刻可以有多个线程同时运行

1.1 Goroutine 协程

image-20220507104859764.png

  • 协程: 用户态,轻量级线程,栈KB级别
  • 线程: 内核态,线程中可以跑多个协程,栈MB级别

开启协程

  • go 关键字,加在所调用的函数之前,为当前函数开启一个新的协程

  • 一般在主程序中加 sleep 方法是为了让主协程等待各子协程执行完毕,后续改进为WaitGroup

1.2 CSP(Communicating Sequential Processes)

  • 协程间通信,实现多个协程的同步执行

  • 1. 通道:channel

  • 2. 临界区:共享内存 ,必须采用P、V操作加锁实现对临界区的访问,影响性能

  • PS:建议使用通信实现共享内存,而不是通过共享内存实现通信

1.3 Channel --- 通道

image-20220507111809675.png

  • 声明方式make(chan 元素类型,[缓冲区大小])

    • 无缓冲通道 make(chan int),相当于同步通信

    • 有缓冲通道 make(chan int,2),见下例,典型的生产者-消费者模型,一般生产者执行较快,消费者较慢,有缓冲能够很好地平衡这种关系

  • 缓冲通道示例:

    • A 子协程发送0-9数字

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

    • 主协程输出最后的平方数

       func CalSquare(){
           src := make(chan int)//无缓冲通道
           dest := make(chan int,3)//有三个缓冲区的通道
           //开一个新的协程
           //A往通道src里面放数据
           go func(){
               //必须要设置关闭通道
               defer close(src)
               for i := 0; i < 10; i++ {
                   //往通道src里面放i的值
                   src <- i
               }
           }()
           //B从src里面取数据再放到通道dest里
           go func(){
               defer close(dest)
               for i := range src {
                   dest <- i * i
               }
           }()
           //M从dest里取数据并输出
           for i := range dest {
               //复杂操作
               println(i)
           }
       }
      

1.4 并发安全演示 Lock

  • x 是64位 int 变量,lock 是 sync 包里的 Mutex (互斥量)

image-20220507210730741.png

1.5 WaitGroup

  • 使用 sleep 实现协程的同步并不优雅,因为无法知晓子协程的具体运行时间,从而无法设置精确值

  • 用 sync 包里的 WaitGroup 代替 sleep 实现协程的休眠

  • 基本原理: 在内部维持了一个计数器,每当开启一个新的协程就+1,执行结束就-1,直到计数器清零,期间主线程持续阻塞

  • 主要方法:Add() Done() Wait()

  • 对1.1 案例进行优化

     var wg sync.WaitGroup
     wg.Add(开启线程个数)
     ​
     go func(形参名a 形参类型){
         //延迟执行Done(),计数器-1
         defer wg.Donr()
         调用函数(a)
     }(传入给a的形参b)
     ​
     //所有线程开启完成后调用Wait()阻塞主线程
     wg.Wait()
    

(二)依赖管理

了解Go语言依赖管理的演进路线

1 Go依赖管理演进

  • GOPATH ——> Go Vendor ——> Go Module
  • 不同环境(项目)依赖的版本不同
  • 控制依赖库的版本

1.1 GOPATH

  • 环境变量 $GOPATH,是 所有 go 项目的工作区,包含bin(项目编译的二进制文件)、pkg(项目编译的中间产物,加速编译)、src(项目源码) 三个目录

  • 项目代码直接依赖 src 下的代码,把所有依赖的源代码都放到 src 中

  • go get 下载最新版本的包到 src下

  • 弊端

    • 所有项目共享 src 目录,只能共享同一版本的依赖
    • 当两个不同的项目依赖于不同版本的 package 时,无法同时构建成功,“一山不容二虎”,无法实现 package 的多版本控制

1.2 Go Vendor

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

  • 依赖寻址方式vendor => GOPATH,首先去 vendor 里面找,找不到再去 GOPATH 找

  • 通过每个项目引入一份依赖的副本,解决了多个项目需要同一个 package 依赖的冲突问题,每个项目在vendor中存储自己所需要的依赖,版本可以不同

  • 弊端

    • 当出现依赖传递时,即同一个项目中的两个依赖包又分别需要依赖另一个包的不同版本时,就无法实现,会出现不兼容,原因与GOPATH弊端原因类似
    • 无法控制依赖的版本
    • 更新项目又可能出现依赖冲突,导致编译出错

1.3 Go Module

  • 通过go.mod 文件管理依赖包版本
  • 通过go get/ go mod指令工具管理依赖包
  • 通过定义版本规则和管理项目依赖的关系来实现,为不同依赖包的不同版本建立映射规则

2 依赖管理三要素

  • 配置文件,描述依赖 go.mod 描述项目所需的依赖及其版本关系
  • 中心仓库管理依赖库 Proxy 存放所有依赖资源包
  • 本地工具 go get / go mod 将仓库中的依赖获取到本地

2.1 go.mod

  • 模块路径: 标识模块,有github前缀表示可以从Github仓库中找到

  • 依赖标识[Module Path][Version/Pseudo-version]模块路径 + 语义化版本/伪版本号

  • 原生sdk版本

     module example/project/app             //依赖管理基本单元
     ​
     go 1.16                                //原生库
     ​
     require(                               //单元依赖
         example/lib1 v1.0.2
         example/lib2 v1.0.0 // indirect
         example/lib3 v0.1.0-20190725025543-5a5fe074e612
         example/lib4 v0.0.0-20180306012644-bacd9c7ef1dd   // indirect
         example/lib5/v3 v3.0.2
         example/lib6 v3.2.0+incompatible
     )
    

2.2 version

  • gopath 和 govendor 都是源码副本方式依赖,没有版本概念,gomod定义了版本规则

  • 语义化版本:

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

    V1.3.0

    V2.3.0

    • 不同MAJOR版本表示不兼容的API(不同库),即使在同一个库中,只要MAJOR版本不同便代表不是同一个模块
    • 不同MINOR版本表示新增函数或功能,可以向后兼容
    • 不同PATCH版本表示修复bug
  • 基于commit 的伪版本

    vX.0.0-yyyymmddhhmmss-abcdefgh1234

    v0.0.0-20220401081311-c38fb59326b7

    v1.0.0-20201130134442-10cb98267c6c

    • 第一部分:基础版本前缀,基本与语义化版本一致
    • 第二部分:时间戳(yyyymmddhhmmss),为提交Commit时的时间
    • 第三部分:校验码(abcdefgh1234),包含12位的哈希前缀
    • 每次Commit后Go都会默认生成一个伪版本号

2.3 indirect

  • 间接依赖: 未直接导入该依赖模块的源码包

2.4 incompatible

  • 主版本2+模块(v3 v4 v5)会在模块路径后面增加 /vN 后缀,用于实现按照不同的模块来处理同一个项目不同主版本的依赖
  • 对于没有 go.mod 文件并且主版本2+ 的依赖,会有 +incompatible

2.5 依赖图

image-20220508122535110.png

  • 选择最新的兼容版本,【最新】【兼容】
  • MINOR版本是可以向下兼容的

3 依赖分发(将依赖放置在多个第三方平台上)

3.1 回源

image-20220508122943992.png

  • 无法保证构建稳定性,依赖版本的作者进行增加、修改、删除软件版本时
  • 无法保证依赖可用性,依赖版本的作者删除软件时
  • 增加第三方压力,代码托管平台负载问题

3.2 Proxy(缓存)

Go Proxy , 服务站点,缓存源网站中的软件内容,缓存的软件版本不会改变,并且在源站软件删除后依然可用,项目构建时会直接从Go Proxy中拉取所需依赖资源

image-20220508125644460.png

2.3.2.1 Proxy相关配置——变量 GOPROXY
  • GOPROXY="https://proxy1.cn,https://proxy2.cn,direct",是服务站点的URL链表,其中,direct表示源站
2.3.2.2 Proxy工具——go get

image-20220508131003101.png

  • 啥都不加,默认拉取最新MAJOR版本
2.3.2.3 Proxy工具——go mod

image-20220508131121900.png

(三)测试

从单元测试实践出发,提升质量意识

3.1 单元测试

image-20220508131819410.png

3.1.1 单元测试规则

  • 测试文件命名规范:_test.go结尾,便于区分

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

image-20220508132200712.png

  • 初始化准备: 提供了一个测试用的TestMain函数,将初始化逻辑放入其中

    • m.Run()跑该包下的所有单元测试例子

image-20220508132356963.png

3.1.2 单元测试例子

  • go test [flags][packages]命令执行测试源代码文件(以_test.go结尾)
  • PS E:\go\0.Demos\courseDemo2\go-project-example-0> go test test/print_test.go test/print.go要同时写上测试函数所在文件和被测试函数所在文件
 package test
 ​
 func HelloTom() string {
     return "Tom"
 }
 ​
 package test
 ​
 import (
     //assert工具包
     "github.com/stretchr/testify/assert"
     "testing"
 )
 ​
 func TestHelloTom(t *testing.T) {
     output := HelloTom()
     expectOutput := "Tom"
     //判断是否相等
     assert.Equal(t, expectOutput, output)
 }

3.1.3 评估单元测试——代码覆盖率

  • 选择不同的测试分支在测试指令go test ..... 的最后加上--cover
  • 计算的是:成功执行的代码行数占总行数的比值

3.2 单元测试 —— Mock

3.2.1 单元测试依赖

image-20220508140318039.png

  • 单元的依赖多种多样,有文件、数据库、内存等等
  • 直接对整个单元进行测试是不稳定的,同时无法保证幂等性质,需要Mock机制,确保每次测试的输入是稳定的,一致的而不是依赖于文件

3.2.2 文件处理测试例子

  • log源文件

image-20220508141650251.png

  • 被测试函数
 func ReadFirstLine() string {
     open,err := os.Open("log")
     defer os.Close()
     if err != nil {
         return ""
     }
     scanner := bufio.NewScanner(open)
     //返回log文件中的第一行
     for scanner.Scan() {
         return scanner.Text()
     }
     return ""
 }
 ​
 func ProcessFirstLine() string {
     line := ReadFirstLine()
     destLine := strings.ReplaceAll(line,"11","00")
     return destLine
 }
  • 测试函数
 func TestReadFirstLine(t *testing T) {
     firstLine := ProcessFirstLine()
     assert.Equal(t,"line00",firstLine)
 }
  • 问题:一旦测试源文件 log 被删除,测试不可进行

3.2.3 Mock

  • 开源Mock包: monkey:github.com/bouk/monkey

  • 快速Mock函数:

    • 打桩: 用函数A替换函数B,则函数B是原函数,函数A是打桩函数
    • 为一个函数打桩
    • 为一个方法打桩
  • 直接在打桩函数里面将测试需要的测试模拟值写进去,而不是依赖文件

文件处理 —— Mock测试

  • 被测试文件(不变)
  • 测试文件
 //原测试方法
 func TestProcessFirstLine(t *testing.T) {
     firstLine := ProcessFirstLine()
     assert.Equal(t, "line00", firstLine)
 }
 ​
 //重写测试方法
 func TestProcessFirstLineWithMock(t *testing.T) {
     monkey.Patch(ReadFirstLine, func() string {
         //将原ReadFirstLine函数内容改为直接输出“line110”,将原本从文件获取测试值改为固定的测试值
         return "line110"
     })
     //卸载打桩
     defer monkey.Unpatch(ReadFirstLine)
     firstLine := ProcessFirstLine()
     assert.Equal(t, "line000", firstLine)
 }

3.3 基准测试

  • 被测试函数:随机选择服务器
 package benchmark
 ​
 import (
     "github.com/bytedance/gopkg/lang/fastrand"
     "math/rand"
 )
 ​
 var ServerIndex [10]int
 ​
 func InitServerIndex() {
     for i := 0; i < 10; i++ {
         ServerIndex[i] = i+100
     }
 }
 ​
 func Select() int {
     return ServerIndex[rand.Intn(10)]
 }
 ​
 func FastSelect() int {
     return ServerIndex[fastrand.Intn(10)]
 }
  • 测试函数:对 Select() 函数测试,命名Benchmark开头
 package benchmark
 ​
 import (
     "testing"
 )
 ​
 //通过循环做串行的压力测试
 func BenchmarkSelect(b *testing.B) {
     InitServerIndex()
     b.ResetTimer()
     for i := 0; i < b.N; i++ {
         Select()
     }
 }
 //通过RunParalle做并行压力测试
 func BenchmarkSelectParallel(b *testing.B) {
     InitServerIndex()
     b.ResetTimer() //重置时间,将初始化时间减去
     b.RunParallel(func(pb *testing.PB) {
         for pb.Next() {
             Select()
         }
     })
 }
 func BenchmarkFastSelectParallel(b *testing.B) {
     InitServerIndex()
     b.ResetTimer()
     b.RunParallel(func(pb *testing.PB) {
         for pb.Next() {
             FastSelect()
         }
     })
 }

(四)项目实战 —— 需求话题页面

通过项目需求、需求拆解、逻辑设计、代码实现感受真正项目的开发

4.1 需求描述

  • 展示话题(标题,文字描述)和回帖列表
  • 暂时不考虑前端设计,仅实现一个本地web服务
  • 话题和回帖数据用文件存储

4.2 需求用例

image-20220508173251709.png

  • 抽出两个实体类:话题(Topic)帖子(Post)

4.3 ER图

image-20220508173522408.png

4.4 分层结构

image-20220508173921123.png

  • 存储媒介(DataBase) :用于存储数据,如文档、数据库等
  • 数据层(Repository) :数据 Model,进行外部数据的增删改查
  • 逻辑层(Service) :业务 Entity,处理核心业务逻辑输出,调用数据层实现功能
  • 视图层(Controller) :视图 View,处理和外部的交互逻辑,显示页面等

4.5 组件工具

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

  • Go Mod

    • 进入项目所在目录,执行 go mod init 项目路径
    • go get gopkg.in/gin-goinc/gin.v1@v1.3.0

4.6 Repository

4.6.1 定义的两个结构体:Topic 和 Post

4.6.2 实现两个查询操作:QueryTopicById 和 QueryPostsByParent

  • QueryTopicById :根据id查询话题

  • QueryPostsByParent :根据主题查询出所有帖子

  • 通过索引实现搜索,将文档中的数据行映射到内存Map

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

4.6.3 初始化索引

  • 初始化话题索引

     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
     }
    

4.6.4 实现查询

  • QueryTopicById

     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
     }
    
  • QueryPostsByParent

     func (*PostDao) QueryPostById(id int64) (*Post, error) {
         var post Post
         err := db.Where("id = ?", id).Find(&post).Error
         if err == gorm.ErrRecordNotFound {
             return nil, nil
         }
         if err != nil {
             util.Logger.Error("find post by id err:" + err.Error())
             return nil, err
         }
         return &post, nil
     }
    

4.7 Service

4.7.1 创建实体,设计流程

image-20220508211956243.png

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

image-20220508212115225.png

4.7.2 获取数据,考虑并行实现

  • 由于获取 topic 和 posts 数据之间不存在依赖关系,所以考虑用并行来实现

4.8 Controller

  • 构建 view 对象,处理业务错误码
  • 定义一个用于返回处理信息的结构体,包括处理码(Code)、处理信息(Msg)和返回数据(Data)

4.9 Router