这是我参与「第五届青训营」伴学笔记创作活动的第 2 天
学习完一门语言的基础语法后,要知其所以然,会运用才是进阶!
一、本堂课重点内容
本节课程主要分为四个方面讲解:
- 并发编程
从并发编程的视角带大家了解Go高性能的本质
- 依赖管理
了解Go语言依赖管理的演进路线
- 单元测试
从单元测试实践出发,提高大家的质量意识
- 项目实战
通过项目需求、需求拆解、逻辑设计、代码实现带领大家感受下真实的项目开发
二、详细知识点介绍
1. 并发编程
Go的特点——“快”!为什么?
1.1 并行 VS 并发
并发:多线程程序在一个核的CPU上运行 —— 两个或多个事件在同一时间间隔内发生
并发:多线程程序在多个核的CPU上运行 —— 两个或多个事件在同一时刻发生
Go可以充分发挥多核优势,高效运行
协程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
提倡通过通信共享内存而不是通过共享内存而实现通信
通道Channel
- 无缓冲通道也被称为同步通道;
- 有缓冲通道可以类比红石中继器(但是有点不一样,只是效果类似);
通道是用来传递数据的一个数据结构,可以用于两个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实现同步
三种方法:Add、Done、Wait
使用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 背景
2.2 Go依赖管理演进
GOPATH
- 项目代码直接依赖src下的代码
- go get 下载最新版本的包到src目录下
弊端
场景:A,B依赖于某一package的不同版本
问题:无法实现package的多版本控制
Go Vender
- 项目目录下增加Vender文件,所有依赖包副本形式放在 $ProjectRoot/Vender
- 依赖寻址方式:vender => GOPATH
通过每个项目引入一份依赖的副本,解决了多个项目需要同一个package依赖的冲突问题
弊端
- 无法可控制依赖的版本
- 更新项目又可能出现依赖冲突,导致编译错误
GO MODULE
- 通过 go.mod 文件管理依赖包版本
- 通过 go get/go mod 指令工具管理依赖包
终极目标: 定义版本规则和管理项目依赖关系
2.3 依赖管理三要素
-
配置文件,描述依赖 go.mod
-
中心仓库管理依赖库 Proxy
-
本地工具 go get/mod
可以类比Java中的maven
详细解释:
go.mod
依赖标识: [Module Path][Version/Pseudo-version]
version的两种类型
- 语义化版本
- 基于commit伪版本
indirect
对于没有直接表示的模块会在go.mod
中加上// indirect
,例如(A->B->C)
(A->B是直接依赖,A->C是间接依赖)
incompatible
- 主版本2+模块会在模块路径增加/vN后缀
- 对于没有
go.mod
文件并且主版本2+的依赖,会加上+incompatible
,表示可能会存在一些不兼容的代码逻辑(如上图)
依赖图
选择最低兼容版本 -> B
依赖分发——回源
实质就是这个依赖要去哪里下载,如何下载的问题。
实际上是用Proxy
来缓存,保证了依赖的稳定性。
依赖分发——Proxy
依赖分发——变量 GOPROXY
查找依赖的逻辑:如果最后都没找到就会回到第三方Direct
工具——go get
工具——go mod
3. 测试
质量就是“生命”
可能出现的事故:
测试是避免事故发生的最后一道屏障
分类:
- 回归测试
- 集成测试
- 单元测试
从上到下,覆盖率逐层变大,成本逐层降低
3.1 单元测试
所以,单元测试的质量某种程度上决定了代码的质量
3.1.1 测试规则
- 所有测试文件以_test.go结尾
- func TestXxx(*testing.T)
- 初始化逻辑放到TestMain中
3.1.2 例子
func TestHelloTom(t *testing.T) {
output := HelloTom()
expectOutput := "Tom"
assert.Equal(t, expectOutput, output)
}
func HelloTom() string {
return "Tom"
}
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"
}
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)
}
输出:
- 一般覆盖率:
50%~60%
,较高覆盖率:80%+
- 测试分支相互独立、全面覆盖
- 测试单元粒度足够小,函数单一职责
3.2 单元测试——依赖
- 幂等:重复运行同一个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…
需求:社区话题页面
- 实现一个展示话题(标题,文字描述)和回帖列表的后端http接口;
- 本地文件存储数据(暂不考虑前端页面实现,仅实现一个本地web服务)
- 话题和回帖数据用文件存储
组件及技术点
-
web框架:Gin - github.com/gin-gonic/g…
- 了解go web框架的简单使用
-
分层结构设计:github.com/bxcodec/go-…
- 了解分层设计的概念
-
文件操作:读文件pkg.go.dev/io
-
数据查询:索引www.baike.com/wikiid/5527…
4.2 具体逻辑与框架:
需求用例:
4.3 ER图(Entity Relationship Diagram)
- 话题
- 帖子
4.4 分层结构
-
数据层:数据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)
的时间复杂度查找操作。
初始化话题内存索引具体实现:
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(逻辑层)
实体:
流程:
代码流程编排:
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
}
可用性:
代码如下图:
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
}
运行图:
三、课后实践
- 支持对话题发布回帖。
- 回帖id生成需要保证不重复、唯一性。
- 新加回帖追加到本地文件,同时需要更新索引,注意Map的并发安全问题。
四、课后个人总结
今天学习受益匪浅,知道了项目学习的全部流程,这些案例给我学习到很深刻的感触,一个项目的形成需要如此多的步骤,这些或许在Java中学习过,但是却没有如此印象深刻,今天的学习给我更多了解,将给我在今后提升很大,今后的路还很长,还需要继续努力。