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

41 阅读9分钟

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

在今天主要学习了Go语言的并发编程,Go项目中依赖管理以及基本的测试方法,最后利用一个简单的web后端项目学习到了Go语言的web开发流程以及项目结构,本篇笔记记录了个人认为重点内容.

1. Go语言并发编程

1.1 并行与并发的区别

image.png

1.2 Goroutine(协程)

Go 语言支持并发,我们只需要通过 go 关键字来开启 goroutine 即可。

goroutine 是轻量级线程,goroutine 的调度是由 Golang 运行时进行管理的。

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

代码示例:


import (
	"fmt"
	"time"
)

func HelloPrint(i int) {
	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()
}

1.2 CSP

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

image.png

1.3 Channel

通道(channel)是用来传递数据的一个数据结构。

声明一个通道很简单,我们使用chan关键字即可,通道在使用前必须先创建:

ch := make(chan int)

通道可用于两个 goroutine 之间通过传递一个指定类型的值来同步运行和通讯。操作符 <- 用于指定通道的方向,发送或接收。如果未指定方向,则为双向通道。

ch <- v    // 把 v 发送到通道 ch
v := <-ch  // 从 ch 接收数据
           // 并把值赋给 v

注意:默认情况下,通道是不带缓冲区的。发送端发送数据,同时必须有接收端相应的接收数据。

ch := make(chan int, 100)

通道可以设置缓冲区,通过 make 的第二个参数指定缓冲区大小:

image.png

带缓冲区的通道允许发送端的数据发送和接收端的数据获取处于异步状态,就是说发送端发送的数据可以放在缓冲区里面,可以等待接收端去获取数据,而不是立刻需要接收端去获取数据。

不过由于缓冲区的大小是有限的,所以还是必须有接收端来接收数据的,否则缓冲区一满,数据发送端就无法再发送数据了。

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

示例代码:

package concurrence

func CalSquare() {
   src := make(chan int)     //不带缓冲的channel
   dest := make(chan int, 3) //带缓冲的channel

   go func() {
      defer close(src)
      for i := 0; i < 10; i++ {
         src <- i //在该协程中将 0 - 9 阻塞放入src通道中
      }
   }()

   go func() {
      defer close(dest)
      for i := range src {
         dest <- i * i //在该协程中将src通道中的数据遍历(实现了两个协程间的通信)并计算出平方值放入dest带缓冲通道中
      }
   }()

   for i := range dest {
      println(i) //主协程打印输出
   }
}

1.4 并发安全Lock

当多个协程共享一个通道时,即共享临界资源,则会有死锁的风险. 在Go中sync包下,有一个Mutex结构体能够充当锁,来保护共享资源,实现同步

基本用法:

var lock sync.Mutex //定义一把锁
lock.lock() //加锁
lock.unlock() //解锁

代码示例:

package concurrence

import (
   "log"
   "sync"
   "time"
)

// 全局变量,定义一个x整型变量和一把锁
var (
   x    int64
   lock sync.Mutex
)

// 加锁的方法
func addWithLock() {
   for i := 0; i < 2000; i++ {
      lock.Lock()
      x++
      lock.Unlock()
   }
}

// 不加锁的方法
func addWithoutLock() {
   for i := 0; i < 2000; i++ {
      x++
   }
}

// 测试方法
func add() {
   x = 0
   for i := 0; i < 5; i++ {
      go addWithLock()
   }
   time.Sleep(time.Second)
   log.Println("加锁方法x=", x)

   x = 0
   for i := 0; i < 5; i++ {
      go addWithoutLock()
   }
   time.Sleep(time.Second)
   log.Println("不加锁方法x=", x)
}

1.5 WaitGroup

WaitGroup就是package sync用来做任务编排的一个并发原语。这个要解决的就是并发-等待的问题:现有一个goroutine A在检查点(chaeckpoint)等待一组goroutine全部完成,如果在执行任务的这些goroutine还没有全部完成,那么goroutine A就会阻塞在检查点,直到所有的goroutine都完成后才能继续执行。

类似于Java中的CountDowmLatch

Go标准库中的WaitGroup提供了三个方法

func (wg *WaitGroup) Add(delta int) //用来设置WaitGroup的计数值;
func (wg *WaitGroup) Done() //用来将WaitGroup的计数值减1,其实就是调用了Add(-1);
func (wg *WaitGroup) Wait() //调用这个方法的goroutine会一直阻塞,直到WaitGroup的计数值变为0。

image.png

示例代码:

package concurrence

import (
   "fmt"
   "sync"
)

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

func ManyGo() {
   var wg sync.WaitGroup //定义一个WaitGroup,实现更优雅的阻塞
   for i := 0; i < 5; i++ {
      wg.Add(1)        //5个协程,计数器的值累加为5
      go func(j int) { // go关键字开启协程
         defer wg.Done() //释放,计数器减1
         hello(j)
      }(i)
   }
   wg.Wait() //wait阻塞,直到计数器的值为0停止阻塞
}

2. 依赖管理

2.1 背景

对于hello world以及类似的单体函数只需要依赖原生SDK,而实际工程会相对复杂,我们不可能基于标准库0~1编码搭建,而更多的关注业务逻辑的实现,而其他的涉及框架、日志、driver、以及collection等一系列依赖都会通过sdk的方式引入,这样对依赖包的管理就显得尤为重要 image.png

2.2 Go依赖管理演进

image.png

2.2.1 Go Path

GOPATH是Go语言支持的一个环境变量,value是Go项目的工作区。 目录有以下结构:src:存放Go项目的源码;pkg:存放编译的中间产物,加快编译速度;bin:存放Go项目编译生成的二进制文件

image.png

Go Path的弊端:

image.png

2.2.2 Go Vendor

Vendor 是当前项目中的一个目录,其中存放了当前项目依赖的副本。在Vendor机制下,如果当前项目存在Vendor目录,会优先使用该目录下的依赖,如果依赖不存在,会从GOPATH中寻找;但vendor无法很好解决依赖包的版本变动问题和一个项目依赖同一个包的不同版本的问题

image.png

Go Vendor的弊端:

image.png

2.2.3 Go Module

Go Modules 是Go语言官方推出的依赖管理系统,解决了之前依赖管理系统存在的诸如无法依赖同一个库的多个版本等问题,go module从Go 1.11 开始实验性引入,Go 1.16 默认开启

image.png

3. 测试

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

3.1 单元测试

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

规则:

  • 所有测试文件以 _test.go结尾
  • 测试函数为 func TestName(t *testing.T)

image.png

例子:

image.png

4. 项目实战

4.1 需求描述:

image.png

4.2 需求用例:

image.png

4.3 E-R图:

image.png

4.4 分层结构

image.png

4.5 组件工具

image.png

4.6 请求示例

首先创建好数据库和配置gorm连接

image.png 这里我们启动服务,并用apifox工具向以下api发出http请求

image.png

image.png

响应结果如下:

    "code": 0,
    "msg": "success",
    "data": {
        "TopicInfo": {
            "Topic": {
                "Id": 1,
                "UserId": 1,
                "Title": "青训营开课啦",
                "Content": "快到碗里来!",
                "CreateTime": "2022-04-01T13:50:19+08:00"
            },
            "User": {
                "Id": 1,
                "Name": "Jerry",
                "Avatar": "",
                "Level": 1,
                "CreateTime": "2022-04-01T10:00:00+08:00",
                "ModifyTime": "2022-04-01T10:00:00+08:00"
            }
        },
        "PostList": [
            {
                "Post": {
                    "Id": 1,
                    "ParentId": 1,
                    "UserId": 1,
                    "Content": "举手报名!",
                    "DiggCount": 10,
                    "CreateTime": "2022-04-01T14:50:19+08:00"
                },
                "User": {
                    "Id": 1,
                    "Name": "Jerry",
                    "Avatar": "",
                    "Level": 1,
                    "CreateTime": "2022-04-01T10:00:00+08:00",
                    "ModifyTime": "2022-04-01T10:00:00+08:00"
                }
            },
            {
                "Post": {
                    "Id": 2,
                    "ParentId": 1,
                    "UserId": 2,
                    "Content": "举手报名+1",
                    "DiggCount": 20,
                    "CreateTime": "2022-04-01T14:51:19+08:00"
                },
                "User": {
                    "Id": 2,
                    "Name": "Tom",
                    "Avatar": "",
                    "Level": 2,
                    "CreateTime": "2022-04-01T10:00:00+08:00",
                    "ModifyTime": "2022-04-01T10:00:00+08:00"
                }
            }
        ]
    }
}

我们跟踪请求,阅读代码:

1.请求到达路由后从路径上传过来了话题id,继而调用controller层的QueryPageInfo()方法

image.png

2.进行简单的参数转换后,调用service层的QueryPageInfo()方法,在Service层中,首先定义了一系列的结构体,为了方便从Dao层查询数据并封装便于返回给Controller层

image.png

3.执行QueryPageInfo()方法时,先后调用了NewQueryPageInfoFlow(topId int64)方法中和Do()方法, 在NewQueryPageInfoFlow(topId int64)方法中将topId传给了QueryPageInfoFlow结构体中的topicId

image.png

4.在Do()方法中,进行了参数校验,查询数据以及打包数据,Do()方法是一个结构体方法,通过QueryPageInfoFlow结构体的指针引用先对topicId进行和合法校验,再查询对应topicId下的相关topic信息和post信息,因为查询topic和post信息没有相关性,因此使用了两个协程并行查询,并利用WaitGroup实现与主协程同步,加快查询效率,并将发帖用户和回帖用户信息封装为userMap存入QueryPageInfoFlow结构体,最后通过packPageInfo()方法来打包topic信息和post信息为pageinfo最后返回给Controller层 image.png

image.png

image.png

5.查询数据库(gorm),首先初始化,再在对应go文件中定义跟数据库字段对应的结构体,直接贴代码:

image.png

image.png

image.png

以上就是对于127.0.0.1:8080/community/page/get/1请求的基本流程.

第二天笔记结束