Go语言工程进阶与实践操作 | 青训营笔记

33 阅读9分钟

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

学习完一门语言的基础语法后,要知其所以然,会运用才是进阶!

一、本堂课重点内容

本节课程主要分为四个方面讲解:

  1. 并发编程

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

  1. 依赖管理

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

  1. 单元测试

从单元测试实践出发,提高大家的质量意识

  1. 项目实战

通过项目需求、需求拆解、逻辑设计、代码实现带领大家感受下真实的项目开发

二、详细知识点介绍

1. 并发编程

Go的特点——“快”!为什么?

1.1 并行 VS 并发

并发:多线程程序在一个核的CPU上运行 —— 两个或多个事件在同一时间间隔内发生

并发:多线程程序在多个核的CPU上运行 —— 两个或多个事件在同一时刻发生

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

image.png

协程Goroutine

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

所有Go语言一次可以创建上万级别的协程。

在函数或方法调用前面加上关键字go,将会同时运行一个新的Goroutine。

e.g:快速(需要开多个线程) 打印hello goroutine:0 ~ hello goroutine:4

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

输出:

hello goroutine : 4
hello goroutine : 2
hello goroutine : 1
hello goroutine : 0
hello goroutine : 3

image.png 提倡通过通信共享内存而不是通过共享内存而实现通信

通道Channel

image.png

  • 无缓冲通道也被称为同步通道;
  • 有缓冲通道可以类比红石中继器(但是有点不一样,只是效果类似);

通道是用来传递数据的一个数据结构,可以用于两个goroutine之间,通过传递一个指定类型的值来同步运行和通讯。操作符<-用于指定通道的方向,实现发送or接收;若未指定方向,则为双向通道。 通道在使用前必须先创建。

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

package main

import (
	"fmt"
)

func CalcPow() {
        // 无缓冲队列
	src := make(chan int)
	dest := make(chan int, 3)
	// A 子协程src发送0~9数字
	go func() {
		defer close(src) // 当子协程src结束的时候再关闭,减少资源浪费
		for i := 0; i < 10; i++ {
			src <- i
		}
	}()
	// B 子协程dest计算输入数字的平方
	go func() {
		defer close(dest)
        // 通过 range 关键字遍历读取到的数据
		for i := range src {
			dest <- (i * i)
		}
	}()
	// 主协程输出最后的答案
	// 这里可以暂时认为子协程需要使用匿名函数
	for i := range dest {
        // 因为主协程可能会有更多的复杂操作,比较耗时,所以用带缓冲的通道可以避免问题
		fmt.Println(i)
	}
}

func main() {
	CalcPow()
}

输出:

0
1
4
9
16
25
36
49
64
81

注意!!

如果通道不带缓冲,发送方会阻塞直到接收方从通道中接收了值。如果通道带缓冲,发送方则会阻塞直到发送的值被拷贝到缓冲区内;如果缓冲区已满,则意味着需要等待直到某个接收方获取到一个值。接收方在有值可以接收之前会一直阻塞。

range 函数遍历每个从通道接收到的数据,因为 src 在发送完 5 个数据之后就关闭了通道,所以这里我们 range 函数在接收到 5 个数据之后就结束了。如果上面的 src 通道不关闭,那么 range 函数就不会结束,从而在接收第 6 个数据的时候就阻塞了。

fatal error: all goroutines are asleep - deadlock!

并发安全Lock

当采用共享内存实现通信的时候,会发生一些Undefine问题,这时候就需要并发安全。

e.g:对变量执行2000次+1操作,5个协程并发执行

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

输出:

WithoutLock : 9245 //这个值不确定,随机产生
WithLock : 10000

WaitGroup实现同步

image.png 三种方法:Add、Done、Wait image.png 使用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()
}

输出:

Hello WaitGroup : 4
Hello WaitGroup : 1
Hello WaitGroup : 2
Hello WaitGroup : 3
Hello WaitGroup : 0

2. 依赖管理

2.1 背景

image.png

2.2 Go依赖管理演进

image.png

GOPATH

image.png

  • 项目代码直接依赖src下的代码
  • go get 下载最新版本的包到src目录下

弊端 image.png 场景:A,B依赖于某一package的不同版本

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

Go Vender

  • 项目目录下增加Vender文件,所有依赖包副本形式放在 $ProjectRoot/Vender
  • 依赖寻址方式:vender => GOPATH

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

弊端

image.png

  • 无法可控制依赖的版本
  • 更新项目又可能出现依赖冲突,导致编译错误

GO MODULE

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

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

2.3 依赖管理三要素

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

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

  3. 本地工具 go get/mod

可以类比Java中的maven

详细解释:

go.mod

image.png

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

version的两种类型
  • 语义化版本
  • 基于commit伪版本

image.png

indirect

对于没有直接表示的模块会在go.mod中加上// indirect,例如(A->B->C)(A->B是直接依赖,A->C是间接依赖) image.png

incompatible
  • 主版本2+模块会在模块路径增加/vN后缀
  • 对于没有go.mod文件并且主版本2+的依赖,会加上+incompatible,表示可能会存在一些不兼容的代码逻辑(如上图)
依赖图

image.png 选择最低兼容版本 -> B

依赖分发——回源

实质就是这个依赖要去哪里下载,如何下载的问题。

实际上是用Proxy来缓存,保证了依赖的稳定性。

image.png

依赖分发——Proxy

image.png

依赖分发——变量 GOPROXY

查找依赖的逻辑:如果最后都没找到就会回到第三方Direct

image.png

工具——go get

image.png

工具——go mod

image.png

3. 测试

质量就是“生命”

可能出现的事故: image.png

测试是避免事故发生的最后一道屏障

image.png

分类:

  • 回归测试
  • 集成测试
  • 单元测试

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

3.1 单元测试

image.png

所以,单元测试的质量某种程度上决定了代码的质量

3.1.1 测试规则

  • 所有测试文件以_test.go结尾
  • func TestXxx(*testing.T)
  • 初始化逻辑放到TestMain中

image.png

3.1.2 例子

func TestHelloTom(t *testing.T) {
	output := HelloTom()
	expectOutput := "Tom"
	assert.Equal(t, expectOutput, output)
}

func HelloTom() string {
	return "Tom"
}

image.png

3.1.3 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"
}

image.png

3.1.4 覆盖率

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

e.g

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

输出:

image.png

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

3.2 单元测试——依赖

image.png

  • 幂等:重复运行同一个case,结果与之前一致
  • 稳定:指单元测试相互隔离,可以独立运行

3.3 单元测试——文件处理

学习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)
}

输出:

line11
line22
line33
line44
line55

一旦测试文件被修改了,可能就无法运行,这时候就需要mock

3.4 单元测试——mock

monkey: https://github.com/bouk/monkey
这是一个开源的mock测试库,可以对method或者实例的方法进行mock

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

快速Mock函数:

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

e.g

// 用函数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
// 这样整个测试函数就摆脱了本地文件的束缚和依赖

3.5 基准测试

基准测试是指测试一段程序的性能及耗费CPU的程度;

在实际的项目开发中,经常会遇到代码性能瓶颈,为了定位问题,经常要对代码做性能分,这时就用到了基准测试,其使用方法与单元测试类似。

  • 优化代码,需要对当前代码分析
  • 内置的测试框架提供了基准测试的能力

4. 项目实战

4.1 需求模型与描述

需求背景:

青训营话题forum.juejin.cn/youthcamp/p…

需求:社区话题页面

  1. 实现一个展示话题(标题,文字描述)和回帖列表后端http接口
  2. 本地文件存储数据(暂不考虑前端页面实现,仅实现一个本地web服务)
  3. 话题和回帖数据用文件存储

组件及技术点

4.2 具体逻辑与框架:

需求用例:

image.png

4.3 ER图(Entity Relationship Diagram)

  • 话题
  • 帖子

image.png

4.4 分层结构

image.png

  • 数据层:数据Model,封装外部数据的增删改查。我们的数据存储在本地文件,通过文件操作拉取话题和帖子数据。数据层面向逻辑层,对逻辑层透明,屏蔽下游数据差异,也就是无论下游是文件,还是数据库或者微服务,对于逻辑层的接口模型是不变的。

  • 逻辑层:业务Entity,处理核心业务逻辑输出。对应我们的需求,也就是话题页面,包括话题和回帖列表,并上送给视图层。

  • 视图层:试图View,处理和外部的交互逻辑,我们封装JSON格式化的请求结构,用API形式访问就可以了。

4.5 组件工具

  • Gin:高性能Go web框架
    • https://github.com/gin-gonic/gintinstallation
  • Go Mod
    •  go mod init
      
       go get gopkg.in/gin-gonic/gin.v1@v1.3.0``` 
      

4.6 Repository(数据层)

// Topic
{
    "id":1,
    "title":"青训营开始了!",
    "content":"欢迎踊跃报名!",
    "create_time":1000000007
}

// Post
{
    "id":1,
    "parent_id":1,
    "content":"欢迎欢迎热烈欢迎!",
    "create_time":1000000007
}

如何实现查询?

可以采用索引的概念,类似于书的目录,可以引导我们快速查找定位我们需要的结果。

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

image.png

初始化话题内存索引具体实现:

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
}

4.7 Service(逻辑层)

实体:

image.png

流程:

image.png

代码流程编排:

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
}

可用性:

image.png

代码如下图:

image.png

4.8 Controller(视图层)

  • 构建View对象
  • 业务错误码
package cotroller

import (
	"strconv"

	"github.com/Moonlight-Zhao/go-project-example/service"
)

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(),
		}
	}
	pageInfo, err := service.QueryPageInfo(topicId)
	if err != nil {
		return &PageData{
			Code: -1,
			Msg:  err.Error(),
		}
	}
	return &PageData{
		Code: 0,
		Msg:  "success",
		Data: pageInfo,
	}

}

4.9 Router

通过Gin搭建外部框架:

  • 初始化数据索引
  • 初始化引擎配置
  • 构建路由
  • 启动服务
package main

import (
	"github.com/Moonlight-Zhao/go-project-example/cotroller"
	"github.com/Moonlight-Zhao/go-project-example/repository"
	"gopkg.in/gin-gonic/gin.v1"
	"os"
)

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
}

运行图:

image.png

三、课后实践

  1. 支持对话题发布回帖。
  2. 回帖id生成需要保证不重复、唯一性。
  3. 新加回帖追加到本地文件,同时需要更新索引,注意Map的并发安全问题。

四、课后个人总结

今天学习受益匪浅,知道了项目学习的全部流程,这些案例给我学习到很深刻的感触,一个项目的形成需要如此多的步骤,这些或许在Java中学习过,但是却没有如此印象深刻,今天的学习给我更多了解,将给我在今后提升很大,今后的路还很长,还需要继续努力。

五、引用参考