Go语言入门-工程实践(二)|青训营笔记
Go语言入门-工程实践(二)|青训营笔记
这是我参与「第三届青训营 -后端场」笔记创作活动的的第二篇笔记。
源码:https://github.com/Moonlight-Zhao/go-project-example/tree/V0
本篇的四个部分:
Go高性能的本质
并发VS并行
并发:一个核的CPU上 间歇的运行多线程程序
并行:多线程程序在多个核的CPU上运行
协程:用户态,轻量级别现成,栈KB级别
线程:内核态,线程跑多个协程,栈MB级别
简单实例:
package main
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)//启动5个协程后 主进程停一秒 让协程们执行完
}
func main() {
HelloGoRoutine()
}
/*
hello goroutine : 2
hello goroutine : 3
hello goroutine : 4
hello goroutine : 0
hello goroutine : 1
*/
CSP(Communicating Sequential Processes) 模型
GO语言提倡通过通信共享内存,而不是通过共享内存而实现通信。这里可以看这篇文章来稍微了解一下Golang CSP并发模型。
channel
channel分成有缓冲和无缓冲两种。
生产者和消费者模型简单实例:
package main
func CalSquare() {
src := make(chan int)
dest := make(chan int, 3)
go func() { //生产者 往src里添加数据
defer close(src)
for i := 0; i < 10; i++ {
src <- i
}
}()
go func() { //消费者 从src中取出数据存入dest
defer close(dest)
for i := range src {
dest <- i * i
}
}()
for i := range dest { //消费者从dest里进行消费 因为生产者的速度可能会比消费者的快很多,因此dest增加了缓冲区
//复杂操作
println(i)
}
}
func main() {
CalSquare()
}
/* 结果:
0
1
4
9
16
25
36
49
64
81
*/
并发安全 Lock
这里不知道为啥跑官方的代码我加锁不加锁是一个样子的,很奇怪,可能偶尔会出错吧。。。。
WaitGroup
简单介绍:
通过WaitGroup重现 并发VS并行中的示例:
依赖管理
背景
Go依赖管理演进
现在主要在用的就是Go Module,要从头开始讲起
GOPATH
思路:
缺陷:A项目依赖V1版本,B项目依赖V2版本,无法实现package多版本控制
GO Vendor
解决了GoPath的问题,A项目和B项目先去自己的vendor文件中寻找依赖包,这样项目与项目之间就不会起冲突。
弊端:
尽管它解决了项目之间的冲突,但是A项目进行更新的时候,如果更新后新包和旧包不兼容,则无法解决。而且没有依赖控制的版本。
GO Module
完美版本:这个可以类比Java中的Maven。
依赖管理三要素
接下来挨个介绍三要素。
依赖配置
go.mod
依赖管理基本单元:引入别的地方的包,也可以是github上项目的包
原生库:可能不同的项目对应的go的版本不同
单元依赖:前面是包名 后面是版本号,这样可以唯一指定。indirect代表非直接依赖。
version
版本控制主要是分两种:
-
语义化版本 好像是和git比较像
MAJOR代表大的版本,可以理解为不同的MAJOR之间是版本不兼容、代码隔离的。
MINOR代表小的函数增加,这必须在MAJOR兼容的情况下添加功能。
PATCH代表bug的修复。 -
基于commit的伪版本
版本前缀和语义化版本是一样的。
中间的是提交commit的时间戳。
后面的是提交时哈希码的前缀。
indirect
incompatible
当语义版本大于等于2的时候,例如v3.0.2,应当在包名后面跟上v2后缀。
对于没有go.mod文件并且主版本2+的依赖,会在后面加上一个+incompatible,代表可能会有一些不兼容的代码逻辑。
依赖图
这道题我在第一次听的时候选的C,现在看作为一个只是版本管理的系统,不会去智能的选择两个。而且1.3和1.4是相互兼容的,因此我们应当在满足所有项目的要求的情况下选择最低版本,即1.4,即便是有1.5也是选择1.4。我第二次选择了A...很尬,选了1.3那B项目直接报错了。。
依赖分发
回源
如果我们直接去依赖Github或者SVN这种第三方的源会有以下的问题:
- 无法保证构建稳定性,因为作者可以随时随地的增加或者删除自己的代码。
- 无法保证依赖可用性,因为作者删了 你就没得用了,项目就崩溃了。
- 增加第三方压力,Github本来是没想着把项目这么用的,直接依赖会导致高并发的访问,给他们带来巨大压力并违背初衷。
Proxy
我们可以将代码管理到Proxy中,这样就直接从Proxy中下载和访问,稳定且可靠,解决了上面的问题。
变量 GOPROXY
首先在第一个网址中查找,没有去第二个,还没有这个direct就是回第三方网站Github上去查找。
下面是查找的路径,Proxy1到Proxy2到Direct,只要有一个有就返回了。
工具
go get
go mod
测试
- 回归测试:测试人员手动的去点击,使用程序的功能,看是否符合预期。
- 集成测试:对系统功能维度进行验证,做一些自动化的回归测试。
- 单元测试:在一定程度上决定代码的质量,所以很重要。
单元测试
保证质量:对当前代码单元测试后发现没问题,再对历史代码进行单元测试也没问题,说明新的代码没有对旧的代码产生影响,因此可以放心的使用。
提升效率:如果出现了bug,可以先运行本地的单元测试,很快的定位问题,而不是需要编译后再去找问题。
规则
- 测试文件的命名以_test.go结尾。
- 函数命名要Test+驼峰规则。
- TestMain函数在更高维度上抽象了,初始化和释放资源可以在里面。m.Run()可以运行所有的单元测试。
例子
运行
assert
上面的例子中直接使用了不等的符号,assert包里面有很多判断相等的函数,可以用这个避免有些想的不全。
覆盖率
测试的好坏:代码覆盖率越高越好。
覆盖率的计算:因为输入是70,测试函数里的第一行和第二行执行了,第三行没执行。2/3=66.7%。
为了提高覆盖率,我们可以再写一个测试函数:
这样测试的覆盖率就是100%了。
Tips
依赖
幂等:多次运行单元测试,结果应当是一样的。
稳定:单元测试应当是能够相互隔离的,能在任何时间,任何函数进行独立的运行。
文件处理
如果这个单元测试所测试的文件被别人删除或者修改,就会丢失幂等和稳定性,接下来引入Mock测试。
Mock测试
Patch和Unpatch
打桩比较像用一个函数A替代另一个函数B,A就是打桩函数。里面有Patch和Unpatch。
Patch入参有target和replacement,target就是目标要替换掉的函数,B。replacement是打桩函数。
Unpatch就是要把桩卸下来。
实现方式就是在函数运行时,通过Unsafe包,将内存中函数的地址替换成运行时函数地址。最终在测试时调用的其实是打桩函数。
示例
所以其实就是把文件替换成了一个函数罢了。。。这里通过defer实现了对桩函数的卸载。
基准测试
- 优化代码,需要的对当前代码分析。
- 内置的测试框架提供了基准测试的能力。
例子
这里我们要对Select()函数做基准测试。
运行
这里InitServerIndex()是为了初始化服务器索引,初始化后我们需要重置时间,因为这一部分不是测试所花的时间。
为啥多协程并发反而更慢?因为Select()函数用到了Rand包,会加一个全局锁,因此更慢。
优化
将Rand包改成fastrand,牺牲了一部分的随机数的一致性,让测试更快的执行。
项目实战
需求描述
需求用例
Topic就是话题,PostList就是回帖列表。
E-R图
Entity Relationship Diagram
Topic对Post是一对多。
分层结构
- 数据层:数据Model,外部数据的增删改查。Service不需要管数据层Repository如何实现,只需要关心Model的数据就行。
- 逻辑层:业务Entity,处理核心业务逻辑输出。组装数据层传过来的数据,例如订单需要很多信息去组合。
- 视图层:视图view,处理和外部的交互逻辑。这里更多的是和上层去交互,json格式化一些结果,封装一些API。
组件工具
运行go get这个命令,添加gin依赖。这个gopkg.in网址可以打开看一看,里面版本是和github对应的。
Repository
index
可以考虑把数据放入到map中,来获取O(1)的查找。
数据初始化,从文件中读取数据到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)//创建map key是int value是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//map中id:对象地址
}
topicIndexMap = topicTmpMap
return nil
}
查询
package repository
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 {//因为这个包在repository下,因此这个函数可以通过repository.NewPostDaoInstance()来调用 返回一个PostDao
postOnce.Do(
func() {//只初始化一个PostDao
postDao = &PostDao{}
})
return postDao
}
func (*PostDao) QueryPostsByParentId(parentId int64) []*Post {//这个函数属于PostDao对象
return postIndexMap[parentId]
}
这里的索引是话题ID,数据是话题。而我们有一个功能是话题下很多个帖子,索引:话题ID,数据是帖子列表,这就需要在逻辑层进行组装。
Service
实体
type PageInfo struct {
Topic *repository.Topic
PostList []*repository.Post
}
流程
参数校验:判断id是否合法,不能为0之类的。
代码流程编排
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) prepareInfo() error {
//获取topic信息
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
topic := repository.NewTopicDaoInstance().QueryTopicById(f.topicId)
f.topic = topic
}()
//获取post列表
go func() {
defer wg.Done()
posts := repository.NewPostDaoInstance().QueryPostsByParentId(f.topicId)
f.posts = posts
}()
wg.Wait()
return nil
}
这里的代码串联起了开始的知识,通过WaitGroup并行读取两种数据,并且这两种操作必须是没有关联的,不然会产生冲突,感觉是多协程的经典案例。
Controller
构建View对象,定义业务错误码。
package cotroller
import (
"strconv"
"github.com/Moonlight-Zhao/go-project-example/service"
)
type PageData struct {//构建View对象
Code int64 `json:"code"`//定义业务错误码
Msg string `json:"msg"`
Data interface{} `json:"data"`
}
func QueryPageInfo(topicIdStr string) *PageData {
topicId, err := strconv.ParseInt(topicIdStr, 10, 64)//这里是传入ID为字符串,转成Int类型
if err != nil {
return &PageData{
Code: -1,//如果转int失败返回-1
Msg: err.Error(),
}
}
pageInfo, err := service.QueryPageInfo(topicId)
if err != nil {
return &PageData{
Code: -1,
Msg: err.Error(),
}
}
return &PageData{
Code: 0,//成功返回0
Msg: "success",
Data: pageInfo,
}
}
Router
初始化数据索引;初始化引擎配置;构建路由;启动服务。
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
}
}
运行效果
go run .\server.go 运行项目
通过Postman发送Get请求
达成效果。