go语言进阶+项目实战+作业|青训营笔记

214 阅读12分钟

这是我参与「第三届青训营-后端场」笔记创作活动的第2篇笔记。

0.课前准备:

代码地址 github.com/Moonlight-Z…

执行get/mod指令时连接github超时(timeout)怎么办

在命令行输入go env可以查看当前环境变量(变量按照名称字符串大小排序),未经配置的情况下可以找到如下两行:

GO111MODULE=""
...
GOPROXY="https://proxy.golang.org,direct"

代理缓存服务器的网址为.org造成下载超时。在命令行输入go env -w GO111MODULE=on打开mod,再输入go env -w GOPROXY=https://goproxy.cn,https://proxy.golang.org,direct在代理路径中添加国内的服务器。可以看到环境变量被改为:

GO111MODULE="on"
...
GOPROXY="https://goproxy.io/zh/,https://proxy.golang.org,direct"

此时使用go getgo mod dity不再超时。

1.并发编程

并发与并行

并发:cpu通过时间片的方法在进程间快速切换,让进程同时进行。

并行:是并发的一种手段,利用多核实现多线程程序同时运行(需要硬件支持)。

1.1 Goroutine协程

协程:轻量级,运行在用户态的线程,MB级别。

线程:运行在内核态,KB级别。

go语言一个进程可以创建上万的协程,从而高效的并发。使用方法:在函数前加关键字go。

1.2 CSP(Communicating Sequential Processes)

协程间的通信:通过通信实现共享内存(go的通道Channel),而不是通过共享内存实现通信(C的pv信号量,需要加锁)。

1.3 Channel

无缓冲通道:通过make(chan 元素类型)定义。由于无缓冲区,所以当消息传递的时候,发送方一定在发送消息,接收方一定在接受消息,所以两个gorutine会同步。

有缓冲通道:通过make(chan 元素类型, 缓冲大小)定义。缓冲区相当于一个队列。

1.4 并发安全Lock

使用定义变量的锁

var (
    x    int64
    lock sync.Mutex
)

定义一个带锁的变量x,使用lock.Lock()上锁,使用lock.Unlock()解锁。

1.5 WaitGroup

用一个变量记录还在运行的子协程数量,用Wait等待这个变量减小到0。

var wg sync.WaitGroup//定义变量
sg.Add(delta)//变量值增加delta
wg.Done()//变量值减1,一般写在协程中
wg.Wait()//等待变量值降为0

2.依赖管理

go的依赖管理分为3个阶段:GOPATH >> Go Vendor >> Go Module(广泛应用)。主要解决依赖库版本不同的问题。

2.1.1 GOPATH

$GOPATH是一个路径(一个工作区),包含文件bin,pkg,src,分别存储项目编译的二进制文件、编译的中间产物(用于加速编译)、项目源码。项目的所有代码都依赖于src中的源码,编译时用go get下载最新版本的包到src目录下。

当所依赖的包升级且程序必须使用以前版本的包时出错。(无法实现package的多版本控制)

2.1.2 Go Vendor

项目目录下增加vendor文件,所有依赖包以副本的形式放在$ProjectRoot/vendor下。依赖寻址时,先寻找vendor再寻找GOPATH。

当程序同时依赖一个包的两个版本且两个版本不兼容时出错。原因在于这种方法只是改变了依赖的寻址顺序,编译器实际并不知道得到的包的版本。

2.1.3 Go Moudule

通过go.mod文件管理依赖包版本;通过go get/go mod指令工具管理依赖包。

2.2 依赖管理三要素

  1. 配置文件,描述依赖(go.mod)
  2. 中心仓库管理依赖库(Proxy)
  3. 本地工具(go get/go mod) 2.3.1 依赖配置-go.mod

go.mod主要由三部分组成: 依赖管理基本单元(module 路径)、原生库(go version)、单元依赖(require(...))。

单元依赖中的每一行包括两部分,模块路径(与依赖管理基本单元对应)和模块的版本号。

2.3.2 依赖配置-version

语义化版本:\${MAJOR}.\${MINOR}.\${PATCH},例如V1.3.0。MAJOR为大版本,大版本不同则可能不兼容;MINOR一般会新增函数或功能,需要做到前后兼容;PATCH一般用于版本bug的修复。

基于commit的伪版本:版本前缀-时间戳-哈希码,版本前缀与语义化版本相同;时间戳即提交时间;提交commit的12位哈希码前缀(校验码)。每次提交时go都会生成一个伪版本号,例如v1.0.0-20220507231840-abcdefgh1234

2.3.3 依赖配置-indirect

require中可能有关键字:

行末加//indirect标识间接依赖。例如程序依赖A,A依赖B,则A无标注,B被标注为间接依赖。

模块路径后加/vN后缀,标识主版本为N(N>=2)的包的路径。

行末加+incompatible标识不兼容。若一个包的主版本MAJOR大于等于2且无go.mod文件,则其可能会与前版本不兼容。

依赖配置时,编译器选择最低的兼容版本(先保证兼容,再寻找最低)。

2.3.4 依赖分发-回源(解决源代码去哪里寻找的问题)

可以直接从仓库中拉取源代码,以完成依赖分发。但由于仓库作者可能增加/修改/删除软件版本(无法保证构建的稳定性),可能删除软件(无法保证依赖的可用性),同时也为了减小仓库的访问压力(软件发行触发大量用户编译增加第三方压力),所以实际中会建立一个稳定可靠的代理服务器来缓存仓库中的内容。

用GOPROXY配置寻址顺序: GOPROXY="ADDR1,ADDR2,direct",ADDR1和ADDR2是两级缓存代理,direct代表源站。编译器将以ADDR1 -> ADDR2 -> direct的顺序寻找源码。

2.3.5 工具-go get

go get xxx \[参数]可以用参数的方式下载xxx包。

参数可以使用:

@update    默认,最新的MAJOR版本
@none      删除依赖
@v1.1.2    下载特定语义版本
@23dfdd5   下载特定提交版本
@master    分支的最新commit

2.3.6工具-go mod

go mod+参数

参数可以使用

init      初始化,创建go.mod文件
download  下载模块到本地缓存
tidy      增加需要的依赖,删除不需要的依赖

3.测试

回归测试:调查用户反馈

集成测试:对功能进行测试,对某个接口进行自动化测试

单元测试:对单极函数进行测试

回归测试>>集成测试>>单元测试:覆盖率逐渐变大,测试成本逐渐变低。因此单元测试使用率最高。被用于保证质量与提高效率。

3.1.1单元测试-规则

  • 测试文件命名规范:所有测试文件以_test.go结尾
  • 测试函数命名规范:func TestXxx(t *testing.T)
  • 执行go test指令时没有也不需要main函数,而是寻找命名严格遵守规范的文件和函数,将他们全部运行。
  • 执行单个测试函数使用go test -run 正则表达式。其中正则表达式只要能匹配函数前缀,则函数将被执行。例如:
//所有测试函数
func TestA()
func TestB()
func TestAb()
//指令
go test             //执行全部测试函数
go test -run        //报错。加入-run则必须再输入一个run的参数
go test -run ""     //执行全部测试函数,因为空字符串是所有字符串的前缀
go test -run Test   //执行全部测试函数,因为所有测试函数的前缀都包含"Test"
go test -run TestA  //执行 TestA() 和 TestAb()
go test -run TestB  //执行 TestB()
go test -run t      //也会执行全部测试函数,原因不明
go test -run te     //不执行任何测试函数,测试通过。说明正则表达式区分大小写

编写测试代码时,将初始化逻辑放到TestMain中可以使代码更加规范:

func TestMain(m *testing.M){
    //测试前:数据装载、配置初始化等前置工作
    code:=m.Run()//进行所有测试工作
    //测试后:释放资源等收尾工作
    os.Exit(code)
}

3.1.2代码覆盖率:评估单元测试

使用go test xxx_test.go xxx.go --cover即可计算代码覆盖率。

计算方法:执行所有测试函数,计算被测试程序中被运行代码占全部代码的百分比(以行数计算)。

例如代码

//被测函数
func Foo() bool {
    if true {//覆盖
        println("line2")//覆盖
        println("line3")//覆盖
        return true//覆盖
    }
    return false//未覆盖
}
//测试代码如下,覆盖率为80%
func TestFoo(t *testing.T) {
    isPass := Foo()
    assert.Equal(t, true, isPass)
}

实际项目中覆盖率很难达到100%,一般达到50%-60%(主流程基本没问题,分支没有考虑完全),对于准确率要求高的程序,我们可能要求覆盖率达到80%以上。

为了得到更高的覆盖率,需要:

  1. 完备的测试分支(分支相互独立,不重不漏全面覆盖)
  2. 测试单元粒度足够小(每个函数只履行单一职责)

3.2.1 单元测试的依赖

测试的单元可能会依赖文件、数据库、缓存等资源,这些资源可能正在被正常使用(其状态随时可能被用户使用或改写)。所以我们需要保证单元测试的稳定性和幂等性。

稳定性:测试单元应该保持独立,能够在任何时间、任何环境运行测试。

幂等性:每一次测试的结果都应该一致。

实现稳定性与幂等性就要用到mock机制。例如对读取文件的函数进行mock,屏蔽程序对文件的依赖。

3.2.2 Mock

monkey是一个开源的mock测试库,可以对method或实例的方法进行mock,网址:github.com/bouk/monkey

执行monkey.Patch(tar,src)即可实现快速打桩。其原理是将tar的地址改为src的地址,当程序需要获取tar时,会去src处获得数据,这样就可以解除程序对tar的依赖。解除代码为monkey.Unpatch(tar)

Patch的作用域在Runtime,因此可用defer + 解除代码在函数结束时取消打桩。

3.3 基准测试

基准测试是指测试一段程序的运行性能及耗费cpu的程度。go语言提供了基准测试的框架。

命令行输入go test -bench=.执行所有基准测试。

基准测试的函数命名规范为func BenchmarkXxx(b *testing.B)

执行结果输出各个基准测试函数在单位时间内的执行次数以及每次执行的用时。

4.项目实践

4.1 需求设计

4.1.1 需求描述:实现论坛的社区话题功能,包括:

  1. 展示话题(标题,文字描述)和回帖列表
  2. 不考虑前端页面的实现,仅实现本地的web服务
  3. 话题和回帖数据用文件存储

4.1.2 需求用例

用户(客户端)发送请求,根据请求返回话题和话题下的全部回帖,因此应该从中抽象出2个实体:话题和回帖。

4.1.3 er图

话题应具有属性:id,title,content,create_time

回帖应具有属性:id,topic_id,content,create_time

每个话题都有0或多条回帖。

2022-05-08 16-29-28 的屏幕截图.png

4.1.4 分层结构

2022-05-08 16-30-14 的屏幕截图.png

整体分为三层,repository数据层,serice逻辑层,controller视图层。之后的代码开发将逐层实现。

4.2 代码开发

gin是高性能的go web框架,地址:github.com/gin-gonic/g…

4.2.1 数据层

数据层用于从存储文件中提取数据并传给逻辑层。对于逻辑层的需求,数据层要快速的找到其对应的数据。

我们可以使用全扫描遍历的方式找到对应数据,但这需要O(n)的时间复杂度。我们可以用哈希表的方式为数据建立索引,这样我们就可以在O(1)的时间内找到数据。

代码中建立了2个哈希表,一个存话题id到话题地址的映射,一个存话题id到回帖地址列表的映射。

4.2.2 逻辑层

逻辑层需要做得事情是:参数检验>>准备数据>>组装实体

参数检验:逻辑层获得的参数是一个整数,代表话题id。因此检验id是一个大于0的数。

准备数据:创建2个协程,一个去读取话题地址,一个去读取全部回帖的地址。

组装实体:将之前的全部地址传入返回信息中。

4.2.3 视图层

视图层查看之前是否报错,若报错,返回-1和错误信息,若正确,返回0和"success"和所查数据。

4.3 测试运行

执行go run server.go,新建终端输入curl --location --request GET 'http://0.0.0.0:8080/community/page/get/1'即可正常运行。

课后作业

  • 支持发布回帖。
  • 本地id生成需要保证不重复、唯一性。
  • Append文件,更新索引,注意map的并发安全问题。

数据层我们需要改变db_init.go中的变量。

2022-05-10 20-00-30 的屏幕截图.png

其中7、13行为新增,添加全局读写锁,只有获得锁才能更改数据。

接下来为post.go文件加入"插入函数"。

2022-05-10 21-25-17 的屏幕截图.png

第35行文件打开函数中白色参数的含义分别是:写文件的方式为追加而非覆盖、权限为只写、若文件不存在则创建文件。因为操作系统只允许一个进程对文件进行写操作,所以此时不需要加锁。之后由于要改写的map在内存中,所以需要加锁。

至此,数据层更改完成,接下来是逻辑层部分。我们要增加插入的功能,而目前只有查询功能。因此在service文件夹中新建publish_post.go文件。

2022-05-10 21-28-43 的屏幕截图.png 2022-05-10 21-43-59 的屏幕截图.png

文件中的idworker是id生成器,其支持分布式生成id,其id中包含了毫秒级时间戳、数据中心编码、工作进程编码、序列号(用于为同一毫秒产生的数据标上不同的id)。

函数功能说明:(第一张图是三个普通函数,第二张图是三个PublishPostFlow的方法)

  • init():初始化idworker。
  • Publishost():被视图层cotroller调用的函数。通过调用23行的NewPublishPostFlow函数新建一个PublishPostFlow对象,并调用此对象的Do方法。
  • NewPublishPostFlow():根据输入信息初始化一个PublishPostFlow对象。
  • Do():按序进行其它两个方法。
  • checkParam():检测文本内容不能过长。
  • publish():新建一个post,生成id,调用数据层的函数与方法为数据库插入post。

之后是视图层,同样需要我们添加一个发帖的函数。此时cotroller目录下的文件只有用于实现查询操作的query_page_info.go,于是我们可以新建一个文件(这里将其命名为publish_post.go)用于实现发帖,文件内容可仿照查询文件来写。如下图:

2022-05-10 22-09-14 的屏幕截图.png

最后更改server.go文件。文件中我们可以看到服务器如何对GET数据进行操作,要想支持发帖,我们只需要仿照这段代码进行功能扩展即可。

2022-05-10 22-14-17 的屏幕截图.png

其中第22-28行为新填内容。最后我们在server.go的当前路径下新建server_test.go文件用于检测代码,其内容如下:

2022-05-10 22-17-50 的屏幕截图.png

首先输入go mod tidy更新idworker包的信息,然后使用go run server.go启动服务器。另起一个终端,在当前目录下输入go test -run TestMain -v。可以观察两个终端的输出以及./data/post文件的变化。

2022-05-10 22-25-20 的屏幕截图.png

可以看到很多记录即使创建时间相同但id是不重复的,content内容是相同的是因为所有协程都是在for循环结束后才开始填写数据包造成的,将num的值增大即可增加for的持续时间,使content的值不全相同。