Go 语言从入门到工程实践~
前言:
经过了第一天的学习,基本掌握了 Golang 的基础语法和一些常用的标准库使用。在本次课程中,讲师紧接着前一天的内容,带领大家从工程实践的角度,分享了 Go 语言进阶过程中涉及的几大必备技能:并发编程、依赖管理、软件测试方法 …
在本次的课程结尾,还准备了一个简单的项目实战,从真实的项目案例中抛出一个需求模型,带领我们验到了真实的项目开发流程。
并发编程
如果学过操作系统这门课,提到并发编程很快会想到进程与线程,线程相比于进程更加轻量。然而 Golang 中,又在线程的基础上进一步提出了协程 (Goroutine)的概念,协程被称为“用户态线程”,不存在 CPU 上下文切换的问题,效率非常高。
协程的引入,使得开发者可以很容易的用 Go 写出高并发的程序。
1. 协程实例 - Hello Goroutine:
Go 语言中开启一个协程非常简单,只需要在调用的函数前面加上 go
关键字,就可以让 Go 为该函数创建一个协程来运行。
下面是一个协程创建的实例:
package main
import (
"fmt"
"time"
)
func hello(i int) {
println("hello goroutine : " + fmt.Sprint(i))
}
func main() {
for i := 0; i < 5; i++ {
go func(j int) {
hello(j)
}(i)
}
time.Sleep(time.Second)
}
主函数中开启了 5 个协程用来并发打印 "hello goroutine : i"
,按照预期会输出五条乱序的语句。
hello goroutine : 4
hello goroutine : 0
hello goroutine : 1
hello goroutine : 2
hello goroutine : 3
2. 协程间通信 - Channel:
我们都知道线程间的通信可以使用共享内存的方式,但这种方式会给多线程带来一些问题,所以 Go 提倡通过 通信共享内存 而不是通过共享内存而实现通信。Go 使用通道(Channel)来实现协程间的通信,通道是一个可以用于发送类型化数据的管道,由其负责协程之间的通信,从而避开所有由共享内存导致的陷阱。
使用 make(chan 元素类型, [缓冲大小])
可以创建一个通道,通道(Channel)分为两种类型:
- 无缓冲通道 ——
make(chan int)
- 有缓冲通道 ——
make(chan int, 2)
无缓冲的通道保证同时交换数据,而有缓冲的通道不做这种保证。
下面通过一个例子来看一下 Channel 如何使用,在该例子中,A 子协程发送 0~9 数字,B 子协程计算输入数字的平方,主协程输出最后的平方数。
package main
import "fmt"
func main() {
src := make(chan int)
dest := make(chan int, 3)
// A子协程
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 {
fmt.Print(i, " ");
}
}
该程序的运行结果如下:
0 1 4 9 16 25 36 49 64 81
上面的例子可以抽象成生产者与消费者问题,从结果中可以看出通过 Channel 这种方式,是能保证协程间的运行顺序的,也就是并发安全的。
3. 并发安全 Lock:
Go 语言保留了通过共享内存来实现协程通信的机制,在这种方式下可能存在多个 Goroutine 同时操作一块内存资源的情况,也就是会发生数据竞态。
要确保并发安全,需要对临界资源进行加锁,Golang 中使用 lock.Lock()
和 lock.Unlock()
来对临界区加锁和释放。
在下面的示例中,会开启 5 个协程来执行变量 x
自增 2000 次的操作,分别在加锁与不加锁的情况下,来看一下程序的运行结果:
package main
import (
"sync"
"time"
)
var (
x int64
lock sync.Mutex
)
// 对变量x的访问加锁
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 main() {
x = 0
for i := 0; i < 5; i++ {
go addWithoutLock()
}
time.Sleep(time.Second)
println("WithoutLock: ", x) // 预期结果:10000
x = 0
for i := 0; i < 5; i++ {
go addWithLock()
}
time.Sleep(time.Second)
println("WithLock: ", x) // 预期结果:10000
}
运行结果:
WithoutLock: 6731
WithLock: 10000
可以发现多个协程访问同一个资源可能得不到期望结果,因此要使用互斥锁来避免非并发安全的读写操作。
4. 语言等待组 WaitGroup:
Go 语言中除了可以使用通道(channel)和互斥锁进行两个并发程序间的同步外,还可以使用等待组进行多个任务的同步,等待组可以保证在并发环境中完成指定数量的任务。
WaitGroup 有以下几个方法:
方法名 | 功能 |
---|---|
(wg * WaitGroup) Add(delta int) | 等待组的计数器 +1 |
(wg * WaitGroup) Done() | 等待组的计数器 -1 |
(wg * WaitGroup) Wait() | 当等待组计数器不等于 0 时阻塞直到变 0 |
等待组内部拥有一个计数器,计数器的值可以通过方法调用实现计数器的增加和减少。当添加了 n n n 个并发任务进行工作时,就将等待组的计数器值增加 n n n,每个任务完成时,这个值减 1。同时,在另外一个 goroutine 中等待这个等待组的计数器值为 0 时,表示所有任务已经完成。
我们可以使用 WaitGroup 来改写上面的协程实例 - Hello Goroutine:
package main
import (
"fmt"
"sync"
)
func hello(i int) {
println("hello goroutine : " + fmt.Sprint(i))
}
func main() {
var wg sync.WaitGroup
wg.Add(5)
for i := 0; i < 5; i++ {
go func(j int) {
defer wg.Done()
hello(j)
}(i)
}
wg.Wait()
}
依赖管理
项目开发中经常需要引入第三方依赖,不同环境依赖的版本可能不同,为了解决这一问题,诞生了很多依赖管理工具 GOPATH、Go Vender、Go Module,这些工具让我们可以方便地控制依赖库地版本。
1. GOPATH:
GOPATH 是 Go 语言支持的环境变量,是项目的工作区,主要有三个主要的目录:
src 目录是存放项目源代码的地方,GOPATH 会将所有的依赖都放在 src 目录下,通过 go get
指令来下载最新版本的包到 src 目录下。
由于 GOPATH 不支持依赖的多版本控制,多个项目有相同的依赖时可能会产生问题,因此后面又产生了 Go Vendor。
2. Go Vendor:
通过每个项目引入一份依赖的副本,解决了多个项目需要同一个 package 依赖的冲突问题。
Go Vendor 仍然存在以下问题:
- 无法控制依赖的版本。
- 更新项目又可能出现依赖冲突,导致编译出错。
3. Go Module:
Go Module 是 Go 语言官方推出的依赖管理系统,解决了之前工具的诸多问题。目前最新版本的 Golang 已经默认开启了 Go Module 的管理能力。
- 通过
go.mod
文件管理依赖包版本。 - 通过
go get/go mod
指令工具管理依赖包。
🚀官方文档传送门:Modules · golang/go Wiki (github.com)
依赖配置 - go.mod:
类似于 Java 语言的依赖管理工具 Maven,Go Module 使用 go.mod 文件来管理项目的依赖包信息,格式如下:
module github.com/gosoon/audit-webhook
go 1.12
require (
github.com/elastic/go-elasticsearch v0.0.0
github.com/gorilla/mux v1.7.2
github.com/gosoon/glog v0.0.0-20180521124921-a5fbfb162a81
)
常用命令:
GO MODULE 常用命令 | 描述 |
---|---|
go mod init | 初始化 go.mod |
go mod tidy | 更新依赖文件 |
go mod download | 下载依赖文件 |
go mod vendor | 将依赖转移至本地的 vendor 文件 |
go mod edit | 手动修改依赖文件 |
go mod graph | 打印依赖图 |
go mod verify | 校验依赖 |
Go Module 使用流程:
- 初始化项目:进入项目目录,使用
init
指令后会生成一个go.mod
文件,它的内容将会被 go toolchain 全面掌控,go toolchain 会在各类命令执行时,比如go get
、go build
、go mod
等修改和维护go.mod
文件。mkdir Project cd Project go mod init Project
- 添加依赖:在
main.go
文件的import()
代码块中添加第三方依赖,执行go run main.go
运行代码会发现 go mod 会自动查找依赖自动并下载,同时go.mod
文件也会随着变化。package main import ( "github.com/gin-gonic/gin" ) func main() { // ... }
- 使用
go get
指令升级依赖:运行go get -u
将会升级到最新的次要版本或者修订版本;运行go get -u=patch
将会升级到最新的修订版本;运行go get package@version
将会升级到指定的版本号 version。
软件测试
软件测试关系着系统的质量,测试是避免事故的最后屏障,通过软件测试可以发现程序中的各种问题和错误,规避软件事故造成的资金损失。
测试分为回归测试、集成测试与单元测试,覆盖率逐渐变大但是成本逐渐降低。单元测试主要是开发阶段开发者对某个模块进行测试,单元测试一定程度上决定了代码的质量,本次课程也着重介绍了单元测试。
单元测试:
1. 单元测试 - 规则:
- 所有文件以
_test.go
结尾。 - 测试函数命名:
func TestXxx(*testing.T)
。 - 初始化逻辑放到 TestMain 中。
2. 单元测试 - 运行:
GoLand 中可以右键直接运行单元测试,VS Code 可以在终端中使用 go test [flags] [packages]
指令。
3. 单元测试 - Tips:
- 单元测试要保证较高的覆盖率,80% 以上。
- 测试分支相互独立,全面覆盖。
- 测试单元粒度足够小,函数单一职责。
单元测试 - Mock:
Mock 方法是单元测试中常见的一种技术,它的主要作用是模拟一些在应用中不容易构造或者比较复杂的对象,从而把测试与测试边界以外的对象隔离开。
使用 Mock 做接口测试时,一般分二步:
- 打桩:创建 Mock 桩,指定 API 请求内容及其映射的响应内容。
- 调桩:被测服务来请求 Mock 桩并接收 Mock 响应。
🚀Go 常用开源 Mock 包:bouk/monkey: Monkey patching in Go (github.com)
基准测试:
Go 语言提供了基准测试的组件,基准测试是指测试程序运行时的性能,比如 CPU 的资源开销等。
testing
包中内置了基准测试的功能,在使用 go test
命令时,默认会将基准测试排除,只运行单元测试,所以需要在 go test
命令后加上 -bench
以执行基准测试。-bench
标记使用一个正则表达式来匹配要运行的基准测试函数名称。所以,最常用的方式就是通过 -bench=.
标记来执行该包下的所有的基准函数。
% go test -bench=. ./examples/test/
项目实战 - 组件及技术点
在课程结尾讲师通过 “社区话题页面” 案例展示了开发过程中涉及到的技术点以及一些常用组件,在此做一个总结。
项目的分层结构:
在实际的项目开发中,通常采用分层开发的方式,上图的分层模型就是我们常说的 MVC 模型,不同层实现不同的功能,实现了项目的分层解耦,有利于开发团队的分工合作。
分层结构主要分为三个部分:
- 数据层:外部数据的增删改查(CRUD)。
- 逻辑层:处理核心业务逻辑输出。
- 视图层:处理和外部的交互逻辑。
组件工具:
1. Gin 框架:
Gin 是一个高性能的开源 go web 框架,用来开发后端程序。
🚀传送门:Gin Web Framework (gin-gonic.com)
2. Go Mod:
通过依赖管理来引入 Gin 框架:
go mod init
go get gopkg.in/gin-gonic/gin.v1@v1.3.0