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

136 阅读10分钟

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

一、本堂课重点内容

  1. Go 语言进阶
  2. 依赖管理
  3. 测试
  4. 项目实战

二、详细知识点介绍

1. Go 语言进阶

(1)并发

  • 并发:通过时间片的切换来实现同时运行的状态。
  • 并行:利用多核直接实现多线程的同时运行。

Image

go 语言可以实现高效的调度 -> 更适合高并发的场景。

(2)协程

  • 线程:是系统里比较昂贵的系统资源,属于内核态,其创建、切换、停止都属于很重的系统操作(由内核进行调度),比较消耗资源。
  • 协程:其创建和调度由 go 语言本身完成,一个线程上可以并发地去跑多个协程,轻量级,可以轻易创建大量的协程。

image-20220509115035854

示例:

用 go 关键字开启一个协程,使用 sleep 暴力阻塞,保证在协程执行完成之前,主线程不退出。

 package concurrence
 ​
 import (
    "fmt"
    "time"
 )
 ​
 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)
 }

(3)通道

  • 协程间可以使用通道、临界区进行通信。
  • 通道:就像一个队列,先入先出,能保证收发数据的顺序。
  • 使用临界区时,需要通过互斥量对内存进行加锁,在一定程度上会影响程序的性能。
  • 提倡“通过通信共享内存”而不是“通过共享内存实现通信”。

image-20220509115527361

通道的创建:

  • 通道是一种引用类型,使用 make 关键字创建。
  • 可以指定缓冲区的大小,即通道的容量。

image-20220509115925100

示例:

  • 使用 <- 向通道发送数据。
  • 使用 range 获取通道中的数据。
  • defer close(…):延迟关闭,在当前函数执行完成后,关闭 … 。
 package concurrence
 ​
 // 计算平方数:0、1、4、9、……
 func CalSquare() {
    src := make(chan int)
    dest := make(chan int, 3)
    go func() {
       defer close(src)
       for i := 0; i < 10; i++ {
          src <- i
       }
    }()
    go func() {
       defer close(dest)
       for i := range src {
          dest <- i * i
       }
    }()
    for i := range dest {
       //复杂操作
       println(i)
    }
 }

(4)同步

  • 锁:lock sync.Mutex。
  • lock.Lock():上锁。
  • lock.Unlock():解锁。
 package concurrence
 ​
 import (
    "sync"
    "time"
 )
 ​
 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)
 }

使用 sync 包下的 waitgroup 实现阻塞:

  • 创建变量:wg sync.WaitGroup。
  • wg.Add(计数值)。
  • Done():计数器 -1。
  • Wait():阻塞直到计数器为 0。
 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. 依赖管理

依赖管理演进历史:GOPATH -> Go Vendor -> Go Module。

(1)GOPATH

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

image-20220509152306065

弊端:所有本地项目都依赖于同一个 src 源码,无法兼容一个包的多个版本。如:A 和 B 依赖于某一 package 的不同版本,则无法实现 package 的多版本控制。

image-20220509152512388

(2)Go Vendor

  • 项目的依赖会优先从 vendor 目录下进行获取,若没有,再到 GOPATH 环境变量下去寻找。

image-20220509152717699

弊端:

image-20220509153115761

(3)Go Module

  • 通过 go.mod 文件管理依赖包版本(配置文件、描述依赖)。
  • Proxy:中心仓库管理依赖库。
  • 通过 go get/go mod 指令工具管理依赖包。

go.mod 依赖配置:

  • module xxx:模块路径,标识一个模块。
  • go 原生库的版本号。
  • 单元依赖的描述,每一条依赖的组成:模块路径 + 版本号。,

image-20220509153540852

version:

  • 语义化版本:major:不同的 major 属于不同的大版本,互相之间可以不兼容。minor:新增函数或功能,需要在当前 major 下做到前后兼容。patch:做一些代码 bug 的修复。
  • 伪版本:语义化版本 + 时间戳 + 哈希校验码。

image-20220509153954466

image-20220509154011953

关键字标识:

  • indirect:对于间接依赖,用 indirect 标识。
  • incompatible:不兼容的,将某些可能会存在不兼容代码逻辑的依赖标识出来。
  • 主版本(major)在 2 以上时,依赖路径后应追加对应的 /vN。

image-20220509154137442

image-20220509154206211

示例:选 B,会选择最低的兼容版本。

image-20220509154401554

直接使用版本管理仓库下载依赖可能会出现问题:

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

优化:直接从 go proxy 中拉取依赖,更稳定、可靠。

image-20220509154641859

通过 go proxy 环境变量来控制 go proxy 的配置:

image-20220509154709714

依赖管理工具:

go get:

  • 使用 go get 默认会拉取最新的 major 版本。

image-20220509154842962

go mod:

  • 在实际项目中,每一次提交前都可以执行一次 go mod tidy。

image-20220509154949584

3. 测试

(1)测试的分类

  • 回归测试:通过终端手动地回归一些场景。
  • 集成测试:对系统功能维度的测试。
  • 单元测试:测试开发阶段。

从上到下,被测代码覆盖范围逐渐变大,成本却逐层降低。

(2)单元测试

image-20220509155534515

测试规则:

image-20220509155637279

示例:

 package test
 ​
 import (
     "github.com/stretchr/testify/assert"
     "testing"
 )
 ​
 func HelloTom() string {
     return "Tom"
 }
 ​
 // test
 func TestHelloTom(t *testing.T) {
     output := HelloTom()
     expectOutput := "Tom"
     assert.Equal(t, expectOutput, output)
 }

运行测试:

  • m.run():跑当前包下的所有单元测试。
  • go test [flags] [packages]。
  • 通过开源包来实现测试结果是否等于预期结果的判断:"github.com/stretchr/testify/assert"。

代码覆盖率:

  • 代码覆盖率越完备,代码性能就越有保障。
  • 使用 go test xxx.go --cover,通过加上 cover 参数,可以得到代码测试的覆盖率。
  • 在下面的例子中,测试时只运行了需要测试的函数中的前两行,即验证了前两行的正确性,最后一行并没有得到验证,因此覆盖率为 2/3。
  • 在实际项目中,一般覆盖率为:50%~60%,较高:80%+。
 package test
 ​
 import (
     "github.com/stretchr/testify/assert"
     "testing"
 )
 ​
 func JudgePassLine(score int16) bool {
    if score >= 60 {
       return true
    }
    return false
 }
 ​
 // test
 func TestJudgePassLineTrue(t *testing.T) {
     isPass := JudgePassLine(70)
     assert.Equal(t, true, isPass)
 }
 // coverage:66.7%
 ​
 func TestJudgePassLineFail(t *testing.T) {
     isPass := JudgePassLine(50)
     assert.Equal(t, false, isPass)
 }
 // coverage:100.0%

单元测试的目标:

  • 幂等:重复运行相同的代码,结果是一样的。
  • 稳定:程序运行稳定,不会崩溃出错。

(3)单元测试 - mock

  • 常用的开源 mock 测试包:monkey。
  • 用打桩函数去替换原函数。
  • patch():为函数打桩。入参含义:target(原函数)、replacement(打桩函数)。
  • unpatch():保证在测试结束后把桩给卸载掉。
  • 在运行时,实际调用的是打桩函数,实现 mock 测试。
  • 可以在任何时间,并且不依赖任何本地文件进行测试。
 package test
 ​
 import (
     "bou.ke/monkey"
     "github.com/stretchr/testify/assert"
     "testing"
 )
 ​
 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
 }
 ​
 // test
 func TestProcessFirstLine(t *testing.T) {
     firstLine := ProcessFirstLine()
     assert.Equal(t, "line00", firstLine)
 }
 ​
 // mock
 func TestProcessFirstLineWithMock(t *testing.T) {
     // 打桩,替换原函数
     monkey.Patch(ReadFirstLine, func() string {
         return "line110"
     })
     // 延迟卸桩
     defer monkey.Unpatch(ReadFirstLine)
     line := ProcessFirstLine()
     assert.Equal(t, "line000", line)
 }

(4)基准测试

使用 go test 末尾加上 -bench=. 标签进行基准测试。

示例:负载均衡,随机选择执行服务器。

关于随机函数:

  • rand 函数为了保证全局的随机性以及并发安全,持有了一把全局锁,因此使用并行的方式进行测试效率反而可能更低。
  • 解决 rand 函数的效率问题:开源的 fastrand,该函数极大地提高了多线程并发情况下的性能,缺点是牺牲了数据一定的随机性。

三、实践练习例子

话题与帖子是一对多的关系:

image-20220509161629911

(1)分层结构

  • 数据层:主要关联底层的数据模型,面向逻辑层,对逻辑层透明,会屏蔽掉底层的数据差异,向逻辑层提供数据 model。
  • 逻辑层:通过接收数据层的数据做打包封装,会输出实体 entity,在本例中就是话题页面。
  • 视图层:对上游的 client 负责,主要是封装一些数据格式。

image-20220509161753235

组件工具:

  • 使用 go mod init 来初始化 go.mod 依赖文件。
  • 执行 go get xxx 获取依赖。

image-20220509161944962

(2)数据层

  • 创建索引,并初始化内存 Map。
  • 实现查询:根据 topic id 到 Map 中查询对应的 topic 和 post。
 package repository
 ​
 import (
     "bufio"
     "encoding/json"
     "os"
 )
 ​
 // db_init.go
 // 创建索引
 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
 }
 ​
 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
 }
 ​
 // topic.go
 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]
 }
 ​
 // post.go
 import (
     "sync"
 )
 ​
 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]
 }

(3)逻辑层

Image

 package service
 ​
 import (
    "errors"
    "github.com/Moonlight-Zhao/go-project-example/repository"
    "sync"
 )
 ​
 // 实体(页面)的数据包含话题和对应的帖子
 type PageInfo struct {
    Topic    *repository.Topic
    PostList []*repository.Post
 }
 ​
 // 根据话题 id 查询对应的页面
 func QueryPageInfo(topicId int64) (*PageInfo, error) {
    return NewQueryPageInfoFlow(topicId).Do()
 }
 ​
 func NewQueryPageInfoFlow(topId int64) *QueryPageInfoFlow {
    return &QueryPageInfoFlow{
       topicId: topId,
    }
 }
 ​
 type QueryPageInfoFlow struct {
    topicId  int64
    pageInfo *PageInfo
 ​
    topic *repository.Topic
    posts []*repository.Post
 }
 ​
 func (f *QueryPageInfoFlow) Do() (*PageInfo, error) {
    // 1.参数校验
    if err := f.checkParam(); err != nil {
       return nil, err
    }
    // 2.准备数据
    if err := f.prepareInfo(); err != nil {
       return nil, err
    }
    // 3.组装实体
    if err := f.packPageInfo(); err != nil {
       return nil, err
    }
    return f.pageInfo, nil
 }
 ​
 // 1.参数校验
 func (f *QueryPageInfoFlow) checkParam() error {
    if f.topicId <= 0 {
       return errors.New("topic id must be larger than 0")
    }
    return nil
 }
 ​
 // 2.准备数据
 // topic 和 post 信息都只依赖于 topic id,因此可以并行执行数据的准备
 func (f *QueryPageInfoFlow) prepareInfo() error {
    // 获取 topic 信息
    var wg sync.WaitGroup
    wg.Add(2)
    go func() {
       defer wg.Done()
       // 调用 repository 层的方法完成数据的查询
       topic := repository.NewTopicDaoInstance().QueryTopicById(f.topicId)
       f.topic = topic
    }()
    // 获取 post 列表
    go func() {
       defer wg.Done()
       // 调用 repository 层的方法完成数据的查询
       posts := repository.NewPostDaoInstance().QueryPostsByParentId(f.topicId)
       f.posts = posts
    }()
    // 阻塞,等待上面两个查询都完成后再返回
    wg.Wait()
    return nil
 }
 ​
 // 3.组装实体
 func (f *QueryPageInfoFlow) packPageInfo() error {
    f.pageInfo = &PageInfo{
       Topic:    f.topic,
       PostList: f.posts,
    }
    return nil
 }

(4)控制层

 package cotroller
 ​
 import (
    "github.com/Moonlight-Zhao/go-project-example/service"
    "strconv"
 )
 ​
 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(),
       }
    }
    // 调用 service 层的方法根据 topic id 查询页面信息
    pageInfo, err := service.QueryPageInfo(topicId)
    if err != nil {
       return &PageData{
          Code: -1,
          Msg:  err.Error(),
       }
    }
    return &PageData{
       Code: 0,
       Msg:  "success",
       Data: pageInfo,
    }
 }

(5)启动 server

  • 初始化数据索引。
  • 初始化引擎配置。
  • 构建路由。
  • 启动服务:go run sever.go curl 127.0.0.1:8080/community/page/get/1

四、课后个人总结

通过本次课程的学习,我对 go 语言在并发场景下的使用方法以及优势有了一个大致的了解。其次,我学习到了 go 语言对于依赖管理的方式,这让我联想到了 java 中 Maven 的使用,通过对 go mod 的学习,让我所学的知识有一种融汇贯通的感觉。然后,我还了解到了在实际项目中可能会用到的一些常见的测试方法,如单元测试等。最后,通过一个简单小项目的例子,让我对后端的架构分层设计有了更直观的了解,非常期待下一次课程的学习。