1.语言进阶&依赖管理
1.1 语言进阶
1.1.1 并发&并行
在单核CPU下,线程实际还是串行执行的。操作系统中有一个组件叫做任务调度器,它将CPU的时间片(window下最小约为15毫秒)分给不同的程序使用,只是由于CPU在线程间的切换非常快,人类一般是感觉不到的,所以会觉得他们是同时运行的。一句话说就是微观串行,宏观并行。
一般将这种线程轮流使用CPU的做法称为并发,concurrent,多核 cpu下,每个 核(core) 都可以调度运行线程,这时候线程可以是并行的。
这里引用Golang
语言创造者的Rob Pike
的一句描述:
提到高并发,Go也是一大利器。
1.1.2 线程&协程

线程
一个进程之内可以分为一到多个线程,一个线程就是一个指令流,指令流的一条条指令按照一定的顺序加载给CPU执行,比如在Java中,线程作为最小的调度单位,进程作为资源分配的最小单位。
协程
协程不是系统线程,很多时候被称为轻量级线程、微线程、纤程等,简单来说可以认为协程是线程里面不同的函数,这些函数之间可以相互快速切换,协程和用户线态线程非常接近,用户态线程之间的切换不需要陷入内核,但这不是绝对的,部分系统中的切换也是需要内核态线程的辅助的。协程是编程语言提供的特性(之间的切换方式与过程可以由程序员确定),属于用户态操作。
小结
在Go语言中开启协程只需要在函数调用之前加上go
关键字即可。比如下面这段代码,通过协程的方式,打印一段输出。
package main
import (
"fmt"
"time"
)
func main() {
HelloGoRoutine()
}
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)
}
Go的CSP并发模型
与其他编程语言不同,Go语言除了支持传统语言的 多线程共享内存并发模型之外,还有自己特有的 **CSP(communicating sequential processes)**并发模型,一般情况下,这也是Go语言推荐使用的。
与传统的 多线程通过内存来通信 相比,CSP讲究的是 以通信的方式来共享内存。

普通的线程并发模型,他们的线程通信一般是通过共享内存的方式来进行的,非常典型的方式就是,在访问共享数据(数组、Map等)的时候是通过锁来访问,因此很多时候会衍生出一种叫做 线程安全的数据结构的东西。
而Go的CSP
并发模型则是通过goroutine
和channel
来实现的。
goroutine
是Go语言中并发的执行单位。可以理解为用户空间的线程。channel
是Go语言中不同goroutine
之间的通信机制,即各个goroutine
之间通信的”管道“,有点类似于Linux
中的管道。
Channel
channel
类似与一个队列,满足先进先出的规则,严格保证收发数据的顺序,每一个通道只能通 过固定类型的数据如果通道进行大型结构体、字符串的传输,可以将对应的指针传进去,尽量的节省空间。

在Go中,可以通过make
函数创建通道(channel
,后文都简称通道)。
基本操作
- 操作符
<-
取出数据使用操作符 <-
操作符右边是输入变量,操作符左边是通道代表数据流入通道内.
// 声明一个通道
var a chan int
a <- 5
下面通过一段简单的生产-消费模式的代码示例,熟悉channel
的基本使用。
package main
func main() {
CalSquare()
}
func CalSquare() {
//无缓冲channel
src := make(chan int)
//有缓冲channel
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 {
//TODO
println(i)
}
}
并发安全Lock—传统并发模式
考虑下面这个场景:
package main
import (
"sync"
"time"
)
var (
x int64
lock sync.Mutex
)
func main() {
Add()
}
// 加锁的自增方法
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)
println("没加锁:", x)
x = 0
for i := 0; i < 5; i++ {
go addWithLock()
}
time.Sleep(time.Second)
println("加锁的:", x)
}
上面的程序中,同样都是启用5个协程并发对x
自增2000
次,一个使用了lock
一个没有,在并发情况下,没有加锁的操作可能会引起数据的错误,比如未加锁情况下最终的是8337
,而加锁的情况下确实正确结果10000
,可见加锁在一定程度上可以防止数据错误,保证了原子性。
WaitGroup
在Go语言中除了使用通道(channel
)和互斥锁(lock
)进行并发同步之外,还可以使用等待组WaitGroup
来完成多个任务的同步,与前面提到的锁不同,等待组可以保证在并发环境中完成指定数量的任务。

在WaitGroup
类型中,每个sync.WaitGroup
内部维护了一个计数器,初始默认值为0,详情见上图所示。计数器计数逻辑如下:
修改之前 使用协程打印输出的代码:
package main
import (
"fmt"
"sync"
"time"
)
func main() {
//HelloGoRoutine()
ManyGWaitGroup()
}
func hello(i int) {
println("hello goroutine:" + fmt.Sprint(i))
}
func ManyGWaitGroup() {
var wg sync.WaitGroup
//增加5个等待组数量
wg.Add(5)
for i := 0; i < 5; i++ {
go func(j int) {
//当一个goroutine完成之后,减少一个等待组
defer wg.Done()
hello(j)
}(i)
}
//直到所有的操作都完成
wg.Wait()
}
func(*WaitGroup) Add(delta int)
func(*WaitGroup) Done
func(*WaitGroup) Wait
1.2 依赖管理
1.2.1 GOPATH
目前为止,Go的依赖管理主要经历了三个阶段:
整个路线主要围绕着实现下面两个目标来迭代发展的:
- 不同环境(项目)依赖版本不同
- 控制依赖库的版本

弊端

- 没有版本控制的概念
- 所有的项目需要存放在
$GOPATH/src
目录下,否则就不能编译。
1.2.2 Go Vender

- 项目目录西卡增加了
vender
文件,所有的依赖包副本形式存放在ProjectRoot/vender
下。 - 依赖的寻址方式:
vender->GOPATH
不足

- 无法控制依赖版本
- 更新项目又可能出现依赖冲突,导致编译出错
1.2.3 GoModule
核心三要素
- 配置文件,描述依赖-
go.mod
- 中央仓库管理依赖库-
Proxy
- 本地工具-
go get/mod
go.mod
启用了 Go modules
的项目,初始化项目时,会生成一个 go.mod
文件。描述了当前项目(也就是当前模块)的元信息。

版本管理

indirect
依赖图
依赖分发&回源
github
是比较常见的代码托管平台,而Go Modules
系统中定义的依赖,最终可以对应到多版本代码管理系统中某一个项目的特定提交版本,这样的话,对于go mod
中定义的依赖,则直接可以从对应仓库中下载指定的软件依赖,从而完成依赖分发。
但是直接使用版本管理仓库下载依赖,会存在一些问题,比如无法保证构建确定性,软件作者可以直接在代码平台对软件的版本进行增删改查,导致下次构建使用的是另外一个版本依赖,或者找不到依赖版本,无法保证依赖的可用性,依赖软件作者可以直接在平台删除软件,导致依赖不可用,大幅度增加第三方代码托管平台的压力。 基于此,可以使用下面的Proxy
的方式解决这些问题。
Proxy
变量GOPROXY
go module
通过goproxy
环境变量控制如何使用go proxy;goproxy
是一个goproxy
站点的URL
列表,可以使用direct
表示源站。对于示例配置,整体的依赖寻找路径,会优先从proxy
下载依赖,如果proxy1
不存在,会从proxy2
继续寻找,如果proxy2
z中不存在则会返回到源站直接下载依赖,缓存到proxy
站点中。
go get
官方文档
go mod
2.测试&项目实战
2.1 测试
- 单元测试
Mock
测试- 基准测试
什么?你写代码不用测试?不要钱了是吧!
所以,测试就成了避免事故的最后一道屏障。
- 回归测试一般是
QA
同学手动通过终端回归的一些固定的主流程场景。 - 集成测试是对系统功能维度做的测试验证;
- 单元测试测试开发阶段,开发者对单独的函数、模块做功能的测试,层级从上至下,测试成本逐渐减低,而覆盖率逐步上升,所以单元测试的覆盖率一定程度上决定着代码的质量
2.1.1 单元测试
单元测试主要包括输入、测试单元。输出以及校对,单元测试的概念比较广,包括了接口、函数、模块等;用最后的校对来保证代码的功能与我们的预期相符;一方面可以保证质量,在整体覆盖率足够的情况下,一定程度上既保证了新功能本身的正确性,也不会破坏原有代码的正确性。另一方面可以提升效率,在代码有bug的情况下,通过编写单元测试,可以在一个较短的周期内定位和修复问题。
单元测试规则
下面是单元测试的一些基本规范,这样从文件上就很好的区分源代码和测试代码,以Test
开头,且理解的第一个字母大写。
package test
import "testing"
func HelloTom() string {
return "Jerry"
}
func TestHelloTom(t *testing.T) {
output := HelloTom()
expectOutput := "Tom"
if output != expectOutput {
t.Errorf("Expected %s do not match actual %s", expectOutput, output)
}
}
单测-assert
前面测试直接使用的是比较运算符,除此之外,还有很多现有的aeert
包可以帮助我们实现测试中的比较操作。
package test
import (
"github.com/stretchr/testify/assert"
"testing"
)
func HelloTom() string {
return "Tom"
}
func TestHelloTom(t *testing.T) {
output := HelloTom()
expectOutput := "Tom"
assert.Equal(t, expectOutput, output)
/*if output != expectOutput {
t.Errorf("Expected %s do not match actual %s", expectOutput, output)
}*/
}
单测-覆盖率
package test
import (
"github.com/stretchr/testify/assert"
"testing"
)
func JudgePassLine(score int16) bool {
if score >= 60 {
return true
}
return false
}
func TestJudgePassLineTrue(t *testing.T) {
isPass := JudgePassLine(80)
assert.Equal(t, true, isPass)
}
这是一个判断成绩是否合格的程序,返回bool,输入分数为80,执行测试之后发现只有%66.7
左右的覆盖率。因为用例为80的时候,只是跑了程序的前面两行,也就是分数大于60的逻辑,而剩下的返回false
部分的逻辑并没有得到测试,所有覆盖率自然不会是100%
。
所以,我们新增一个测试如下:这样就可以做到测试覆盖率百分百。
func TestJudgePassLineTrue(t *testing.T) {
isPass := JudgePassLine(80)
assert.Equal(t, true, isPass)
}
func TestJudgePassLineFail(t *testing.T) {
isPass := JudgePassLine(40)
assert.Equal(t, false, isPass)
}
单测-Tips
单测-依赖
实际工程中,复杂的项目一般都会有依赖,而我们单元测试需要保证稳定性和幂等性,稳定性是指相互隔离,能在任何环境、任何时间运行测试。
幂等性指的是每一次测试运行都因该产生与之前一样的结果,而要实现这一目的就要用到Mock
机制。
示例-文件处理
如图,这个例子中,我们将文件中的第一行字符串中的11
替换为00
,执行单元测试并通过单元测试,而我们的单元测试需要依赖本的文件,如果文件被修改或者删除,测试就会出现fail
。为了保证测试case
的稳定性,就需要对读取文件的函数进行mock
屏蔽对于文件的依赖。
2.1.2 Mock测试
我们可以使用Monkey
,这个开源的mock
测试库,对method
或者实例的方法进行mock
,Mockey Patch
的作用域在Runtime
,在运行时通过Go
的unsafe
包,能够将内存中的函数地址替换为运行时函数地址。
快速Mock
函数:
- 为一个函数打桩
- 为一个方法打桩
参考阅读
2.1.3 基准测试
[见]【高质量编程与性能调优】篇
2.2 项目实战
2.2.1 需求背景
社区话题页面
- 展示话题(标题、文字描述)和回帖列表
- 暂不考虑前端页面的实现,仅仅实现一个本地
web
服务 - 话题和回帖数据用文件存储,不涉及数据库连接
需求用例
用户浏览页面消费,涉及页面的展示,包括话题内容和回帖列表,从图中可以抽象出来两个实体,以及实体之间的属性与联系,从而定义出对应的结构体。
E-R图
2.2.2 分层结构
- 数据层:数据
Model
,外部数据的增删改查 - 逻辑层:业务
Entity
,处理核心业务逻辑输出 - 视图层:视图
View
,处理和外部的交互逻辑
组件工具
-
gin
-
go mod
go mod init
go get
如果执行
get
命令之后长时间没反应出现超时情况,可以在命令行执行下面的命令之后再次尝试即可下载。go env -w GOPROXY=https://goproxy.cn
2.2.3 开发步骤
Reposity
数据索引
由于需要根据ID查询到帖子和话题数据,在没有使用数据库的情况下, 我们如何实现呢?最直接的方式就是针对数据文件进行全盘扫描,但显然这是比较耗时的操作,并不是最优的选择,所以这里就用到了 索引的概念。
索引就类似于书本的目录,通过这种方式,我们可以快速的定位到我们所需内容的位置。具体的,这里使用map
结构来实现内存索引,在数据服务对外暴露之前,利用文件元数据初始化全局内存索引,这样就可以实现O(1)
时间复杂度的查找操作了。
- 初始化话题数据索引
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
}
- 查询话题数据
func NewTopicDaoInstance() *TopicDao {
topicOnce.Do(
func() {
topicDao = &TopicDao{}
})
return topicDao
}
func (*TopicDao) QueryTopicById(id int64) *Topic {
return topicIndexMap[id]
}
Service
具体的编排流程,通过err
控制流程退出,正常会返回页面信息。
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
}
接下来只需要编写对应的每一个实现方法即可。写完controller
之后,创建一个服务启动入口server.go
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 := controller.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生成需要保证不重复、唯一性
Append
文件,更新索引、注意Map
的并发安全问题
2.2.4 项目小结
-
os.Open()
-
func NewReader(rd io.Reader) *Reader
致谢&参考
- 协程的概念
- 《Java并发编程之美》
- 字节内部课
PPT
- 使用monkey进行mock
- Go在线手册