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

166 阅读6分钟

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

Go语法工程实践

语言进阶(并发编程)

  • 并发vs并行
    • Go可以充分发挥多核优势,高效运行 image.png
  • Goroutine
    • 协程:用户态,轻量级线程,栈KB级别
    • 线程:内核态,线程跑多个协程,栈MB级别,消耗更多资源
    • 简单案例
func hello(i int) {
   println("hello world : " + fmt.Sprint(i))
}
func HelloGoRoutine() {
   for i := 0; i < 5; i++ {
      go func(j int) {
         hello(j)
      }(i)
   }
   //保证协程在执行完之前主线程不会退出
   time.Sleep(time.Second)
}
  • CSP(Communicating Sequential Processes)
    • 提倡通过通信共享内存而不是通过共享内存实现通信 image.png
  • Channel
    • make(chan 元素类型, [缓冲区大小])
      • 无缓冲通道 make(chan int) :发送和接收的gorountine需要同步
      • 有缓冲通道 make(chan int, 2) :生产消费模型,通道满了不能再传入 image.png
    • 案例 - A子协程发送0-9数字 - B子协程计算输入数字的平方 - 主协程输出最后的平方数 - 输出能保证顺序性0-1-4-9-16...
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(dest)
      for i := range src {
         dest <- i * i
      }
   }()
   //MAIN
   for i := range dest {
      println(i)
   }
}
  • 并发安全Lock
    • Go也提供通过共享内存实现通信的方式,可能引发并发安全问题
    • 可以加锁来保证对共享内存的访问修改同步
    • 避免对共享内存进行可能导致安全问题的读写操作
var (
  x int64
  lock sync.Mutex
)


func addWithLock() {
  for i := 0; i < 2000; i++ {
    lock.Lock()
    x += 1
    lock.Unlock()
  }
}


func Add() {
  x = 0
  for i := 0; i < 5; i++ {
    go addWithLock()
  }
  time.Sleep(time.Second)
}
  • WaitGroup
    • 前面都用sleep来防止主线程在协程结束之前先结束,因为不知道协程结束时间,但这种方法不够优雅
    • 可以使用withgroup实现并发任务的同步
    • 内部是一个计数器,开启协程+1,执行结束-1,主线程阻塞直到计数器为0 image.png
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()
}

依赖管理

  • 背景
    • 工程项目不可能基于标准库0~1编码搭建
    • 利用已经封装好的、经过验证的开发组件或工具来提升研发效率
    • 管理依赖库

Go依赖管理演进

  • 三个阶段
    • GOPATH
    • Go Vendor
    • Go Module(目前广泛应用)
  • 目标
    • 不同项目(环境)依赖的版本不同
    • 控制依赖库的版本
  • GOPATH
    • 环境变量
    • 项目代码直接依赖src下的代码
    • go get下载最新版本的包到src目录下
    • 弊端:A和B都依赖于某一个package的不同版本,但src下只能有一个版本存在,无法保证AB两个项目都能编译通过。无法实现多版本控制 image.png image.png
  • Go Vendor
    • 项目目录下增加vendor文件,所有依赖包副本形式放在$ProjectRoot/vendor
    • 依赖寻址方式:vendor→GOPATH
    • 通过每个项目引入一份依赖的副本,解决了多个项目需要同一个package依赖的冲突问题
    • 弊端:无法解决依赖包的版本变动问题和同一个项目依赖同一个包不同版本的问题。如下图无法控制对D的版本控制,更新项目有可能出现依赖冲突,导致编译出错 image.png image.png
  • Go Module
    • 通过go.mod文件管理依赖包版本
    • 通过go get/go mod指令工具管理依赖包
    • 能够实现终极目标:定义版本规则和管理项目依赖关系

依赖管理三要素

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

依赖配置

  • go.mod
    • 依赖标识:[Module Path][Version/Pseudo-version]
    • 基本单元:模块路径标识了一个模块,可以看出从哪里找到该模块 image.png
  • version
    • GOPATH和vendor都是源码副本方式依赖,没有版本规则概念,而go.mod为了方便管理定义了版本规则
    • 语义化版本 {MAJOR}.{MINOR}.{PATCH},如V1.3.0
    • 基于commit伪版本 vX.X.X-yyyymmddmmss-abcdefgh1234
      • 基础版本前缀和语义化版本一样
      • 时间戳是提交commit的时间
      • 校验码包含12位的哈希前缀
      • 每次提交commit后go会默认生成一个伪版本号
  • 特殊标识符indirect
    • 非直接依赖,表示go.mod对应的当前模块没有直接导入该依赖模块的包
  • 特殊标识符incompatible
    • 兼容,表示可能存在不兼容的代码逻辑
    • 正常主版本2+的模块会在模块路径后增加/vN后缀,对于没有go.mod文件并且主版本2+的依赖会+incompatible
  • 依赖图
    • 1.3 1.4同属于1版本是兼容的,选择最低兼容版本?? image.png

依赖分发

  • 回源
    • 理论上可以直接从github等版本管理仓库直接下载依赖,但可能存在问题:
      • 无法保证构建稳定性:软件版本容易被增加/修改/删除
      • 无法保证依赖可用性
      • 增加第三方压力,代码托管平台负载问题
  • Proxy
    • Go Proxy是一个服务站点,会缓存源站中的内容,缓存的软件版本不会改变,并且在源站软件删除后依然可用,从而实现了稳定、可靠的依赖分发
    • 使用Proxy后构建时会直接从该站点拉取依赖 image.png
  • 变量GOPROXY
    • 通过环境变量控制如何使用GoProxy
    • GOPROXY是一个Proxy站点URL列表,可以用direct表示源站
    • 例如 GOPROXY="proxy1.cn, proxy2.cn, direct",此时依赖寻址路径为proxy1→proxy2→direct
  • 工具 go get image.png
  • 工具 go mod image.png

测试

  • 测试是避免事故的最后一道屏障
  • 测试分类
    • 回归测试:测试员通过终端手动测试一些固定的流程场景
    • 集成测试:自动化地对某些接口进行测试,对系统功能维度做测试验证
    • 单元测试:开发中的测试阶段
  • 测试分类从上到下,覆盖率逐层变大,成本逐层降低

单元测试

  • 单元测试的组成 image.png
  • 规则
    • 所有测试文件以_test.go结尾
    • func TestXxx(*testing.T)
    • 初始化逻辑放到TestMain中
  • 案例:HelloTom()被测试函数 image.png
  • 运行
    • IDE可以直接点
    • go test [flags][packages]
  • assert
import (
   "github.com/stretchr/testify/assert"
   "testing"
)
func TestHelloTom(t *testing.T) {
   output := HelloTom()
   expectOutput := "Tom"
   assert.Equal(t, expectOutput, output)
}
  • 代码覆盖率
    • 如何衡量代码是否经过了足够的测试?如何评价项目的测试水准?如何评估项目是否达到了高水准测试等级?
    • 计算覆盖率,指定cover参数
  • tips
    • 一般覆盖率50%~60%
    • 测试分支相互独立、全面覆盖
    • 测试单元粒度足够小,函数单一职责
func TestJudgePassLineTrue(t *testing.T) {
   isPass := JudgePassLine(70)
   assert.Equal(t, true, isPass)
}
func TestJudgePassLineFail(t *testing.T) {
   isPass := JudgePassLine(50)
   assert.Equal(t, false, isPass)
}

image.png

单元测试-依赖

  • 复杂项目可能依赖一些本地文件、数据库、cache
  • 单元测试需要保证稳定性和幂等性
    • 稳定性:相互隔离,能在任何时间、任何环境、运行测试
    • 幂等性:重复测试结果一样
  • 如果单元测试采用直接依赖是不稳定的,可能和网络有关或者本地的测试文件被人删了,所以需要用到mock机制

单元测试-文件处理

  • 单测依赖于本地文件,如果文件被修改或删除测试就会失败。为了保证测试Case的稳定性,对读取文件函数进行mock,屏蔽对于文件的依赖
  • go读文件
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)
}

单元测试-Mock

  • 常用mock测试包:monkey
  • 可以为一个函数或者实例方法进行打桩
    • 打桩:用一个函数A替换函数B,B是原函数,A是打桩函数
  • target是原函数,实际调用的是打桩函数
  • Mockey Patch的作用域在Runtime,在运行时通过Go的unsafe包能够将内存中函数的地址替换为运行时函数的地址
  • 案例:通过patch对readFirstLine进行打桩,默认返回"line110",通过defer卸载mock,这样整个测试函数就摆脱了本地文件的束缚和依赖
func TestProcessFirstLineWithMock(t *testing.T) {
   monkey.Patch(ReadFirstLine, func() string {
      return "line110"
   })
   defer monkey.Unpatch(ReadFirstLine)
   line := ProcessFirstLine()
   assert.Equal(t, "line000", line)
}

基准测试

  • 基准测试是指测试一段程序的运行性能及耗费CCPU的程度,Go的内置测试框架提供了该能力
  • 案例
    • 基础测试以Benchmark开头,入参是testing.B,用b中的N值反复循环测试
    • Resttimer重置计时器
    • runparallel多协程并发测试,执行两个基准测试,但发现结果在并发情况下更差,因为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 BenchmarkSelect(b *testing.B) {
   InitServerIndex()
   b.ResetTimer()
   for i := 0; i < b.N; i++ {
      Select()
   }
}
func BenchmarkSelectParallel(b *testing.B) {
   InitServerIndex()
   b.ResetTimer()
   b.RunParallel(func(pb *testing.PB) {
      for pb.Next() {
         Select()
      }
   })
}
  • 优化
    • 为了解决随机性能问题,一种高性能随机数方法fasetrand被提出。牺牲了一定的数列一致性,适用于多数场景,测试后发现性能有所提升

项目实践

需求描述

  • 社区话题页面
    • 展示话题和回帖列表
    • 不考虑前端页面实现
    • 话题和回帖数据用文件存储

需求用例

  • User→Topic Page
    • Topic
    • PostList

ER图-Entity Relationship Diagram

  • 话题和帖子一对多

分层结构

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

组件工具

Respository数据层

  • 索引
    • 这里使用map实现内存索引,在服务对外暴露前,利用文件元数据初始化全局内存索引,这样就可以实现O(1)的时间复杂度查找操作
    • 初始化话题数据索引:打开文件,基于file初始化scanner,通过迭代器遍历数据行,转化为结构体存储到内存map
var (
   topicIndexMap map[int64]*Topic
   postIndexMap  map[int64][]*Post
)
func Init(filePath string) error{
   if err := initTopicIndexMap(filePath);err!=nil{
      return err
   }
   if err := initPostIndexMap(filePath);err!=nil{
      return err
   }
   return nil
}
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
}
  • 查询
    • sync.Once主要适用于高并发的场景下只执行一次的场景,类似单例模式,减少存储的浪费
type Post struct {
   Id         int64  `json:"id"`
   ParentId   int64  `json:"parent_id"`
   Content    string `json:"content"`
   CreateTime int64  `json:"create_time"`
}
type PostDao struct {
}
var (
   postDao *PostDao
   postOnce sync.Once
)
func NewPostDaoInstance() *PostDao {
   postOnce.Do(
      func() {
         postDao = &PostDao{}
      })
   return postDao
}
func (*PostDao) QueryPostsByParentId(parentId int64) []*Post {
   return postIndexMap[parentId]
}

Service逻辑层

  • service层实体PageInfo(Topic PostList)
  • 流程:参数校验、准备数据、组装实体
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
}
  • 在查询话题和回帖时,两者都依赖topicId,是可以并发执行的,提高执行效率
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
}
  • 对于数据结构/代码逻辑的设计好好琢磨,和java的相同和不同之处

Controller视图层

  • 一个view对象,通过code msg打包业务状态信息
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,
   }
}

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
}

运行

  • go run server.go启动Web服务
  • 通过curl命令请求服务暴露的接口,直接在浏览器输入url请求
curl --location --request GET 'http://127.0.0.1:8080/community/get/2'

问题

  • go module的优点?参考后面的题目,gomod也没解决vendor的问题?
  • 后面那道题,为什么1.4时最低兼容 版本?
  • Go Proxy举的例子没听明白
  • 实现通过curl命令请求服务暴露的接口,出错curl: (3) URL using bad/illegal format or missing URL
  • 运行下载下来的项目源码时,出BUG
go: bou.ke/monkey@v1.0.2: Get https://proxy.golang.org/bou.ke/monkey/@v/v1.0.2.mod: dial tcp 142.251.43.17:443: connectex: A connection attempt failed because the connected party did not properly respond after a period of time, or e
stablished connection failed because connected host has failed to respond.
解决方法:添加代理
go env -w GOPROXY=https://goproxy.cn