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

130 阅读7分钟

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

1. 语言进阶

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

1.1. 并发 VS 并行 → 协程

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

    Untitled.png

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

    Untitled 1.png

Go在用户态可以自行设计并发度, 而不是依靠系统调度, 可充分发挥多核优势,高效运行

  • Goroutine

    • 协程

      Untitled 3.png

      • 协程:用户态,轻量级线程,栈KB级别
      • 线程:内核态,线程可以跑多个协程,栈MB级别。
    • 快速打印 Hello World

      package concurrence
      
      import (
      	"fmt"
      	"sync"
      )
      
      func hello(i int) {
      	println("hello world : " + fmt.Sprint(i))
      }
      
      func ManyGo() {
      	var wg sync.WaitGroup
      	for i := 0; i < 5; i++ {
      		wg.Add(1)
      		**go** func(j int) { // 通过 go 创建一个协程
      			defer wg.Done()
      			hello(j)
      		}(i)
      	}
      	wg.Wait()
      }
      

1.2 CSP ( Communicating Sequential Processes )

  • Goroutine 提倡通过通信共享内存, 而不是通过共享内存而实现通信 (也就是推荐基于通道channel, 当然goroutine也保留共享内存实现通信的机制)

    Untitled 3.png

  • 通道 Channel

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

    • 无缓冲通道 make(chan int) ( 发送和接收Goroutine同步化, 又称同步通道 )
    • 有缓冲通道 make(chan int,2) ( 解决生产和消费速度不均衡导致的效率问题 )

    Untitled 4.png

    • Code

      package concurrence
      
      func CalSquare() {
      	src := make(chan int)
      	dest := make(chan int, 3)
      	go func() { // 子协程A发送0-9数字
      		defer close(src)
      		for i := 0; i < 10; i++ {
      			src <- i
      		}
      	}()
      	go func() { // 子协程B计算输入数字的平方
      		defer close(dest)
      		for i := range src {
      			dest <- i * i
      		}
      	}()
      	for i := range dest { // 主协程输出
      		println(i)
      	}
      }
      

1.3 并发安全 Lock

对变量执行2000次+1操作,5个协程并发执行 → 尽量避免采用共享内存实现并发任务

Untitled 5.png

1.4 WaitGroup

Untitled 6.png

  • Code

    // Sample
    func query(word string, wg *sync.WaitGroup) {
    	// ...
    	**wg.Done()**
    }
    
    func main() {
    	if len(os.Args) != 2 {
    		fmt.Fprintf(os.Stderr, `usage: simpleDict WORD 
    example: simpleDict hello
    `)
    		os.Exit(1)
    	}
    	word := os.Args[1]
    	**wg := sync.WaitGroup{}
    	wg.Add(1)**
    	go query(word, &wg)
    	**wg.Wait()**
    }
    

2. 依赖管理

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

2.0 背景

工程项目不可能基于标准库由 0→1 编码搭建, 因此需要引入并管理依赖库。

Untitled 7.png

2.1 Go 依赖管理发展

Untitled 8.png

  • 2.1.1 GOPATH

    • 特点

      • 环境变量: $GOPATH

        Untitled 9.png

      • 项目代码直接依赖src下的代码

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

    • 弊端

      场景: A 和 B依赖于某一package的不同版本

      问题: 无法实现package的多版本控制

      Untitled 10.png

  • 2.1.2 Go Vendor

    • 特点

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

      • 依赖寻址方式: vendor ⇒ GOPATH

      • 通过每个项目引入一份依赖的副本,解决了多个项目需要同一个 package依赖的冲突问题。

        Untitled 11.png

    • 弊端

      Untitled 12.png

      • 无法控制依赖的版本
      • 更新项目有可能出现依赖冲突,导致编译出错。
  • 2.1.3 Go Module

    • 特点

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

      终极目标:定义版本规则和管理项目依赖关系

2.2 依赖管理三要素

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

    • 依赖标识: [Module Path][Version/Pseudo-version]

      Untitled 13.png

    • 依赖配置

      • version

        • 语义化版本 ${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 的时间
          • 校验码 ( abcdefabcdef ), 包含 12 位的哈希前缀;

          每次提交commit后, Go 都会默认生成一个伪版本号

      • 特殊标识符

        • indirect

          表示 go.mod 对应的当前模块,没有直接导入该依赖模块的包,也就是非直接依赖,标示间接依赖

          Untitled 14.png

          • A → B → C

            • A → B 直接依赖
            • A → C 间接依赖
          • 用法

            • 当前项目依赖A, 但是A的 go.mod 遗漏了B, 那么就会在当前项目的 go.mod 中补充B, 加 indirect 注释
            • 当前项目依赖A, 但是A没有 go.mod,同样就会在当前项目的 go.mod 中补充B, 加 indirect 注释
            • 当前项目依赖A, A又依赖B, 当对A降级的时候, 降级的A不再依赖B, 这个时候B就标记 indirect 注释
        • incompatible

          Untitled 15.png

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

            这能让 go module 按照不同的模块来处理同一个项目不同主版本的依赖。由于 go module 是1.11实验性引入, 所以这项规则提出之前已经有一些仓库打上了2或者更高版本的tag了,为了兼容这部分仓库,对于没有 go.mod 文件并且主版本在 2+ 的依赖,会在版本号后加上+incompatible 后缀

      • 依赖图

        如果X项依赖了A、B两个项目,且A、B分别依赖了C项目的 v1.3、v1.4两个版本,最终编译时所使用的C项目的版本为 V1.4 ( 选择最低的兼容版本 )

        Untitled 16.png

  • 2.2.2 中心仓库管理依赖库 Proxy

    • 依赖分发 — 回源

      对于 go.mod 中定义的依赖,则直接可以从对应仓库中下载指定软件依赖,从而完成依赖分发。

      Untitled 17.png

      • 存在问题

        • 首先无法保证构建确定性:软件作者可以直接代码平台 增加/修改/删除 软件版本,导致下次构建使用另外版本的依赖,或者找不到依赖版本
        • 无法保证依赖可用性:依赖软件作者可以直接代码平台删除软件,导致依赖不可用
        • 大幅增加第三方代码托管平台压力
    • 依赖分发 — Proxy

      Untitled 18.png

    • 依赖分发 — GOPROXY

      Go Modules通过GOPROXY环境变量控制如何使用 Go Proxy ;GOPROXY是一个 Go Proxy 站点URL列表,可以使用 direct”表示源站。

      Untitled 19.png

      对于示例配置,整体的依赖寻址路径,会优先从 proxy1 下载依赖,如果 proxy1 不存在,会下钻 proxy2 寻找,如果 proxy2 中不存在则会回源到源站直接下载依赖,缓存到proxy站点中。

  • 2.2.3 本地工具 go get/mod

    • go get

      Untitled 20.png

    • go mod

      Untitled 21.png

      尽量提交之前执行下 go mod tidy ,减少构建时无效依赖包的拉取

3. 测试

单元测试实践提升工程质量意识, 质量就是生命 ⇒ 测试是避免事故的最后一道屏障

3.0 背景

  • 3.0.1 事故

    1. 营销配置错误,导致非预期用户享受权益,资金损失10w+
    2. 用户提现,幂等失效,短时间可以多次提现,资金损失20w+
    3. 代码逻辑错误,广告位被占,无法出广告,收入损失500w+
    4. 代码指针使用错误,导致APP不可用,损失上kw+
  • 3.0.2 测试分类

    Untitled 22.png

    • 从上到下, 覆盖率逐层变大,成本却逐层降低

      回归测试一般是QA同学手动通过终端回归一些固定的主流程场景,集成测试是对系统功能维度做测试验证,而单元测试测试开发阶段,开发者对单独的函数、模块做功能验证,层级从上至下,测试成本逐渐减低,而测试覆盖率确逐步上升,所以单元测试的覆盖率一定程度上决定这代码的质量。

3.1. 单元测试

Untitled 23.png

单元测试主要包括 输入,测试单元,输出,以及校对,单元的概念比较广,包括接口,函数,模块等

用最后的校对来保证代码的功能与我们的预期相符

  • 效果

    • 保证质量,在整体覆盖率足够的情况下,一定程度上既保证了新功能本身的正确性,又未破坏原有代码的正确性
    • 提升效率,在代码有bug的情况下,通过编写单元测试,可以在一个较短周期内定位和修复问题。
  • 3.1.1 测试规则

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

      Untitled 24.png

    • 测试函数以 Test 开头 funcTestXxx(-testing.T)

      Untitled 25.png

    • 初始化逻辑放到 TestMain

      Untitled 26.png

  • 3.1.2 测试用例

    // 原函数
    func HelloTom() string {
    	return "Jerry"
    }
    
    // 测试函数
    func TestHelloTom(t *testing.T) {
    	output := HelloTom()
    	expectOutput :=	"Tom"
    	if output != expectOutput {
    		t.Errorf(format: "Expected %s do not match actual %s",	expectOutput,	output)
    	}
    }
    
  • 3.1.3 测试运行

    go test[flagsl[packages]
    

    Untitled 27.png

  • 3.1.4 测试assert

    // 原函数 HelloTom.go
    func HelloTom() string {
    	return "Tom"
    }
    
    // 测试函数 HelloTom_test.go
    import (
    	"github.com/stretchr/testify/assert"
    	"testing"
    )
    
    func TestHelloTom(t *testing.T) {
    	output := HelloTom()
    	expectOutput :=	"Tom"
    	assert.Equal(t,	expectOutput,	output)	
    }
    

    Untitled 28.png

  • 3.1.5 测试覆盖率

    • 代码覆盖率

      • 如何衡量代是否经过了足够的测试?
      • 如何评价项目的测试水准?
      • 如何评估项目是否达到了高水准测试等级?
    • 测试用例

      • 一个判断是否及格的函数,超过60分,返回true,否则返回false,右边是对输入为70 的单元测试

        // 原函数 JudgePassLine.go
        func JudgePassLine(score int16) bool {
        	if score >= 60 {
        		return true
        	}
        	return false
        }
        
        // 测试函数 HelloTom_test.go
        func TestJudgePassLineTrue(t *testing.T){
        	isPass := JudgePassLine(score:70)
        	assert.Equat(t, expected:true, isPass)
        }
        

        Untitled 29.png

      • 执行单元测试,通过指定 cover 参数,我们看输出覆盖率为 66.7%

        一共3行,我们的单元测试执行了 2 行,所以为 66.7%

      • 提升覆盖率,增加一个不及格的测试 case ,最终覆盖率为 100%

        // 测试函数 HelloTom_test.go
        func TestJudgePassLineTrue(t *testing.T){
        	isPass := JudgePassLine(score:70)
        	assert.Equat(t, expected:true, isPass)
        }
        
        func TestJudgePassLineFail(t *testing.T){
        	isPass := JudgePassLine(score:50)
        	assert.Equat(t, expected:false, isPass)
        }
        

        Untitled 30.png

    • Tips

      • 一般覆盖率: 50%~60%, 对于资金型服务,覆盖率可能要求达到较高80%+。
      • 测试分支相互独立、全面覆盖。
      • 测试单元粒度足够小,函数单一职责。

3.2 测试 依赖 与 Mock

  • 3.2.1 依赖

    Untitled 31.png

    外部依赖 ⇒ 稳定 & 幂等 ⇒ Mock机制

    • 稳定: 相互隔离,能在任何时间,任何环境,运行测试
    • 幂等: 每一次测试运行都应该产生与之前一样的结果
  • 3.2.2 文件处理

    • 依赖的本地文件

      line11
      line22
      line33
      line44
      line55
      
    • 测试用例

      将文件中的第一行字符串中的11替换成00,执行单测,测试通过

      由于单测需要依赖本地的文件,如果文件被修改或者删除测试就会fail

      Untitled 32.png

      Untitled 33.png

      为了保证测试case的稳定性,我们对读取文件函数进行mock,屏蔽对于文件的依赖。

  • 3.2.3 Mock

    Monkey: ‣是一个开源的mock测试库,可以对method,或者实例的方法进行mock

    Untitled 34.png

    • 快速Mock函数

      • 为一个函数打桩
      • 为一个方法打桩

    Mockey Patch 的作用域在 Runtime,在运行时通过 Go 的 unsafe 包,能够将内存中函数的地址替换为运行时函数的地址。将待打桩函数或方法的实现跳转。

    • ReadFirstLine 打桩测试, 不再依赖本地文件

      Untitled 35.png

3.3 基准测试

基准测试是指测试一段程序的运行性能及耗费 CPU 的程度。而我们在实际项目开发中,经常会遇到代码性能瓶颈,为了定位问题经常要对代码做性能分析,这就用到了基准测试。

  • 3.3.1 基准测试用例

    Untitled 36.png

    测试函数

    Untitled 37.png

    Untitled 38.png

    • 基准测试以 Benchmark 开头,入参是 testing.B , 用b中的N值反复递增循环测试(对一个测试用例的默认测试时间是 1 秒,当测试用例函数返回时还不到 1 秒,那么 testing.B 中的 N 值 将按 1、2、5、10、20、50……递增,并以递增后的值重新进行用例函数测试。)
    • Resettimer 重置计时器,我们再reset之前做了 init 或其他的准备操作,这些操作不应该作为基准测试的范围;
    • RunParallel 是多协程并发测试;

    执行2个基准测试,发现代码在并发情况下存在劣化,主要原因是rand为了保证全局的随机性和并发安全,持有了一把全局锁

    math/randfastrand (‣)

    Untitled 39.png

    Untitled 40.png

    优化思路: 牺牲了一定的数列一致性,在大多数场景是适用的

4. 项目实战

通过项目需求、需求拆解、逻辑设计和代码实现体会项目开发过程

4.0 需求背景

  • 掘金社区话题入口页面

    页面的功能包括话题详情,回帖列表,支持回帖,点赞,和回帖回复,以此为需求模型,开发一个该页面交涉及的服务端小功能。

    Untitled 41.png

    • 功能 — 社区话题页面

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

4.1 需求用例

  • 4.1.1 抽取实体

    浏览消费用户 ⇒ 抽取 话题回帖列表 两个实体

    Untitled 42.png

  • ER 图

    Untitled 43.png

4.2 分层结构

Untitled 44.png

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

4.3 组件工具

  • Gin 高性能 go web 框架

  • Go Mod

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

4.4 Repository

  • 4.4.1 Struct 定义

    • Topic

      Untitled 45.png

    • Post

      Untitled 46.png

  • 4.4.2 查询实现

    • 方法

      1. 可以用全扫描遍历方式: 但是这虽然能达到我们的目的,但是并非高效的方式

      2. 使用索引: 索引就像书的目录,可以引导我们快速查找定位我们需要的结果;

        • 这里我们用 map 实现内存索引,在服务对外暴露前,利用文件元数据初始化全局内存索引,这样就可以实现 O(1) 的时间复杂度查找操作。

          Untitled 47.png

    • 操作

      1. 初始化话题数据索引

        打开文件,基于 file 初始化 scanner,通过迭代器方式遍历数据行,转化为结构体存储至内存 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
        }
        
      2. 初始化回帖数据索引

        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
        }
        
      3. 查询

        package repository
        
        import (
        	"sync"
        )
        
        type Topic struct {
        	Id         int64  `json:"id"`
        	Title      string `json:"title"`
        	Content    string `json:"content"`
        	CreateTime int64  `json:"create_time"`
        }
        type TopicDao struct {
        }
        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]
        }
        
        package repository
        
        import (
        	"sync"
        )
        
        type Topic struct {
        	Id         int64  `json:"id"`
        	Title      string `json:"title"`
        	Content    string `json:"content"`
        	CreateTime int64  `json:"create_time"`
        }
        type TopicDao struct {
        }
        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]
        }
        

4.5 Service — 逻辑实现

  • 4.5.1 实体

    type PageInfo struct {
    	Topic    *repository.Topic
    	PostList []*repository.Post
    }
    
  • 4.5.2 流程

    Untitled 48.png

  • 4.5.3 代码流程编排

    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
    }
    
    func (f *QueryPageInfoFlow) prepareInfo() error {
    	//获取topic信息
    	var wg sync.WaitGroup
    	wg.Add(2)
    	go func() {
    		defer wg.Done()
    		topic := repository.NewTopicDaoInstance().QueryTopicById(f.topicId)
    		f.topic = topic
    	}()
    	//获取post列表
    	go func() {
    		defer wg.Done()
    		posts := repository.NewPostDaoInstance().QueryPostsByParentId(f.topicId)
    		f.posts = posts
    	}()
    	wg.Wait()
    	return nil
    }
    
    func (f *QueryPageInfoFlow) packPageInfo() error {
    	f.pageInfo = &PageInfo{
    		Topic:    f.topic,
    		PostList: f.posts,
    	}
    	return nil
    }
    

4.6 Controller

  • 4.6.1 实体

    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.7 Router — web服务引擎配置

  • 操作

    • 初始化数据索引
    • 初始化引擎配置
    • 构建路由
    • 启动服务
    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
    }
    

4.8 运行

  • go run server.go

    Untitled 49.png

总结

本节课针对go在项目开发过程中, 主要是GO语言的一些高并发特性、依赖管理、单元测试和一些代码层面的实战,包括从并发编程视角了解Go高性能本质、了解Go依赖管理的演进路线和依赖管理三要素单元测试实践提升工程质量意识、以及 通过项目需求、需求拆解、逻辑设计和代码实现体会项目开发过程实战 等。