这是我参与「第三届青训营 -后端场」笔记创作活动的的第 2 篇笔记。
一、本堂课重点内容
- Go 语言进阶
- 依赖管理
- 测试
- 项目实战
二、详细知识点介绍
1. Go 语言进阶
(1)并发
- 并发:通过时间片的切换来实现同时运行的状态。
- 并行:利用多核直接实现多线程的同时运行。
go 语言可以实现高效的调度 -> 更适合高并发的场景。
(2)协程
- 线程:是系统里比较昂贵的系统资源,属于内核态,其创建、切换、停止都属于很重的系统操作(由内核进行调度),比较消耗资源。
- 协程:其创建和调度由 go 语言本身完成,一个线程上可以并发地去跑多个协程,轻量级,可以轻易创建大量的协程。
示例:
用 go 关键字开启一个协程,使用 sleep 暴力阻塞,保证在协程执行完成之前,主线程不退出。
package concurrence
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)
}
(3)通道
- 协程间可以使用通道、临界区进行通信。
- 通道:就像一个队列,先入先出,能保证收发数据的顺序。
- 使用临界区时,需要通过互斥量对内存进行加锁,在一定程度上会影响程序的性能。
- 提倡“通过通信共享内存”而不是“通过共享内存实现通信”。
通道的创建:
- 通道是一种引用类型,使用 make 关键字创建。
- 可以指定缓冲区的大小,即通道的容量。
示例:
- 使用 <- 向通道发送数据。
- 使用 range 获取通道中的数据。
- defer close(…):延迟关闭,在当前函数执行完成后,关闭 … 。
package concurrence
// 计算平方数:0、1、4、9、……
func CalSquare() {
src := make(chan int)
dest := make(chan int, 3)
go func() {
defer close(src)
for i := 0; i < 10; i++ {
src <- i
}
}()
go func() {
defer close(dest)
for i := range src {
dest <- i * i
}
}()
for i := range dest {
//复杂操作
println(i)
}
}
(4)同步
- 锁:lock sync.Mutex。
- lock.Lock():上锁。
- lock.Unlock():解锁。
package concurrence
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)
}
使用 sync 包下的 waitgroup 实现阻塞:
- 创建变量:wg sync.WaitGroup。
- wg.Add(计数值)。
- Done():计数器 -1。
- Wait():阻塞直到计数器为 0。
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. 依赖管理
依赖管理演进历史:GOPATH -> Go Vendor -> Go Module。
(1)GOPATH
- 项目代码直接依赖 src 下的代码。
- 可以使用 go get 下载最新版本的包到 src 目录下。
弊端:所有本地项目都依赖于同一个 src 源码,无法兼容一个包的多个版本。如:A 和 B 依赖于某一 package 的不同版本,则无法实现 package 的多版本控制。
(2)Go Vendor
- 项目的依赖会优先从 vendor 目录下进行获取,若没有,再到 GOPATH 环境变量下去寻找。
弊端:
(3)Go Module
- 通过 go.mod 文件管理依赖包版本(配置文件、描述依赖)。
- Proxy:中心仓库管理依赖库。
- 通过 go get/go mod 指令工具管理依赖包。
go.mod 依赖配置:
- module xxx:模块路径,标识一个模块。
- go 原生库的版本号。
- 单元依赖的描述,每一条依赖的组成:模块路径 + 版本号。,
version:
- 语义化版本:major:不同的 major 属于不同的大版本,互相之间可以不兼容。minor:新增函数或功能,需要在当前 major 下做到前后兼容。patch:做一些代码 bug 的修复。
- 伪版本:语义化版本 + 时间戳 + 哈希校验码。
关键字标识:
- indirect:对于间接依赖,用 indirect 标识。
- incompatible:不兼容的,将某些可能会存在不兼容代码逻辑的依赖标识出来。
- 主版本(major)在 2 以上时,依赖路径后应追加对应的 /vN。
示例:选 B,会选择最低的兼容版本。
直接使用版本管理仓库下载依赖可能会出现问题:
- 无法保证构建稳定性:增加、修改、删除软件版本。
- 无法保证依赖可用性:删除软件。
- 增加第三方压力:代码托管平台负载问题。
优化:直接从 go proxy 中拉取依赖,更稳定、可靠。
通过 go proxy 环境变量来控制 go proxy 的配置:
依赖管理工具:
go get:
- 使用 go get 默认会拉取最新的 major 版本。
go mod:
- 在实际项目中,每一次提交前都可以执行一次 go mod tidy。
3. 测试
(1)测试的分类
- 回归测试:通过终端手动地回归一些场景。
- 集成测试:对系统功能维度的测试。
- 单元测试:测试开发阶段。
从上到下,被测代码覆盖范围逐渐变大,成本却逐层降低。
(2)单元测试
测试规则:
示例:
package test
import (
"github.com/stretchr/testify/assert"
"testing"
)
func HelloTom() string {
return "Tom"
}
// test
func TestHelloTom(t *testing.T) {
output := HelloTom()
expectOutput := "Tom"
assert.Equal(t, expectOutput, output)
}
运行测试:
- m.run():跑当前包下的所有单元测试。
- go test [flags] [packages]。
- 通过开源包来实现测试结果是否等于预期结果的判断:"github.com/stretchr/testify/assert"。
代码覆盖率:
- 代码覆盖率越完备,代码性能就越有保障。
- 使用 go test xxx.go --cover,通过加上 cover 参数,可以得到代码测试的覆盖率。
- 在下面的例子中,测试时只运行了需要测试的函数中的前两行,即验证了前两行的正确性,最后一行并没有得到验证,因此覆盖率为 2/3。
- 在实际项目中,一般覆盖率为:50%~60%,较高:80%+。
package test
import (
"github.com/stretchr/testify/assert"
"testing"
)
func JudgePassLine(score int16) bool {
if score >= 60 {
return true
}
return false
}
// test
func TestJudgePassLineTrue(t *testing.T) {
isPass := JudgePassLine(70)
assert.Equal(t, true, isPass)
}
// coverage:66.7%
func TestJudgePassLineFail(t *testing.T) {
isPass := JudgePassLine(50)
assert.Equal(t, false, isPass)
}
// coverage:100.0%
单元测试的目标:
- 幂等:重复运行相同的代码,结果是一样的。
- 稳定:程序运行稳定,不会崩溃出错。
(3)单元测试 - mock
- 常用的开源 mock 测试包:monkey。
- 用打桩函数去替换原函数。
- patch():为函数打桩。入参含义:target(原函数)、replacement(打桩函数)。
- unpatch():保证在测试结束后把桩给卸载掉。
- 在运行时,实际调用的是打桩函数,实现 mock 测试。
- 可以在任何时间,并且不依赖任何本地文件进行测试。
package test
import (
"bou.ke/monkey"
"github.com/stretchr/testify/assert"
"testing"
)
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
}
// test
func TestProcessFirstLine(t *testing.T) {
firstLine := ProcessFirstLine()
assert.Equal(t, "line00", firstLine)
}
// mock
func TestProcessFirstLineWithMock(t *testing.T) {
// 打桩,替换原函数
monkey.Patch(ReadFirstLine, func() string {
return "line110"
})
// 延迟卸桩
defer monkey.Unpatch(ReadFirstLine)
line := ProcessFirstLine()
assert.Equal(t, "line000", line)
}
(4)基准测试
使用 go test 末尾加上 -bench=. 标签进行基准测试。
示例:负载均衡,随机选择执行服务器。
关于随机函数:
- rand 函数为了保证全局的随机性以及并发安全,持有了一把全局锁,因此使用并行的方式进行测试效率反而可能更低。
- 解决 rand 函数的效率问题:开源的 fastrand,该函数极大地提高了多线程并发情况下的性能,缺点是牺牲了数据一定的随机性。
三、实践练习例子
话题与帖子是一对多的关系:
(1)分层结构
- 数据层:主要关联底层的数据模型,面向逻辑层,对逻辑层透明,会屏蔽掉底层的数据差异,向逻辑层提供数据 model。
- 逻辑层:通过接收数据层的数据做打包封装,会输出实体 entity,在本例中就是话题页面。
- 视图层:对上游的 client 负责,主要是封装一些数据格式。
组件工具:
- 使用 go mod init 来初始化 go.mod 依赖文件。
- 执行 go get xxx 获取依赖。
(2)数据层
- 创建索引,并初始化内存 Map。
- 实现查询:根据 topic id 到 Map 中查询对应的 topic 和 post。
package repository
import (
"bufio"
"encoding/json"
"os"
)
// db_init.go
// 创建索引
var (
topicIndexMap map[int64]*Topic
postIndexMap map[int64][]*Post
)
func Init(filePath string) error {
if err := initTopicIndexMap(filePath); err != nil {
return err
}
if err := initPostIndexMap(filePath); err != nil {
return err
}
return nil
}
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
}
// topic.go
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]
}
// post.go
import (
"sync"
)
type Post struct {
Id int64 `json:"id"`
ParentId int64 `json:"parent_id"`
Content string `json:"content"`
CreateTime int64 `json:"create_time"`
}
type PostDao struct {
}
var (
postDao *PostDao
postOnce sync.Once
)
func NewPostDaoInstance() *PostDao {
postOnce.Do(
func() {
postDao = &PostDao{}
})
return postDao
}
func (*PostDao) QueryPostsByParentId(parentId int64) []*Post {
return postIndexMap[parentId]
}
(3)逻辑层
package service
import (
"errors"
"github.com/Moonlight-Zhao/go-project-example/repository"
"sync"
)
// 实体(页面)的数据包含话题和对应的帖子
type PageInfo struct {
Topic *repository.Topic
PostList []*repository.Post
}
// 根据话题 id 查询对应的页面
func QueryPageInfo(topicId int64) (*PageInfo, error) {
return NewQueryPageInfoFlow(topicId).Do()
}
func NewQueryPageInfoFlow(topId int64) *QueryPageInfoFlow {
return &QueryPageInfoFlow{
topicId: topId,
}
}
type QueryPageInfoFlow struct {
topicId int64
pageInfo *PageInfo
topic *repository.Topic
posts []*repository.Post
}
func (f *QueryPageInfoFlow) Do() (*PageInfo, error) {
// 1.参数校验
if err := f.checkParam(); err != nil {
return nil, err
}
// 2.准备数据
if err := f.prepareInfo(); err != nil {
return nil, err
}
// 3.组装实体
if err := f.packPageInfo(); err != nil {
return nil, err
}
return f.pageInfo, nil
}
// 1.参数校验
func (f *QueryPageInfoFlow) checkParam() error {
if f.topicId <= 0 {
return errors.New("topic id must be larger than 0")
}
return nil
}
// 2.准备数据
// topic 和 post 信息都只依赖于 topic id,因此可以并行执行数据的准备
func (f *QueryPageInfoFlow) prepareInfo() error {
// 获取 topic 信息
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
// 调用 repository 层的方法完成数据的查询
topic := repository.NewTopicDaoInstance().QueryTopicById(f.topicId)
f.topic = topic
}()
// 获取 post 列表
go func() {
defer wg.Done()
// 调用 repository 层的方法完成数据的查询
posts := repository.NewPostDaoInstance().QueryPostsByParentId(f.topicId)
f.posts = posts
}()
// 阻塞,等待上面两个查询都完成后再返回
wg.Wait()
return nil
}
// 3.组装实体
func (f *QueryPageInfoFlow) packPageInfo() error {
f.pageInfo = &PageInfo{
Topic: f.topic,
PostList: f.posts,
}
return nil
}
(4)控制层
package cotroller
import (
"github.com/Moonlight-Zhao/go-project-example/service"
"strconv"
)
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(),
}
}
// 调用 service 层的方法根据 topic id 查询页面信息
pageInfo, err := service.QueryPageInfo(topicId)
if err != nil {
return &PageData{
Code: -1,
Msg: err.Error(),
}
}
return &PageData{
Code: 0,
Msg: "success",
Data: pageInfo,
}
}
(5)启动 server
- 初始化数据索引。
- 初始化引擎配置。
- 构建路由。
- 启动服务:go run sever.go curl 127.0.0.1:8080/community/page/get/1
四、课后个人总结
通过本次课程的学习,我对 go 语言在并发场景下的使用方法以及优势有了一个大致的了解。其次,我学习到了 go 语言对于依赖管理的方式,这让我联想到了 java 中 Maven 的使用,通过对 go mod 的学习,让我所学的知识有一种融汇贯通的感觉。然后,我还了解到了在实际项目中可能会用到的一些常见的测试方法,如单元测试等。最后,通过一个简单小项目的例子,让我对后端的架构分层设计有了更直观的了解,非常期待下一次课程的学习。