代码链接:https://github.com/Moonlight-Zhao/go-project-example/tree/V0
Go语言上手-工程实践
1.语言进阶
从并发编程的视角带大家了解Go高性能的本质
1.1 并发 VS 并行
并发:多线程程序在一个核的cpu上运行
并行:多线程程序在多个核的cpu上运行
Go可以充分发挥多核优势,高效运行
1.2 协程【Goroutine】
协程:用户态,轻量级线程,栈MB级别
线程:内核态,线程跑多个协程,栈KB级别
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)
}
func main() {
HelloGoRoutine()
}
运行结果:
1.3 【CSP(Communication Sequential Processes)】
提倡通过通信共享内存而不是通过共享内存而实现通信
1.4 Channel
make(chan 元素类型,[缓冲大小])
- 无缓冲通道[同步通道] make(chan int)
- 有缓冲通道 make(chan int,2)
A 子协程发送0~9数字
B 子协程计算输入数字的平方
M 主协程输出最后的平方数
package main
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(dest)
for i := range src{
dest <- i * i
}
}()
//M
for i := range dest{
//复杂操作
println(i)
}
}
func main() {
CalSquare()
}
运行结果:
1.5 并发安全 Lock
package main
import (
"sync"
"time"
)
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)
}
func main() {
Add()
}
运行结果:
1.6 WaitGroup
计数器:开启协程+1;执行结束-1;主协程阻塞直到计数器为0
package main
import (
"fmt"
"sync"
)
func hello_new(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_new(j)
}(i)
}
wg.Wait()
}
func main() {
ManyGoWait()
}
运行结果:
2.依赖管理
了解Go语言依赖管理的演进路线
2.1 背景
- 工程项目不可能基于标准库0~1编码搭建
- 管理依赖库
2.2 Go依赖管理演进
GOPATH->GO Vendor -> Go Module
- 不同环境(项目)依赖的版本不同
- 控制依赖库的版本
2.2.1 GOPATH
-
环境变量 $GOPATH
-
项目代码直接依赖src下的代码
-
go get下载最新版本的包到src目录下
2.2.1 GOPATH-弊端
场景:A和B依赖于某一package的不同版本
问题:无法实现package的多版本控制
2.2.2 Go Vender
- 项目目录下增加vendor文件,所有依赖包副本形式放在$ProjectRoot/vendor
- 依赖寻址方式:vendor => GOPATH
- 通过每个项目引入一份依赖的副本,解决了多个项目需要同一个package依赖的冲突问题。
2.2.2 Go Vender-弊端
- 无法控制依赖的版本
- 更新项目又可能出现依赖冲突,导致编译错误
2.2.3 Go Module
- 通过go.mod文件管理依赖包版本
- 通过go get/go mod指令工具管理依赖包
终极目标:定义版本规则和管理项目依赖关系
2.3 依赖管理三要素
- 配置文件,描述依赖 go.mod
- 中心仓库管理依赖库 Proxy
- 本地工具 go get/mod
2.3.1 依赖配置-go.mod
依赖标识:[Module Path] [Version/Pseudo-version],
2.3.2 依赖配置-version
- 语义化版本
$ {MAJOR}. $ {MINOR}.$[PATCH}
V1.3.0
V2.3.0 - 基于commit伪版本
vX.0.0-yyyymmddhhmmss-abcdefgh1234
v0.0.0-20220401081311-c38fb59326b7
v1.0.0-20201130134442-10cb98267c6c
2.3.3 依赖配置-indirect
A->B->C
A->B直接依赖
A->C间接依赖
2.3.4 依赖配置-incompatible
- 主版本2+模块会在模块路径增加/vN后缀。
- 对于没有go.mod 文件并且主版本2+的依赖,会+incompatible
2.3.5 依赖配置-依赖图
如果X项目依赖了A、B两个项目,且A、B分别依赖了C项目的v1.3、v1.4两个版本,最终编译时所使用的C项目的版本为如下哪个选项?(单逃)
A. v1.3
B. v1.4
C.A用到c时用v1.3编译,B用到c时用v1.4编译
答:B
解析:选择最低的兼容版本
2.3.6 依赖分发-回源
- 无法保证构建稳定性
增加/修改/删除软件版本 - 无法保证依赖可用性
删除软件 - 增加第三方压力
代码托管平台负载问题
2.3.5 依赖分发-Proxy
Go Proxy是一个服务站点,它会缓源站中的软件内容,缓存的软件版本不会改变,并且在源站软件删除之后依然可用,从而实现了供“immutability”和"available"的依赖分发。使用Go Proxy之后,构建时会直接从Go Proxy站点拉取依赖
2.3.6 依赖分发-变量 GOPROXY
GOPROXY="https://proxy1.cn, https://proxy2.cn ,direct”
服务站点URL列表,“direct”表示源站
对于实例配置,整体的依赖寻址路径,会优先从proxy1下载依赖,如果proxy1不存在,后下钻proxy2寻找,如果proxy2中不存在,则会回源到源站直接下载依赖,缓存到proxy站点中
2.3.7 工具- go get
go get example.org/pkg
- @update 默认
- @one 删除依赖
- @v1.1.2 tag版本,语义版本
- @23dfdd5 特定的commit
- @master 分支的最新的commit
2.3.8 工具- go mod
go mod
- init 初始化,创建go.mod文件
- download 下载模块到本地缓存
- tidy 增加需要的依赖,删除不需要的依赖
3.测试
从单元测试实践出发,提升大家的质量意识
测试一般分为:
回归测试:同学手动通过终端回归一些固定的主流程场景
集成测试:对系统功能维度做测试验证
单元测试:测试开发阶段,开发者对单独的函数,模块做功能验证
层级从上至下,测试成本逐渐减低,而测试覆盖率确逐步上升,所以单元测试的覆盖率一定程度上决定这代码的质量
从上到下,覆盖率逐层变大,成本却逐层降低
3.1 单元测试
3.1.1 规则
-
所有测试文件以_test.go结尾
-
func TestXxx(*testing.T)
-
初始化逻辑放到TestMain中
3.1.2 例子
testing.T 是普通测试包
package main
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)
}
}
运行结果:
备注:
记住将文件名改为以_test.go结尾
导入开源的包改变比较的函数:
先下载git
然后cmd 中安装:go get github.com/bmizerany/assert
package newTom
import (
"github.com/bmizerany/assert"
"testing"
)
func HelloTom() string {
return "Jerry"
}
func TestHelloTom(t *testing.T) {
output := HelloTom()
expectOutput := "Tom"
assert.Equal(t,expectOutput,output)
}
运行结果:
3.1.3 覆盖率
- 衡量代码是否经过了足够的测试
- 评价项目的测试水准
- 评估项目是否达到了高水准测试等级
- -cover 开启测试覆盖率
coverage.go
package coverage
func JudgePassLine(score int16) bool {
if score >= 60{
return true
}
return false
}
coverage_test.go
package coverage
import (
"github.com/bmizerany/assert"
"testing"
)
func TestJudgePassLineTrue(t *testing.T) {
isPass := JudgePassLine(70)
assert.Equal(t,true,isPass)
}
运行结果:
改变coverage_test.go代码,增加一个不及格的测试case
package coverage
import (
"github.com/bmizerany/assert"
"testing"
)
func TestJudgePassLineTrue(t *testing.T) {
isPass := JudgePassLine(70)
assert.Equal(t,true,isPass)
}
func TestJudgePassLineFalse(t *testing.T) {
isPass := JudgePassLine(50)
assert.Equal(t,true,isPass)
}
运行结果:
- 一般覆盖率:50%~60%,较高的覆盖率80%+
- 测试分支相互独立,全面覆盖
- 测试单元粒度足够小,函数单一职责
3.2 依赖
工程中复杂的项目一般会有外部依赖,而在单元测试中需要保证稳定性和幂等性
- 稳定:指相互隔离,能在任何时间,任何环境,运行测试
- 幂等:指每一次测试运行都应该产生与之前一样的结果
- 要实现这一目的就要用到mock机制
3.3 文件处理
例子:将文件中的第一行字符串中的11替换成00,执行单元测试
file.go:
package file
import (
"bufio"
"os"
"strings"
)
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
}
file_test.go:
package file
import (
"github.com/bmizerany/assert"
"testing"
)
func TestProcessFirstLine(t *testing.T){
firstLine := ProcessFirstLine()
assert.Equal(t,"line00",firstLine)
}
log:
line11
line22
line33
line44
line55
运行结果:
单元测试需要依赖本地的文件,如果文件被修改或者删除测试就会fail,为了保证测试case的稳定性,对读取文件函数进行mock,屏蔽对于文件的依赖
3.4 Mock
monkey是一个开源的mock测试库,可以对method,或者实例的方法进行mock,反射,指针赋值
monkey : https://github.com/bouk/monkey
快速Mock函数:
- 为一个函数打桩
- 为一个方法打桩
patch源代码:
// 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源代码:
// 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))
}
将上面测试代码更改:
通过patch对Readfineline进行打桩mock,默认返回line110,这里通过defer卸载mock,这样整个测试函数就摆脱了本地文件的束缚和依赖
package file
import (
"github.com/bmizerany/assert"
"bou.ke/monkey"
"testing"
)
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 基准测试
- 优化代码,需要对当前代码分析
- 内置的测试框架提供了基准测试的能力
3.5.1 例子
举一个服务器负载均衡的例子,首先有10个服务器列表,每次随机执行select函数随机选择一个执行
practice.go:
package main
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 测试运行
practice_test.go:
package main
import "testing"
func BenchmarkSelect(b *testing.B) {
InitServerIndex()
b.ResetTimer()
for i := 0; i < b.N; i++ {
Select()
}
}
func BenchmarkSelectParallel(b *testing.B) {
InitServerIndex()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
Select()
}
})
}
运行结果:
-bench regexp 执行相应的 benchmarks(基准),例如 -bench=.;
-bench=.表示运行 benchmark_test.go 文件里的所有基准测试
- BenchmarkSelect-16:基准测试名称
- 70778086:表示测试的次数,也就是 testing.B 结构中提供给程序使用的 N
- “16.6 ns/op”:表示每一个操作耗费多少时间(纳秒)
3.5.3 优化
字节为了解决随机性能问题,开源了一个高性能随机数方法fastrand,开源地址为:http://github.com/bytedance/gopkg
先导入:go get github.com/bytedance/gopkg
将初试代码改为:
package main
import (
"github.com/bytedance/gopkg/lang/fastrand"
)
var ServerIndex [10]int
func InitServerIndex() {
for i := 0; i < 10; i++ {
ServerIndex[i] = i + 100
}
}
func Select() int {
return ServerIndex[fastrand.Intn(10)]
}
运行结果:
性能提高了百倍,主要思想是牺牲了一定的数列一致性
4.项目实战
通过项目需求,需求拆解,逻辑设计,代码实现带领大家感受下真实的项目开发
4.1 需求设计
社区话题页面
- 展示话题(标题,文字描述)和回帖列表
- 暂不考虑前端页面实现,仅仅实现一个本地web服务
- 话题和回帖数据用文件存储
4.2 需求用例
主要功能点:用户浏览消费,涉及页面的展示,包括话题内容和回帖的列表
4.3 ER图
- 话题
- 帖子
4.4 分层结构
- 数据层:数据Model,外部数据的增删改查
- 逻辑层:业务Entity,处理核心业务逻辑输出
- 视图层:视图view,处理和外部的交互逻辑
- 数据层关联底层数据模型,也就是这里的model,封装外部数据的增删改查,由于我们的数据存储在本地文件,通过文件操作拉取话题,帖子数据
- 数据从面向逻辑层,对service层透明,屏蔽下游数据差异,也就是不管下游是文件,还是数据库,还是微服务等,对service层的接口模型是不变的
- Service逻辑层处理核心业务逻辑,计算打包业务实体entiy,对应我们的需求,就是话题页面,包括话题和回帖列表,并上送给视图层
- Controller视图层负责处理和外部的交互逻辑,以view视图的形式返回给客户端,对于我们需求,我们封装json格式化的请求结构,api形式访问就好
4.5 组件工具
- Gin高性能 go web框架
https://github.com/gin-gonic/gin#installation - Go Mod
go mod init
go get gopkg.in/ginogonic/gin.v1@v1.3.0
4.6 定义struct
根据之前的ER图,先定义struct
定义数据data:
post:
{"id":1,"parent_id":1,"content":"小姐姐快来1","create_time":1650437616}
{"id":2,"parent_id":1,"content":"小姐姐快来2","create_time":1650437617}
{"id":3,"parent_id":1,"content":"小姐姐快来3","create_time":1650437618}
{"id":4,"parent_id":1,"content":"小姐姐快来4","create_time":1650437619}
{"id":5,"parent_id":1,"content":"小姐姐快来5","create_time":1650437620}
{"id":6,"parent_id":2,"content":"小哥哥快来1","create_time":1650437621}
{"id":7,"parent_id":2,"content":"小哥哥快来2","create_time":1650437622}
{"id":8,"parent_id":2,"content":"小哥哥快来3","create_time":1650437623}
{"id":9,"parent_id":2,"content":"小哥哥快来4","create_time":1650437624}
{"id":10,"parent_id":2,"content":"小哥哥快来5","create_time":1650437625}
topic:
package repository
import (
"sync"
)
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 Repository
4.7.1 Repository-index
- 可以使用全扫描遍历,但是效率不高,这里引出索引的概念
- 这里用map实现内存索引,在服务对外暴露前,利用文件元数据初始化全局内存索引,这样就可以实现0/1的时间复杂度查找操作
db_init.go:
var(
topicIndexMap map[int64]*Topic
postIndexMap map[int64][]*Post
)
4.7.2 初始化话题数据索引
- 打开文件
- 基于file初始化scanner
- 通过迭代器方式遍历数据行
- 转化为结构体存储至内存map
db_init.go:
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
}
4.7.3 查询操作
- 直接根据查询key获得map中的value
- 使用sync.once 主要适用高并发的场景下只执行一次的场景
- 这里基于once的实现模型就是我们平常的单例模型,减少存储的浪费
topic.go:
package repository
import (
"sync"
)
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.8 Service
通过err控制流程退出,正常会返回页面信息,err为nil
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
}
关于prepareInfo方法,话题和回帖信息的获取都依赖topicid,这样就可以并行执行,提高执行效率
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
}
4.9 Controller
定义一个view对象,通过code msg打包业务状态信息,用data承载业务实体信息
package cotroller
import (
"strconv"
"github.com/Moonlight-Zhao/go-project-example/service"
)
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{
Code: -1,
Msg: err.Error(),
}
}
pageInfo, err := service.QueryPageInfo(topicId)
if err != nil {
return &PageData{
Code: -1,
Msg: err.Error(),
}
}
return &PageData{
Code: 0,
Msg: "success",
Data: pageInfo,
}
}
4.10 Router
最后是web服务的引擎配置
- 初始化数据索引
- 初始化引擎配置
- 构建路由
- 启动服务
path映射到具体的controller,通过path变量传递话题id
package main
import (
"github.com/Moonlight-Zhao/go-project-example/cotroller"
"github.com/Moonlight-Zhao/go-project-example/repository"
"gopkg.in/gin-gonic/gin.v1"
"os"
)
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
}
}
func Init(filePath string) error {
if err := repository.Init(filePath); err != nil {
return err
}
return nil
}
4.11 运行
参考博客:https://blog.csdn.net/easysec/article/details/112760038
运行server.go文件:
打开页面输入:http://localhost:8080/community/page/get/1
打开页面输入:http://localhost:8080/community/page/get/2
此时运行台:
如果想在cmd中输出需要输入:
curl --location --request GET http://localhost:8080/community/page/get/1
curl --location --request GET http://localhost:8080/community/page/get/2
4.12 测试
覆盖率: