GO语言工程实践 | 豆包MarsCode AI刷题

180 阅读13分钟

1.语言进阶

并发 vs. 并行

并发.png 并发:多线程程序在一个核的CPU上运行,通过线程切换实现同时运行的一个状态

并行.png 并行:多线程程序在多个核的CPU上运行,利用多核实现多线程同时运行

1.1 Goroutine

Goroutine是协程

协程与线程

协程:用户态,轻量级线程,栈KB级别 线程:内核态,一个线程上可以运行多个协程,栈MB级别

使用Goroutine

快速打印hello goroutine: 0 到hello goroutine: 4

func hello(i int) {
	println("hello goroutine: " + fmt.Sprint(i))
}

func HelloGoRoutine() {
	for i := 0; i < 5; i++ {
		go func(j int) {
			hello(j)
		}(i)
	}
	time.Sleep(time.Second)//防止主线程先结束
}

1.2 CSP(Communication Sequential Processes)

GoroutineCSP.png

Go提倡通过通信共享内存而不是通过共享内存通信

1.3 Channel

语法

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

  • 无缓冲通道:make(chan int)
  • 有缓冲通道:make(chan int,2) //2指容量为2

有缓冲 vs. 无缓冲

GoChannel.png

示例

A 子协程发送0-9数字 B 子协程计算输入数字的平方 主协程输出最后的平方数

func CalSquare() {
	src := make(chan int)
	dest := make(chan int, 3)
	//A协程
	go func() {
		defer close(src)
		for i := 0; i < 10; i++ {
			src <- i
		}
	}()
	//B协程
	go func() {
		defer close(src)
		for i := 0; i < 10; i++ {
			src <- i
		}
	}()
	//主协程
	for i := range dest {
		//复杂操作
		println(i)
	}
}

1.4 并发安全 Lock

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

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)
	println("WithoutLock", x)
	x = 0
	for i := 0; i < 5; i++ {
		go addWithLock()
	}
	time.Sleep(time.Second)
	println("WithLock", x)
	
}

输出结果

WithoutLock: 8382
WithLock: 10000

无锁状态下,不能确定最终结果

1.5 WaitGroup

GoWaitGroup.png 计数器 开启协程+1;执行结束-1;主协程阻塞直到计数器为0

示例

快速打印hello goroutine: 0 到hello goroutine: 4

func hello(i int) {
	println("hello goroutine: " + fmt.Sprint(i))
}

func ManyGoWait() {
	var wg sync.WaitGroup
	wg.Add(5)
	for i := 0; i < 5; i++ {
		go func(j int) {
			defer wg.Done()
			hello(j)
		}(i)
	}
	wg.Wait()
}

2.依赖管理

背景

依赖管理背景.png

  • 工程项目不可能基于标准库从0到1编码搭建
  • 需要管理依赖库

2.1 Go依赖管理演进

GOPATH -> Go Vendor -> Go Module

主要解决的问题:

  • 不同环境(项目)依赖的版本不同
  • 控制依赖库的版本

2.1.1 GOPATH

介绍

GOPATH是Go语言的一个环境变量,value是Go项目的工作区

目录有以下结构

  • src:存放项目源码
  • pkg:存放编译的中间产物,加快编译速度
  • bin:存放Go项目编译生成的二进制文件
依赖管理方式
  • 项目代码直接依赖src下的代码
  • go get下载最新版本的包到src目录下
弊端

无法实现package的多版本控制

场景:

GOPATH弊端.png

A和B依赖于某一package的不同版本,但是在GOPATH管理模式下,多项目依赖同一个库,该依赖库是同一份代码,所以不同项目不能依赖同一个库的不同版本

2.1.2 Go Vendor

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

在Vendor机制下,如果当前项目存在Vendor目录,会优先使用该目录下的依赖如果依赖不存在,会从GOPATH中寻找。 通过每个项目引入一份依赖的副本,解决了多个项目需要同一个package依赖的冲突问题

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

场景: GoVendor弊端.png 项目A依赖pkgB和C,而B和C又依赖了pkgD的不同版本,通过vendor的管理模式,我们不能很好地控制对D的依赖版本,一旦更新版本,可能会依赖冲突或编译出错

2.1.3 Go Module

介绍

Go Module是Go语言官方推出的依赖管理系统,解决了之前依赖管理系统存在的诸多问题。 从Go 1.11开始实验性引入,Go 1.16默认开启,一般读作go mod

  • 通过go.mod文件管理依赖包版本
  • 通过go get/go mod指令工具管理依赖包
  • 终极目标:定义版本规则和管理项目依赖关系

2.2 依赖管理三要素

  1. 配置文件,描述依赖 -- go.mod
  2. 中心仓库管理依赖库 -- Proxy
  3. 本地工具 -- go get/mod

2.2.1 配置依赖--go.mod

下方是一个go.mod文件

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-5a5fe074e621
	example/lib4 v0.0.0-20180306012644-bacd9c7ef1dd // indirect
	example/lib5/v3 v3.0.2
	example/lib6 v3.2.0+incompatible
)
  1. 第一部分(module example/project/app)
    • 是一个模块路径,用来标识一个模块,通过路径找到该模块
    • 如果是github前缀表示可以从Github上找到该模块,依赖包的源代码由github托管
    • 如果项目的子包想被单独引用,需要通过单独的init go.mod文件进行管理
  2. 第二部分(go 1.16)
    • 依赖的原生sdk版本
  3. 第三部分(require)
    • 是单元依赖
    • 依赖标识:[Module Path] [Version/Pseudo-version]

2.2.2 依赖配置-version

GoModule为了方便管理定义了版本规则,分为:语义化版本和基于commit的伪版本

  1. 语义化版本
    • 主要包括三个部分:$[MAJOR}.${MINOR}.${PATCH}
    • 例如:v1.3.0,v2.3.0
    • MAJOR:大版本,不同的Major是可以不兼容的
    • MINOR:新增函数或功能,向后兼容
    • PATCH:一般是修复Bug
  2. 基于commit的伪版本
    • vX.0.0-yyyymmddhhmmss-abdcefgh1234
    • 例如:v1.0.1-20201130134442-10cb98267c6c
    • 基础版本前缀:和语义化版本一样
    • 时间戳:提交的时间
    • 校验码:包含12位的哈希前缀
    • 每次commit后Go都会默认生成一个伪版本号

2.3.3 依赖配置--特殊标识符

indirect

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

例如:A->B->C,则有

  • A->B直接依赖
  • A->C间接依赖
incompatible
  • 主版本2+模块会在模块路径增加/vN后缀
    • 这能让go module按照不同的模块来处理同一个主版本的依赖
  • 对于没有go.mod文件并且主版本2+的依赖,会+incompatible
    • 由于go module是在1.11版实验性引入的,在此之前已有一些仓库打上了2或更高版本的tag,为了兼容这部分仓库,会加上此后缀

2.2.4 依赖配置--依赖图

Go依赖配置依赖图.png

项目X依赖了A、B两个项目,且A、B分别依赖了C项目的v1.3、v1.4两个版本,最终编译时所使用的C项目是哪个版本呢? v1.4 选择最低的兼容版本

2.2.5 依赖分发

依赖分发,也就是从哪里下载,如何下载的问题

回溯

Go依赖分发回溯.png

Go Module系统中定义的依赖,最终可以对应到多版本代码管理系统中某一项目的特定提交或版本,这样的话,对于go.mod中定义的依赖,可以直接从对应仓库中下载指定软件依赖,从而完成依赖分发

直接使用版本管理仓库下载以来存在一些问题

  • 无法保证构建稳定性
    • 软件作者可以直接通过代码平台增加/修改/删除软件版本
  • 无法保证依赖可重用性
    • 软件作者可能会删除软件,导致依赖不可用
  • 增加第三方压力
    • 代码托管平台负载问题
Proxy

Go依赖分发Proxy.png

Go Proxy就是解决上述问题的方案

Go Proxy是一个服务站点,它会从缓存源站中的软件内容,缓存的软件版本不会改变,并且在源站软件删除之后依然可用,从而实现了供"immutablity"和"available"的依赖分发

使用Go Proxy之后,构建时会直接从Go Proxy站点拉取依赖

2.2.6 Go Proxy使用

示例: GOPROXY="https://proxy1.cn, https://proxy2.cn direct"

  • Go Module通过GOPROXY环境变量控制如何使用Go Proxy;
  • GOPROXY是一个Go Proxy站点URL列表,可以使用"direct"表示源站。
  • 对于示例配置,整体的依赖寻址路径,会优先从proxy1下载依赖,如果proxy1不存在,再到proxy2,再不存在则会回溯到源站直接下载依赖,缓存到proxy站点中

2.2.7 工具--go get

go get example.org/pkg

后缀:

  • @update 默认
  • none 删除依赖
  • v1.1.2 tag版本,语义版本
  • @23dfdd5 特定的commit
  • @master 分支的最新commit

2.2.8--go mod

go mod

后缀:

  • init 初始化,创建go.mod文件
  • download 下载模块到本地缓存
  • tidy 增加需要的依赖,删除不需要的依赖

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

测试

测试层级.png

  • 回归测试:QA同学手动通过终端回归一些固定的主流程场景
  • 集成测试:对系统功能最测试验证
  • 单元测试:开发阶段,开发者对单独的函数、模块做功能验证,一定层度上决定着代码的质量 从上到下,覆盖率逐层变大,成本逐层降低

3.1 单元测试

单元测试.png

主要包括:输入、测试单元、输出以及校对

  • 单元:概念比较广泛,包括接口、函数、模块等
  • 校对:用来确保代码功能和我们预期相符
  • 单测的作用:
    • 一方面保证质量,在整体覆盖率足够的情况下,一定程度上既保证了新功能本身的正确性,又未破坏原有代码的正确性
    • 另一方面可以提上效率,在代码有bug的情况下,通过编写单测,可以在一个较短周期内定位和修复问题

3.1.1 单元测试-规则

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

func TestMain(m *testing.M) {
	//测试前:数据装载、配置初始化等前置工作
	code := m.Run()
	//测试后:释放资源等收尾工作
	os.Exit(code)
}

3.1.2 单元测试-例子

func HelloTom() string {
	return "Jerry"
}

func TestHelloTom(t *testing.T) {
	output := HelloTom()
	expectOutput := "Tom"
	if output != expectOutput {
		t.Errorf("Expected %s do not match actual %s", expectOutput, output)
	}
}

3.1.3 单元测试-运行

go test [flags] [packages]

Go单测例子结果.png

3.14 单元测试-assert

import (
	"github.com/stretchr/testify/assert" //一个开源的测试包
	"testing"
)

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

3.1.5 单元测试-覆盖率

概念

代码覆盖率是对

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

对函数

func JudgePassLine(score int16) bool {
	if score >= 60 {
		return true
	}
	return false
}

其单元测试

func TestJudgePassLineTrue(t *testing.T) {
	isPass := JudgePassLine(70)
	assert.Equal(t, true, isPass)
}

执行

>go test judgment_test.go judgment.go --cover
	command-line-arguments 1.296s coverage: 66.7% of statements

使用--cover就可以计算覆盖率,以上例来看,三行代码共执行两行,故覆盖率为66.7%

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

3.2 单元测试-依赖

单元测试依赖.png

对于工程中复杂的项目,一般会依赖文件、数据库、缓存等

所以单测需要保证稳定性和幂等性

  • 稳定是指相互隔离,能在任何时间、任何环境运行测试
  • 幂等是指每一次测试运行都应该产生与之前一样的结果。

要实现这一目的就要用到mock机制

3.3 单元测试-文件处理

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")
	return destLine
}
func TestProcessFirstLine(t *testing.T) {
	firstLine := ProcessFirstLine()
	assert.Equal(t, "line00", firstLine)
}

举例说明,如果单测需要依赖本地的文件,如果文件被修改或者删除就会fail,为了保证测试case的稳定性,我们对读取文件函数进行mock,屏蔽对于文件的依赖

3.4 单元测试-Mock

bouk/monkey: Monkey patching in Go Monkey是一个开源的mock测试库,可以对method,或者实例的方法进行mock,反射,指针赋值 Monkey Patch的作用域在Runtime,在运行时通过Go的unsafe包,能够将内存中函数的地址替换为运行时函数的地址,将待打桩函数或方法的实现跳转到

//Patch replaces a function with another
func Patch(target, replacement interface{}) *PatchGuard {
	t := reflect.ValueOf(target)
	r := reflect.ValueOf(replacement)
	patchValue(t, r)

	return &PatchGuard{t, r}
}

//Unpatch removes any monkey patches on target
//returns whether target was patched in the first place
func Unpatch(target interface{}) bool {
	return unpatchValue(reflect.ValueOf(target))
}

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

func TestProcessFirstLineWithMock(t *testing.T) {
	monkey.Patch(ReadFirstLine, func() string {
		return "line110"
	})
	defer monkey.Unpatch(ReadFirstLine)
	line := ProcessFirstLine()
	assert.Equal(t, "line000", line)
}

3.5 基准测试

  • 基准测试是指测试一段程序的运行性能以及耗费CPU的程度。
  • Go提供了基准测试框架,提供了基准测试的能力
  • 场景:在开发中经常会遇到代码性能瓶颈,为了定位问题经常要对代码做性能分析,需要用到基准测试
  • 使用:类似单元测试

3.5.1 基准测试-例子

举例:服务器负载均衡问题 首先,我们有10个服务器列表,每次通过select函数随机选择一个用来执行

import (
	"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)]
}

3.5.2 基准测试-运行

func BenchmarkSelect(b *testing.B) {
	InitServerIndex()
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		Select()
	}
}
func BenchmarkSelectParaller(b *testing.B) {
	InitServerIndex()
	b.ResetTimer()
	b.RunParallel(func(pb *testing.PB){
		for pb.Next() {
			Select()
		}
	})
}
BenchmarkSelect-12           61563442  18.77 ns/op
BenchmarkSelectParallel-12   19034819  79.42 ns/op

基准测试以Benchmark开头,入参是testing.B, 用b中的N值反复递增循环测试 (对一个测试用例的默认测试时间是 1 秒,当测试用例函数返回时还不到 1 秒,那么 testing.B 中的 N 值将按 1、2、5、10、20、50……递增,并以递增后的值重新进行用例函数测试。) Resttimer重置计时器,我们再reset之前做了init或其他的准备操作,这些操作不应该作为基准测试的范围;runparallel是多协程并发测试;执行2个基准测试,发现代码在并发情况下存在劣化,主要原因是rand为了保证全局的随机性和并发安全,持有了一把全局锁。

3.5.3 基准测试-优化

bytedance/gopkg: Universal Utilities for Go 为解决问题,字节开源了一个高性能随机数方法fastrand,提高了性能

func FastSelect() int {
	return ServerIndex[fastrand.Intn(10)]
}

基准测试优化执行结果.png

4 项目实战

4.1 需求描述

社区话题页面

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

4.2 需求用例

Go需求用例.png 从图中可以抽出两个实体 实体的属性?实体间的关系?

4.3 ER图-Entity Relationship Diagram

Go实战ER图.png

4.4 分层结构

Go实战分层结构.png

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

4.5 组件工具

4.6 Repository

根据er图可以定义出Topic以及Post

Topic

{
	"id": 1,
	"title": "青训营来啦!",
	"content": "小姐姐,快到碗里来~",
	"create_time": 1650437625
}

Post

{
	"id": 1,
	"parent_id": 1,
	"content": "小姐姐快来1",
	"create_time": 1650437616
}

如何实现查询呢

index

全盘扫描遍历方式可以实现,但是效率不高,故引出索引整个概念,可以引导我们快速查找、定位我们需要的结果

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

index具体实现
func initTopicIndexMap(filePath string) error {
	open, err = os.Open(file + "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
}

首先打开文件,基于file初始化scanner,通过迭代器方式遍历数据行,转化为结构体存储至内存map

查询

有了内存索引,下一步就是实现查询操作。

  • 直接根据查询key获得map中的value就好了
  • sync.once:主要适用高并发的场景下只执行一次的场景
  • 这里的基于once的实现模式为单例模式,减少了存储浪费
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.7 Service

定义实体

Service层实体:

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

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

代码流程编排

func (f *QueryPageInfoFlow) Do() (*PageInfo, error) {
	if err := f.checkParam(); err != nul {
		return nil, err
	}
	if err := f.prepareInfo(); err != nul {
		return nil, err
	}
	if err := f.pagePageInfo(); err != nul {
		return nil, err
	}
	return f.pagaInfo, nil
}
checkParam

检查参数,这里略

prepareInfo

关于prepareInfo方法,话题和回帖信息的获取都依赖topic Id,这样两者就可以并行执行,提高效率

func (f *QueryPageInfoFlow) prepareInfo() error {
	//获取topic信息
	var wg sync.WaitGroup
	wg.Add(2)
	go func() {...}()
	//获取post列表
	go func() {...}()
	wg.Wait()
	return nil
}

4.8 Controller

定义一个view对象,通过code msg打包业务状态信息,用data承载业务实体信息

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{...}
	}
	pageInfo, err = service.QueryPageInfo(topicId)
	if err != nil {
		return &PageData{...}
	}
	return &PageData{...}
}

4.9 Router

  • 初始化数据索引
  • 初始化引擎配置
  • 构建路由
  • 启动服务
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 := controller.QueryPageInfo(topicId)
		c.JSON(200, data)
	})
	err := r.Run()
	if err != nil {
		return
	}
}