这是我参与「第五届青训营 」伴学笔记创作活动的第 6 天
本文内容从工厂实践的角度,分析实际开发所遇到的难题
GO语言工程实践
并发编程
并发vs并行
通过上篇文章了解到,go的多线程性能十分优秀,go可以充分发挥多核优势高效运行。
并发是多个线程在一个核上的cpu上运行,多线程是多个线程在多个核得到cpu上并发执行。go语言主要优化了线程的调度模型。通过高效调度更好的利用调度资源。
go的高并发里有一个重要概念
协程
。
- 协程:用户态、轻量级线程、栈MB级别。
- 线程:内核态、线程可以跑多个协程、栈kb级别。
协程的使用可以减少对线程的反复创建和使用。从而减少了资源消耗。
go使用协程案例
func HelloGoRoutine(){
for i:=0;i<5;i++{
go func(j int){
println(j)
}(i)
}
time.Sleep(time.Second)
}
go通过 go
关键词来实现协程,使用go关键词后会异步执行go后面的函数,为了防止在创建完成协程后函数瞬间执行结束,从而导致无法正常的打印结果,所以我们需要让主线程睡眠一秒后再结束。
打印结果为:4、1、0、2、3 可以看出执行结果是并发的不是按照传入顺序执行的。
协程之间的通信
go提倡使用通信实现共享内存,而不是共享内存实现通信。go协程之间的通信使用的是channel进行的,虽然go也可以使用共享内存实现通信但是这种方式在一定程度上影响性能。
channel
channel的创建方式使用,
make(chan 元素类型,[缓冲大小])
- 无缓冲管道 make(chan int)
- 有缓冲管道 make(chan int,2)
channel通信案例
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)
}
}
go语言通过<-
和->
来实现对通道的输入输出,一个生产者消费者案例,案例中协程A负责发送数组到通道src,协程B负责计算输入数字的平方。然后输入结果到dest管道中,最后打印出结果。defer是对资源的关闭。
并发安全Lock
通过锁与无锁实现多线程累加
func Add(){
x=0
for i := 0; i < 5; i++ {
go addWithLock()
}
time.Sleep(time.Second)
println("addWithLock",x)
for i := 0; i < 5; i++ {
go addWithoutLock()
}
time.Sleep(time.Second)
println("addWithOutLock",x)
}
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
}
}
输出结果:
addWithLock 10000
addWithOutLock 19126
可以明显的看出,在多线程竞争的情况下导致计算结果错误。而加锁强制进行单线程执行不会出现多线程错误。
waitGroup案例
在等待协程的使用我们使用的sleep来实现,可以明显看出这样并不优雅,我们并不知道子协程何时结束,通过等待指定时间显然是不合理的。
func GoWait(){
waitGroup := sync.WaitGroup{}
waitGroup.Add(5)
for i := 0; i < 5; i++ {
go func() {
defer waitGroup.Done()
time.Sleep(time.Second)
}()
}
waitGroup.Wait()
println("123")
}
在go中我们使用WaitGroup来实现同步。开启时计数器+1执行结束-1,当计数器为0时就结束运行。使用wait方法函数就会进入等待状态,当计数器为0后会继续往下执行。
依赖管理
在项目的开发过程中,我们会用到各种依赖包,对包的管理也是十分重要的一个过程,这关系到我们项目的构建。
工程类项目不可能基于标准库从0进行编码搭建,所以我们需要学会对依赖库的管理。
go的依赖管理的演进:GOPATH -> GO Vendor -> GO Module
GOPATH实现原理是所有依赖都在src下,通过go get下载最新的源码到src目录下。
这样使用也会存在弊端。
在图中的场景中,A和B依赖于某个包的不同版本。无法实现包的多版本控制。
项目目录下增加vendor文件,所有依赖包副本形式放在vendor文件夹下,依赖寻找方式:
vendor=>GOPATH
。每个项目引入一个依赖副本,解决了多个项目需要同一个package依赖冲突问题。
这种依赖方式仍然存在问题。
项目A依赖项目B和项目C,但是项目B和C分别依赖D项目的不同版本。对于这种情况我们不能控制依赖的版本。更新项目可能出现依赖冲突,导致编译出错。vendor实际上还是直接依赖于项目的源码,不能很好的处理依赖的版本问题。
最佳的处理方式使用 go module
- 通过go.mod 文件管理依赖包版本
- 通过 go get/go mod 指令工具管理依赖包
从而实现定义版本规则和管理项目依赖关系。
依赖管理的三要素:
- 配置文件 描述依赖
go.mod
- 中心仓库管理依赖库
proxy
- 本地工具
go get/mod
go.mod文件
module juejin #依赖管理基本单元
go 1.19 #原生库
require ( #单元依赖
github.com/gin-gonic/gin v1.8.2
gorm.io/driver/mysql v1.4.5
gorm.io/gorm v1.24.3
)
依赖标识:项目路径/版本号
语义化版本:v1.0
基于commit伪版本:v1.0-230203aacd0d0
后面带有indirect的是非直接依赖。通过@
可以指定版本。
go get后面可以跟:
命令 | 作用 |
---|---|
@update | 默认 |
@none | 删除依赖 |
@v1.2 | tag版本 |
@23ff5.. | commit版本 |
@master | 分支最新的commit |
go mod 命令:
命令 | 作用 |
---|---|
init | 初始化创建go.mod文件 |
download | 下载模块到本地缓存 |
tidy | 增加需要的依赖,删除不需要的依赖 |
单元测试
单元测试在整个开发中占有相当重要的地位
graph LR
开发 --->B(测试)--避免--> 事故
测试是避免事故的最后一道屏障。
测试分为:
从上到下覆盖率逐层变大,成本却逐层降低。
单元测试的流程:
进行输入输出,然后对输出和期望值的校对。
单元测试
go的单元测试要遵循以下规则:
- 所有测试文件以_test.go结尾
- func TestXxx(*testing.T)
- 初始化逻辑放到TestMain中
单元测试案例:
func GetTom()string{
return "Jerry"
}
func TestGetTom(t *testing.T) {
tom := GetTom()
expect:="Tom"
if tom!=expect {
t.Errorf("error match")
}
}
对期望结果进行执行来查看期望结果和最终结果是否一致。
单元测试-assert
func TestGetTom(t *testing.T) {
output:=GetTom()
expectOutput:="Tom"
assert.Equal(t,expectOutput,output)
}
单元测试-覆盖率
评估代码的标准,是否经过了足够的测试。如何评价项目的测试水准?如何评估项目是否达到了高水准测试等级?
func JudgePassLine(score int16)bool{
if score >60{
return true
}
return false
}
func TestJudgePassLine(t *testing.T){
isPass:=JudePassLine(70)
assert.Equal(t,true,isPass)
}
执行覆盖率测试 go test judgment_test.go judgment.go --cover 得到测试结果。测试结果为66.7%,因为测试共三行代码成功执行两行代码,得到的覆盖率就是66.7%。
- 一般覆盖率:50-60%,较高覆盖率80%。
- 测试分支相互独立,全面覆盖
- 测试单元粒度足够小,函数单一职责
单元测试-mock
单元测试包:>monkey
对实例的方法进行打桩。用函数A去替换函数B,A是打桩函数B是原函数。在实际测试的时候实际上使用的是打桩函数。函数返回的内容是打桩函数指定的内容。
mock示例。
func TestProcessFirstLineWithMock(t *testing.T){
monkey.Patch(ReadFirstLine,func() string{
return "line110"
})
defer monkey.Unpatch(ReadFirstline)
line:=ProcessFirstLine()
assert.Equal(t,"line000",line)
}
基准测试
基准测试主要进行代码的性能测试。和对资源的损耗。
示例:
func Select() int {
return ServerIndex[rand.Intn(10)]
}
测试代码
func BenchmarkSelect(b *testing.B){
b.ResetTimer()
for i:=0;i<b.N;i++{
Select()
}
}
//并行测试
func BenchmarkSelectParallel(b *testing.B){
b.ResetTimer()
b.RunParallel(func(pb *testing.PB){
for pb.Next(){
Select()
}
})
}
测试结果:
BenchmarkSelect ----------18 ns/op
BenchmarkSelectParallel --79 ns/op
并行反而慢的原因是rand函数为了随机性和安全性在生成随机数时会进行加锁操作,会影响生成速度。并行进行串行化会影响性能。 为了解决这种情况,使用字节开源的fastrand得到结果的速度就会大幅提升。
项目实践
开发一个包括话题详情,回帖列表,支持回帖,点赞,和回帖回复,我们今天就以此为需求模型,开发一个该页面交涉及的服务端小功能。
社区话题页面-需求描述
- 展示话题和回帖
- 暂不考虑前端页面实现,仅仅实现一个本地web服务器
- 话题和回帖数据用文件存储
功能涉及用户浏览,包括话题内容和回帖的列表。
这时实体类的er图,有了模型实体,属性之间的联系,对我们后续开发就提供更加清晰的思路。
分层结构分析,整体分为三层,repository数据层,service层,controller层。
- 数据层:数据Model,外部数据的增删改查
- 逻辑层:业务Entity,处理核心业务逻辑输出
- 视图层:视图view,处理和外部的交互逻辑
开发使用Go的Gin
框架
我们需要通过话题id查询到话题,通过话题id查询到帖子详细信息。
项目初始化topicOnce sysnc.Once
的Do
方法可以确保只会执行一次,可以用在单例模式中。这和java中实现的双重验证的单例模式是不一样的。使用单例模式可以提高系统性能。
整体设计:
graph LR
A( 读取文件 )-->B( 映射数据到Map )
B--存放数据-->E
E(map)
C(Get请求)-->D(Gin的service)--取数据-->E
对于Gin框架会在我的下一篇文章进行详细介绍。