这是我参与「第五届青训营 」伴学笔记创作活动的第 2 天
Day02 -- 工程实践
1.语言进阶——并发编程
1.1 Goroutine(协程)
并发与并行
并发:多线程程序在一个核的cpu上的运行
并行:多线程程序在多个核的cpu上运行
而go语言可以充分发挥多核性能,解决高并发问题
线程与协程
线程:是内核态,消耗操作系统的性能,线程跑多个协程,栈KB级,非常消耗资源
协程:轻量级,创建协程不需要消耗操作系统的资源,编程语言自身就能完成这项操作,所以协程也被称作用户态线程。栈MB级
如何开启协程
实例:快速打印hello groroutine
go 语言开启协程的方法就是在函数名前加一个人go
func hello(i int){
println("hello goroutine : " + fmt.Sprint(i))
}
func closure() {
for i := 0; i < 3; i++ {
go func(j int) {
hello(j)
}(i)
}
time.Sleep(time.Second)
}
time.Sleep(time.Second)的作用是暴力阻塞,保证子协程结束前主协程不退出
输出结果如图:
可以发现输出是乱序,可以知道,是并行打印的
1.2 CSP
狗语言提倡通信来共享内存,通过chanel通道(类似于消息队列,遵循先入先出)
通过chanel通道让一个goroutine到另一个goroutine
由于共享内存实现数据交换时,需要对数据加锁,存在数据竞争问题,因此选择了通过通信共享内存
1.3 Channel
通道通过make关键字创建,int是要传输数据的类型
无缓冲通道不用指定缓存内存,因此数据的传输是同步的,因此可以通过缓存的方法来解决
同时缓存的方式也更贴切现实中生产与用户的关系
实例:A子协程发送0~9数字,B子协程计算输入数字的平方主协程输出最后的平方数
首先创建两个子协程
src := make(chan int)
dest := make(chan int, 3)
A子协程发送0~9数字:
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
}
}()
输出结果:
for i := range dest {
println(i)
}
这个地方的输出也可以换成其他的复杂操作
从输出的结果中可以看出,数据的传输是有序的
1.4并发安全LOOK
go提倡通信共享内存,但是还是不可避免的会出现共享内存通信,多个协程抢占一个资源,为了避免这种问题,就要用到锁
互斥锁sync.Mutex
sync.Mutex是一个互斥锁,可以由不同的goroutine加锁和解锁。
sync.Mutex
是Golang标准库提供的一个互斥锁,当一个goroutine
获得互斥锁权限后,其他请求锁的goroutine
会阻塞在Lock()
方法的调用上,直到调用Unlock()
方法被释放。
下面是加锁不加锁的结果对比
测试是对变量x进行2000次加一操作,由5个协程并发执行,这样就会出现资源抢占
加锁的函数每次执行加法时都会将资源权限关闭,直到计算完成才释放权限
观察结果发现,加锁后的结果是正确的,而不加锁出现了奇怪的结果,这就是并发安全问题,会有一定概率导致结果奇怪,切问题不好排查
因此在开发时要避免共享内存时出现并发安全问题
1.5 WaitGroup
之前我们为了子协程结束前主协程不退出,使用Sleep函数进行暴力阻塞,但是这种方法并不是最优的,因为我们不知道子协程具体的执行时间,因此,狗语言提供了sync包下的WaitGroup来解决该问题
主要有以下3个方法
主要使用计数器来解决这个问题
当启动了n个并发任务时,计数器初始为n,每完成一个并发任务,调用Done方法计数器-1,用Wait阻塞,当所有并发任务完成时,计数器值为0,结束
具体实现用法如下
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()
}
2.依赖管理
在开发时,我们不能所有东西都从0开始,而是更多的使用已经开发测试好,封装好的组件,专注于业务逻辑
GO的依赖管理经历了3个阶段GOPATH -> GO VENDER -> GO MODULE
GOPATH
项目所有依赖的源码都放在src目录下,这样就会出现弊端
项目A依赖v1,项目B依赖v2,而v2比v1进行了升级,可能发生了变化,导致启动两个项目时会出现错误
GO VENDER
项目目录下增加vendor文件,所有依赖包副本形式放在$ProjectRoot/vendor
依赖寻址方式: vendor => GOPATH
通过每个项目引入一份依赖的副本,解决了多个项目需要同一个 package依赖的冲突问题。这样即使发生升级,也会有副本存在
但是这种方式也有弊端
即A依赖了B,C,但是B,C又依赖了不同版本的D,无法控制依赖的版本
GO MODULE
GO 1.16默认开启
通过go.mod文件管理依赖包版本
通过go get/go mod指令工具管理依赖包
依赖管理的三要素:
1.配置文件,描述依赖 go.mod
2.中心仓库管理依赖库 Proxy
3.本地工具 go get/mod
类似java中maven对依赖的管理,导入依赖
依赖配置-go.mod
依赖管理基本单元:标识了模块路径
单元依赖又两部分组成:模块路径和版本号
依赖配置-version
1.语义化版本
{MINOR}.${PATCH}
MAJOR:大版本,不同MAJOR可以不兼容
MINOR:新增函数与功能,相同MAJOR下的不同MINOR要兼容
PATCH:代码bug修复
如:V1.3.0
2.基于commit伪版本
vx.0.0-yyyymmddhhmmss-abcdefgh1234
中间部分是一个提交的时间戳
后面是一个12位哈希码
依赖配置-indirect
对于没有直接依赖的用indirect标注
依赖配置-incompatible
主版本2+模块会在模块路径增加/N后缀。
对于没有go.mod文件并且主版本2+的依赖,会+incompatible
依赖最低兼容版本选择
如果X项目依赖了A、B两个项目,且A、B分别依赖了C项目的v1.3、v1.4两个版本,最终编译时所使用的C项目的版本为哪个呢?
答案是v1.4,因为go对版本的选择使用最低兼容版本,因为1.3与1.4是兼容的所以会优先选择1.4
这就是依赖最低兼容版本选择
依赖分发-回源
可以直接从github上去下载依赖,但是这样会有几个问题
1.无法保证构建稳定性增加/修改/删除软件版本,作者可以随时修改代码,导致我们本地的代码出现问题
2.无法保证依赖可用性删除软件,作者可能会删除
3.增加第三方压力,代码托管平台负载问题
依赖分发-Proxy
Proxy会缓存一些软件内容,缓存的版本不会改变,通过这个中间站点,保证了代码稳定性
我们可以直接去Proxy去选择一些依赖
依赖分发-变量GOPROXY
通过控制GOPROXY环境变量来控制Proxy配置
GOPROXY="proxy1.cn, proxy2.cn ,direct”
是url列表,direct表示,前两个地方都没有时,会回到源站去
以下是依赖查找路径示意图:
工具1-go get
默认加@update,拉取最新版本
工具2-go mod
每个项目开始前init去初始化创建一个go.mod文件
经过代码的修改,可能有些依赖已经不需要了,可以每次上传代码时进行tidy操作,来更新依赖
3.测试
测试是避免事故必要的屏障
单元测试一定程度上决定了代码的质量
通过输入到测试单元得到输出,如我们的预期输出进行校对来验证代码
这里的测试单元可以是函数,可以是模块,一些聚合函数等等
单元测试可以提升测试效率,可以快速定位错误
1.1单元测试--规则
1)所有测试文件以_test.go结尾
2)func TestXxx(*testing.T)函数名采用驼峰命名(注意如果命名有误可能无法正常运行)
3)初始化逻辑放到TestMain中
TestMain可以进行初始化,对数据装载等前置工作,测试结束后要进行释放资源等收尾工作
run就是跑pakage下所有的单元测试
1.2 实例:
现在写好一个HelloTom函数要输出Tom
然后编写测试函数对输出结果校对,看是不是Tom,如果不是,说明函数功能有误
尝试去Run写好的测试函数,看看得到的结果
提示错误,说明与预期功能不符
1.3 单元测试--assert
通过assert包 去实现一些比较功能较为方便
"github.com/stretchr/testify/assert"
将刚刚的HelloTom函数修改,再次测试,如果正确,会直接PASS
1.4 单元测试--覆盖率
表示测试的覆盖度,评价测试标准
覆盖率计算实例:
被测试函数是一个查看及格率的函数
在测试函数中,我们传参70
在刚刚运行测试代码的后面加 -cover即可查看覆盖率,这里我们看到覆盖率为66.7%
那如何计算覆盖率呢??
在测试时传参了70,可以看到被测试函数主要代码是3行,70的参数执行了if语句,也就是2行,2/3也就是66.7%
如何提高覆盖率??
答案是补充测试分支,让测试用例尽可能完备
如图,补充一个60分一下的测试,这样所有的代码都被测试了,覆盖率为100%
在实际开发时,覆盖率一般很难达到100%
一般50~60,对于一些较高要求的业务时,要求80+,比如涉及交易业务
测试的分支要相互独立,全面覆盖
测试的单元粒度要足够小,函数单一职责
2.单元测试--依赖
单元测试要求幂等和稳定
幂等就是多次测试得到的结果相同
稳定就是在任何时间,任何条件时都可以独立的测试
但是在测试时会要求网络,因此可能需要MOCK操作
3.1单元测试--文件处理
先来看一段文件操作的代码
功能函数1主要是对文件进行遍历读入,第二个功能函数式将每行中的11换成00,下面是一个测试函数
但是对于文件的问题就是测试文件可能被修改,这样就无法满足在特定场景下测试
这就需要MOCK
3.2 单元测试--MOCK
常用的mock包,monkey
https://github.com/bouk/monkey
什么是mock?
mock 测试,当待测试的函数/对象的依赖关系很复杂,并且有些依赖不能直接创建,例如数据库连接、文件I/O等。这种场景就非常适合使用 mock 测试。简单来说,就是用 mock 对象模拟依赖项的行为。
什么是打桩?
打桩是软件测试里单元测试的一种方法,单元测试涉及手工编写测试集、指定输入数据以及为缺少的函数提供桩函数。给桩函数提供返回值叫做打桩。
其中Patch可以为函数打桩,包括2个方法
target是原函数,也是目标被替换的函数
replcement是需要打桩的函数
UnPatch是测试结束后,将桩卸载的函数
在运行时我们运行的是打桩函数,去模拟功能
我们去修改刚刚对文件操作的测试函数
可以看到,打桩函数是每次都只让读line110,这样测试完全不需要依赖文件也可以了
4.基准测试
对当前代码进行分析,优化代码,而GO内置的框架提供了基准测试
测试方法类似于单元测试
一下是一个负载均衡的模拟代码,模拟随机10个服务器并进行选择
下面是对服务器选择的串行,并行基准测试
基准测试函数以Benchmark开头和单元测试类似
由于服务器初始化的时间不是我们测试业务的时间范围,在开始选择前,都进行了定时器重置
然后得到测试结果发现并行的时间较长
这是因为并行时使用了rand函数考虑随机数量的一致性并发问题,持有了全局锁,降低了并发性能
这里调整使用FastRand函数
包:
https://github.com/bytedance/gopkg
时间效率会大大提高,不过使用FastRand会使随机一直性损失,不过无伤大雅
4.项目实践
需求背景:有话题详情,帖子,回帖,点赞,恢复等
需求描述:社区话题页面,展示话题(标题,文字描述)和回帖列表
,暂不考虑前端页面实现,仅仅实现一个本地web服务,话题和回帖数据用文件存储(这里简化操作,不和数据库相连,方便理解)
对需求分析后可以画出话题topic和帖子post实体类 的E-R图
分层结构:
类比于java开发的三层结构模式,所做的功能类似,实际开发根据具体功能拆分结构
数据层:数据Model,外部数据的增删改查
逻辑层:业务Entity,处理核心业务逻辑输出
视图层:视图view,处理和外部的交互逻辑
Gin高性能web框架:
https://github.com/gin-gonic/gin#installation
Go Mod:
go mod init go get gopkg.in/gin-gonic/g…
1.Repository
Topic实体需要实现的操作时根据id查询话题QueryTopicById
Post实体需要实现的是根据话题id去查询相关的帖子
这里使用索引去检索数据
var (
topicIndexMap map[int64]*Topic
postIndexMap map[int64][]*Post
)
然后去初始化话题数据
打开文件,并迭代读取,存储到map中
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
}
接下来是查询的操作,根据话题id作为索引去查询话题
这里使用sync.Once单例模式,和java中spring的操作类似
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]
}
回帖部分类似,此处不再展示
2.Service
实体类的创建
type PageInfo struct {
Topic *repository.Topic
PostList []*repository.Post
}
流程:
参数校验-->准备数据-->组装实体
对应的主流程如下
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
}
3.Controller
构建返回信息,类似java的Result类,包含code,message,data
type PageData struct {
Code int64 `json:"code"`
Msg string `json:"msg"`
Data interface{} `json:"data"`
}
构建view视图,并对业务错误进行处理
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.框架搭建
初始化数据索引,舒适化引擎配置,创建陆青,发送请求,启动服务
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
}