1.语言进阶
并发 vs. 并行
并发:多线程程序在一个核的CPU上运行,通过线程切换实现同时运行的一个状态
并行:多线程程序在多个核的CPU上运行,利用多核实现多线程同时运行
1.1 Goroutine
Goroutine是协程
协程与线程
协程:用户态,轻量级线程,栈KB级别 线程:内核态,一个线程上可以运行多个协程,栈MB级别
使用Goroutine
快速打印hello goroutine: 0 到hello goroutine: 4
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)//防止主线程先结束
}
1.2 CSP(Communication Sequential Processes)
Go提倡通过通信共享内存而不是通过共享内存通信
1.3 Channel
语法
语法:make(chan 元素类型, [缓冲大小])
- 无缓冲通道:
make(chan int) - 有缓冲通道:
make(chan int,2) //2指容量为2
有缓冲 vs. 无缓冲
示例
A 子协程发送0-9数字 B 子协程计算输入数字的平方 主协程输出最后的平方数
func CalSquare() {
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(src)
for i := 0; i < 10; i++ {
src <- i
}
}()
//主协程
for i := range dest {
//复杂操作
println(i)
}
}
1.4 并发安全 Lock
对变量执行2000次+1操作,5个协程并发执行
var (
x int64
lock sync.Mutex
)
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("WithoutLock", x)
x = 0
for i := 0; i < 5; i++ {
go addWithLock()
}
time.Sleep(time.Second)
println("WithLock", x)
}
输出结果
WithoutLock: 8382
WithLock: 10000
无锁状态下,不能确定最终结果
1.5 WaitGroup
计数器
开启协程+1;执行结束-1;主协程阻塞直到计数器为0
示例
快速打印hello goroutine: 0 到hello goroutine: 4
func hello(i int) {
println("hello goroutine: " + fmt.Sprint(i))
}
func ManyGoWait() {
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()
}
2.依赖管理
背景
- 工程项目不可能基于标准库从0到1编码搭建
- 需要管理依赖库
2.1 Go依赖管理演进
GOPATH -> Go Vendor -> Go Module
主要解决的问题:
- 不同环境(项目)依赖的版本不同
- 控制依赖库的版本
2.1.1 GOPATH
介绍
GOPATH是Go语言的一个环境变量,value是Go项目的工作区
目录有以下结构
- src:存放项目源码
- pkg:存放编译的中间产物,加快编译速度
- bin:存放Go项目编译生成的二进制文件
依赖管理方式
- 项目代码直接依赖src下的代码
- go get下载最新版本的包到src目录下
弊端
无法实现package的多版本控制
场景:
A和B依赖于某一package的不同版本,但是在GOPATH管理模式下,多项目依赖同一个库,该依赖库是同一份代码,所以不同项目不能依赖同一个库的不同版本
2.1.2 Go Vendor
介绍
- 项目目录下增加vendor文件,所有依赖包副本形式放在 $ProjectRoot/vender
- 依赖寻址方式:vendor => GOPATH
机制
在Vendor机制下,如果当前项目存在Vendor目录,会优先使用该目录下的依赖如果依赖不存在,会从GOPATH中寻找。 通过每个项目引入一份依赖的副本,解决了多个项目需要同一个package依赖的冲突问题
弊端
- 无法控制依赖的版本
- 更新项目又可能出现依赖冲突,导致编译出错
场景:
项目A依赖pkgB和C,而B和C又依赖了pkgD的不同版本,通过vendor的管理模式,我们不能很好地控制对D的依赖版本,一旦更新版本,可能会依赖冲突或编译出错
2.1.3 Go Module
介绍
Go Module是Go语言官方推出的依赖管理系统,解决了之前依赖管理系统存在的诸多问题。 从Go 1.11开始实验性引入,Go 1.16默认开启,一般读作go mod
- 通过go.mod文件管理依赖包版本
- 通过go get/go mod指令工具管理依赖包
- 终极目标:定义版本规则和管理项目依赖关系
2.2 依赖管理三要素
- 配置文件,描述依赖 -- go.mod
- 中心仓库管理依赖库 -- Proxy
- 本地工具 -- go get/mod
2.2.1 配置依赖--go.mod
下方是一个go.mod文件
module example/project/app
go 1.16
require (
example/lib1 v1.0.2
example/lib2 v1.0.0 // indirect
example/lib3 v0.1.0-20190725025543-5a5fe074e621
example/lib4 v0.0.0-20180306012644-bacd9c7ef1dd // indirect
example/lib5/v3 v3.0.2
example/lib6 v3.2.0+incompatible
)
- 第一部分(module example/project/app)
- 是一个模块路径,用来标识一个模块,通过路径找到该模块
- 如果是github前缀表示可以从Github上找到该模块,依赖包的源代码由github托管
- 如果项目的子包想被单独引用,需要通过单独的init go.mod文件进行管理
- 第二部分(go 1.16)
- 依赖的原生sdk版本
- 第三部分(require)
- 是单元依赖
- 依赖标识:[Module Path] [Version/Pseudo-version]
2.2.2 依赖配置-version
GoModule为了方便管理定义了版本规则,分为:语义化版本和基于commit的伪版本
- 语义化版本
- 主要包括三个部分:
$[MAJOR}.${MINOR}.${PATCH} - 例如:v1.3.0,v2.3.0
- MAJOR:大版本,不同的Major是可以不兼容的
- MINOR:新增函数或功能,向后兼容
- PATCH:一般是修复Bug
- 主要包括三个部分:
- 基于commit的伪版本
vX.0.0-yyyymmddhhmmss-abdcefgh1234- 例如:v1.0.1-20201130134442-10cb98267c6c
- 基础版本前缀:和语义化版本一样
- 时间戳:提交的时间
- 校验码:包含12位的哈希前缀
- 每次commit后Go都会默认生成一个伪版本号
2.3.3 依赖配置--特殊标识符
indirect
indirect后缀:表示go.mod对应的当前模块没有直接导入该依赖模块的包,也就是非直接依赖,表示间接依赖
例如:A->B->C,则有
- A->B直接依赖
- A->C间接依赖
incompatible
- 主版本2+模块会在模块路径增加/vN后缀
- 这能让go module按照不同的模块来处理同一个主版本的依赖
- 对于没有go.mod文件并且主版本2+的依赖,会+incompatible
- 由于go module是在1.11版实验性引入的,在此之前已有一些仓库打上了2或更高版本的tag,为了兼容这部分仓库,会加上此后缀
2.2.4 依赖配置--依赖图
项目X依赖了A、B两个项目,且A、B分别依赖了C项目的v1.3、v1.4两个版本,最终编译时所使用的C项目是哪个版本呢? v1.4 选择最低的兼容版本
2.2.5 依赖分发
依赖分发,也就是从哪里下载,如何下载的问题
回溯
Go Module系统中定义的依赖,最终可以对应到多版本代码管理系统中某一项目的特定提交或版本,这样的话,对于go.mod中定义的依赖,可以直接从对应仓库中下载指定软件依赖,从而完成依赖分发
直接使用版本管理仓库下载以来存在一些问题
- 无法保证构建稳定性
- 软件作者可以直接通过代码平台增加/修改/删除软件版本
- 无法保证依赖可重用性
- 软件作者可能会删除软件,导致依赖不可用
- 增加第三方压力
- 代码托管平台负载问题
Proxy
Go Proxy就是解决上述问题的方案
Go Proxy是一个服务站点,它会从缓存源站中的软件内容,缓存的软件版本不会改变,并且在源站软件删除之后依然可用,从而实现了供"immutablity"和"available"的依赖分发
使用Go Proxy之后,构建时会直接从Go Proxy站点拉取依赖
2.2.6 Go Proxy使用
示例:
GOPROXY="https://proxy1.cn, https://proxy2.cn direct"
- Go Module通过GOPROXY环境变量控制如何使用Go Proxy;
- GOPROXY是一个Go Proxy站点URL列表,可以使用"direct"表示源站。
- 对于示例配置,整体的依赖寻址路径,会优先从proxy1下载依赖,如果proxy1不存在,再到proxy2,再不存在则会回溯到源站直接下载依赖,缓存到proxy站点中
2.2.7 工具--go get
go get example.org/pkg
后缀:
@update默认none删除依赖v1.1.2tag版本,语义版本@23dfdd5特定的commit@master分支的最新commit
2.2.8--go mod
go mod
后缀:
init初始化,创建go.mod文件download下载模块到本地缓存tidy增加需要的依赖,删除不需要的依赖
注意事项:尽量提交之前执行下go tidy,减少构建时无效依赖包的拉取
测试
- 回归测试:QA同学手动通过终端回归一些固定的主流程场景
- 集成测试:对系统功能最测试验证
- 单元测试:开发阶段,开发者对单独的函数、模块做功能验证,一定层度上决定着代码的质量 从上到下,覆盖率逐层变大,成本逐层降低
3.1 单元测试
主要包括:输入、测试单元、输出以及校对
- 单元:概念比较广泛,包括接口、函数、模块等
- 校对:用来确保代码功能和我们预期相符
- 单测的作用:
- 一方面保证质量,在整体覆盖率足够的情况下,一定程度上既保证了新功能本身的正确性,又未破坏原有代码的正确性
- 另一方面可以提上效率,在代码有bug的情况下,通过编写单测,可以在一个较短周期内定位和修复问题
3.1.1 单元测试-规则
- 所有测试文件以_test.go结尾
func TestXxx(*testing.T)- 初始化逻辑放到TestMain中
func TestPublishPost(t *testing.T) {
//测试代码
}
func TestMain(m *testing.M) {
//测试前:数据装载、配置初始化等前置工作
code := m.Run()
//测试后:释放资源等收尾工作
os.Exit(code)
}
3.1.2 单元测试-例子
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)
}
}
3.1.3 单元测试-运行
go test [flags] [packages]
3.14 单元测试-assert
import (
"github.com/stretchr/testify/assert" //一个开源的测试包
"testing"
)
func TestHelloTom(t *testing.T) {
output := HelloTom()
expectOutput := "Tom"
assert.Equal(t, expectOutput, output)
}
3.1.5 单元测试-覆盖率
概念
代码覆盖率是对
- 如何衡量代码是否经过了足够的测试
- 如何评价项目的测试水准
- 如何评估项目是否达到了高水准测试等级 用代码覆盖率
示例
对函数
func JudgePassLine(score int16) bool {
if score >= 60 {
return true
}
return false
}
其单元测试
func TestJudgePassLineTrue(t *testing.T) {
isPass := JudgePassLine(70)
assert.Equal(t, true, isPass)
}
执行
>go test judgment_test.go judgment.go --cover
command-line-arguments 1.296s coverage: 66.7% of statements
使用--cover就可以计算覆盖率,以上例来看,三行代码共执行两行,故覆盖率为66.7%
Tips
- 一般覆盖率:50%-60%,较高覆盖率80%+
- 测试分支相互独立、全面覆盖
- 测试单元粒度足够小,函数单一职责
3.2 单元测试-依赖
对于工程中复杂的项目,一般会依赖文件、数据库、缓存等
所以单测需要保证稳定性和幂等性
- 稳定是指相互隔离,能在任何时间、任何环境运行测试
- 幂等是指每一次测试运行都应该产生与之前一样的结果。
要实现这一目的就要用到mock机制
3.3 单元测试-文件处理
func ReadFirstLine() string {
open, err = os.Open("log")
defer open.Close()
if err != nil {
return ""
}
scanner := bufio.NewScanner(open)
for scanner.Scan() {
return scanner.Text()
}
return ""
}
func ProcessFirstLine() string {
line := ReadFirstLine()
destLine := strings.ReplaceAll(line, "11", "00")
return destLine
}
func TestProcessFirstLine(t *testing.T) {
firstLine := ProcessFirstLine()
assert.Equal(t, "line00", firstLine)
}
举例说明,如果单测需要依赖本地的文件,如果文件被修改或者删除就会fail,为了保证测试case的稳定性,我们对读取文件函数进行mock,屏蔽对于文件的依赖
3.4 单元测试-Mock
bouk/monkey: Monkey patching in Go Monkey是一个开源的mock测试库,可以对method,或者实例的方法进行mock,反射,指针赋值 Monkey Patch的作用域在Runtime,在运行时通过Go的unsafe包,能够将内存中函数的地址替换为运行时函数的地址,将待打桩函数或方法的实现跳转到
//Patch replaces a function with another
func Patch(target, replacement interface{}) *PatchGuard {
t := reflect.ValueOf(target)
r := reflect.ValueOf(replacement)
patchValue(t, r)
return &PatchGuard{t, r}
}
//Unpatch removes any monkey patches on target
//returns whether target was patched in the first place
func Unpatch(target interface{}) bool {
return unpatchValue(reflect.ValueOf(target))
}
对ReadFirstLine打桩测试,不再依赖本地文件
func TestProcessFirstLineWithMock(t *testing.T) {
monkey.Patch(ReadFirstLine, func() string {
return "line110"
})
defer monkey.Unpatch(ReadFirstLine)
line := ProcessFirstLine()
assert.Equal(t, "line000", line)
}
3.5 基准测试
- 基准测试是指测试一段程序的运行性能以及耗费CPU的程度。
- Go提供了基准测试框架,提供了基准测试的能力
- 场景:在开发中经常会遇到代码性能瓶颈,为了定位问题经常要对代码做性能分析,需要用到基准测试
- 使用:类似单元测试
3.5.1 基准测试-例子
举例:服务器负载均衡问题 首先,我们有10个服务器列表,每次通过select函数随机选择一个用来执行
import (
"math/rand"
)
var ServerIndex [10]int
func InitServerIndex() {
for i := 0; i < 10; i++ {
ServerIndex[i] = i + 100
}
}
func Select() int {
return ServerIndex[rand.Intn(10)]
}
3.5.2 基准测试-运行
func BenchmarkSelect(b *testing.B) {
InitServerIndex()
b.ResetTimer()
for i := 0; i < b.N; i++ {
Select()
}
}
func BenchmarkSelectParaller(b *testing.B) {
InitServerIndex()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB){
for pb.Next() {
Select()
}
})
}
BenchmarkSelect-12 61563442 18.77 ns/op
BenchmarkSelectParallel-12 19034819 79.42 ns/op
基准测试以Benchmark开头,入参是testing.B, 用b中的N值反复递增循环测试 (对一个测试用例的默认测试时间是 1 秒,当测试用例函数返回时还不到 1 秒,那么 testing.B 中的 N 值将按 1、2、5、10、20、50……递增,并以递增后的值重新进行用例函数测试。) Resttimer重置计时器,我们再reset之前做了init或其他的准备操作,这些操作不应该作为基准测试的范围;runparallel是多协程并发测试;执行2个基准测试,发现代码在并发情况下存在劣化,主要原因是rand为了保证全局的随机性和并发安全,持有了一把全局锁。
3.5.3 基准测试-优化
bytedance/gopkg: Universal Utilities for Go 为解决问题,字节开源了一个高性能随机数方法fastrand,提高了性能
func FastSelect() int {
return ServerIndex[fastrand.Intn(10)]
}
4 项目实战
4.1 需求描述
社区话题页面
- 展示话题(标题,文字描述)和回帖列表
- 暂不考虑前端页面实现,仅仅实现一个本地web服务
- 话题和回帖数据用文件存储
4.2 需求用例
从图中可以抽出两个实体
实体的属性?实体间的关系?
4.3 ER图-Entity Relationship Diagram
4.4 分层结构
- 数据层:数据Model,外部数据的增删改查
- 逻辑层:业务Entity,处理核心业务逻辑输出
- 视图层:视图view,处理和外部的交互逻辑
4.5 组件工具
-
Go Mod
- go mod init
- go get -u github.com/gin-gonic/gin
4.6 Repository
根据er图可以定义出Topic以及Post
Topic
{
"id": 1,
"title": "青训营来啦!",
"content": "小姐姐,快到碗里来~",
"create_time": 1650437625
}
Post
{
"id": 1,
"parent_id": 1,
"content": "小姐姐快来1",
"create_time": 1650437616
}
如何实现查询呢
index
全盘扫描遍历方式可以实现,但是效率不高,故引出索引整个概念,可以引导我们快速查找、定位我们需要的结果
这里我们用map实现内存索引,在服务对外暴露前,利用文件元数据初始化全局内存索引,这样可以实现O(1)的时间复杂度查找操作
index具体实现
func initTopicIndexMap(filePath string) error {
open, err = os.Open(file + "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
}
首先打开文件,基于file初始化scanner,通过迭代器方式遍历数据行,转化为结构体存储至内存map
查询
有了内存索引,下一步就是实现查询操作。
- 直接根据查询key获得map中的value就好了
- sync.once:主要适用高并发的场景下只执行一次的场景
- 这里的基于once的实现模式为单例模式,减少了存储浪费
type Topic struct {
Id int64 `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
CreateTime int64 `json:"create_time"`
}
type TopicDao struct {
}
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]
}
4.7 Service
定义实体
Service层实体:
type PageInfo struct {
Topic *repository.Topic
PostList []*repository.Post
}
流程
参数校验->准备数据->组装实体
代码流程编排
func (f *QueryPageInfoFlow) Do() (*PageInfo, error) {
if err := f.checkParam(); err != nul {
return nil, err
}
if err := f.prepareInfo(); err != nul {
return nil, err
}
if err := f.pagePageInfo(); err != nul {
return nil, err
}
return f.pagaInfo, nil
}
checkParam
检查参数,这里略
prepareInfo
关于prepareInfo方法,话题和回帖信息的获取都依赖topic Id,这样两者就可以并行执行,提高效率
func (f *QueryPageInfoFlow) prepareInfo() error {
//获取topic信息
var wg sync.WaitGroup
wg.Add(2)
go func() {...}()
//获取post列表
go func() {...}()
wg.Wait()
return nil
}
4.8 Controller
定义一个view对象,通过code msg打包业务状态信息,用data承载业务实体信息
type PageData struct {
Code int64 `json:"code"`
Msg string `json:"msg"`
Data interface{} `json:"data"`
}
func QueryPageInfo(topicIdStr string) *PageData {
topicId, err := strconv.ParseInt(topicIdStr, 10, 64)
if err != nil {
return &PageData{...}
}
pageInfo, err = service.QueryPageInfo(topicId)
if err != nil {
return &PageData{...}
}
return &PageData{...}
}
4.9 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 := controller.QueryPageInfo(topicId)
c.JSON(200, data)
})
err := r.Run()
if err != nil {
return
}
}