这是我参与「第三届青训营 -后端场」笔记创作活动的的第2篇笔记。
前置学习资料
一、课前预习链接
【Go 语言原理与实践学习资料】第三届字节跳动青训营-后端专场 - 掘金 (juejin.cn)
二、课程PPT链接
Go 语言入门 - 工程实践.pptx - 飞书文档 (feishu.cn)
三、课程github链接
Moonlight-Zhao/go-project-example at V0 (github.com)
四、Go语言基础知识
清华学神尹成带你实战Golang2022-go语言最新go1.17最有深度最详细-没有之一(开课吧golang试看视频)_哔哩哔哩_bilibili
知识点总结
① 并发编程
一、并行 VS 并发
-
并发(concurrency):把任务在不同的时间点交给处理器进行处理。在同一时间点,任务并不会同时运行。
-
并行(parallelism):把每一个任务分配给每一个处理器独立完成。在同一时间点,任务一定是同时运行。
二、goroutine
-
协程:轻量级的线程,不存在上下文切换,能在多个任务之间调度
-
协程与线程/进程的区别:线程和进程的操作是由程序触发系统接口,最后的执行者是系统,它本质上是操作系统提供的功能。而协程的操作则是程序员指定的
-
协程存在的意义:对于多线程应用,CPU 通过切片的方式来切换线程间的执行,线程切换时需要耗时。协程,则只使用一个线程,分解一个线程成为多个“微线程”,在一个线程中规定某个代码块的执行顺序
-
协程的适用场景:当程序中存在大量不需要 CPU 的操作时(IO 密集型)
三、CSP并发模型
多线程的并发编码,需要频繁加锁解锁,而 Golang 使用 CSP 并发模型的思想,提倡使用通信共享内存,而不是共享内存来通信,其协程之间通过通道 Channel 实现通信从而共享内存,很多时候可以实现无锁编程。 本质上来说:
- 就是推荐 channel 不推荐 atomic(原子操作)和 sync(异步编码)。
- 但是一般来说,如果程序/模块复杂度不高,比如变量出现的地方不多,需要加锁的变量总体也不多,完全可以考虑用mutex。通常复杂度高的情况下,特别一大堆变量相互关联且都需要加锁,可能使用 chan,select 等更简洁明了,也更不易出错。
四、channel
有缓冲和无缓冲的channel有什么区别?
- 有缓冲 chan 不容易阻塞,非同步的
- 无缓冲 chan 是同步的,就是在一个协程里面塞(取),另外一个操作必须即刻执行否则就会阻塞
五、并发安全Lock
Golang/chapter_22(并发编程-线程安全) at master · yyzyyyzy/Golang (github.com)
线程同步的四种方法:
- time.Sleep
- sync.WaitGroup
- channel
- context
② 依赖管理
go mod 使用 - 掘金 (juejin.cn)
自己使用的话,gopath 和 gomod 都用过一段时间,govendor 没使用过,不做评价
-
gopath 需要手动下载包,并且可以自定义包名、路径等等,如果喜欢自己 diy 的话,gopath 模式可以一定程度满足你的需求
- 缺点:对于较多依赖的包,如:gin、beego 等,建议不要使用 gopath 模式,一个一个下载人会崩溃的
-
gomod 全自动模式,开始项目使用 go mod init 命令,拉取依赖使用 go mod tidy,gomod 模式能够非常方便的全自动下载
- 缺点:实在没什么缺点,硬说的话,对于强迫症患者,别打开 gomod 下载的默认文件夹,密密麻麻的,不太美观
前置配置项
set GO111MODULE=auto //在 $GOPATH/src 外面且根目录有 go.mod 文件时,开启模块支持
set GO111MODULE=on //无脑打开gomod模式
set GO111MODULE=off //不打开gomod模式,go 会从 GOPATH/src 或 vendor 文件夹寻找包
set GOPROXY=https://goproxy.cn //配置国内下载代理
实操
go mod init //执行命令之后,你会看到,项目下多了go.mod和go.sum文件
go mod tidy //使用go mod之后,包下载之后是放在了$GOPATH/pkg/mod下
③ 程序测试
一、单元测试(代码对错)
单元测试代码示例
package __单元测试
func Sum(a, b int) int {
return a + b
}
package __单元测试
import (
"testing"
)
func TestGetSum(t *testing.T) {
result := Sum(1, 2)
if result != 3 {
t.Errorf("result is wrong")
return
}
t.Log("result is right")
}
二、覆盖率测试(单元测试的覆盖率)
衡量代码有没有经过足够的单元测试,我们需要进一步使用覆盖率测试评估项目的整体测试水准
覆盖率测试代码示例
package __覆盖率测试
func JudgePass(score int16) bool {
if score >= 60 {
return true
}
return false
}
package __覆盖率测试
func TestJudgePassTrue(t *testing.T) {
isTrue := JudgePass(70)
assert.Equal(t, true, isTrue)
}
三、Mock测试(借助外部工具进行单元测试)
代码不是单纯的流程控制,有着统一规范的输入输出,大多数都是依赖着外部系统,例如:数据库,网络,第三方接口等等。对于这种情况,我们很难单纯通过 Golang 标准库去编写好的单元测试,这时候我们就需要借助第三方的 Mock 工具(bouk/monkey: Monkey patching in Go (github.com))来帮助我们完成单元测试。
通过Mock玩转Golang单元测试 - 知乎 (zhihu.com)
四、性能测试(用时、内存占用)
性能测试代码示例
package __性能测试
func Add(a, b int) int {
return a + b
}
package __性能测试
import "testing"
func BenchmarkAdd(b *testing.B) {
b.Log("Add 开始性能测试")
b.ReportAllocs()
for i := 0; i < b.N; i++ { // b.N,测试循环次数
Add(4, 5)
}
}
工程实践
一、需求设计
-
架构设计
-
需求用例(从用户考虑)
-
ER 图(话题和帖子关系图)
-
项目结构
- 项目依赖
- 项目目录
|—Controller
|—query_page_info.go
|—Service
|—query_page_info.go
|—Repository
|—db_init.go
|—post.go
|—topic.go
|—data
|—post.txt
|—topic.txt
二、代码开发
Data(数据):
- data 数据由 post.txt 和 topic.txt 组成
- 每个 topic 话题有 5 条 post 回帖信息
Repository(数据层):
- 作用:初步封装 DAO 接口和 Query 接口
- 代码思路:
- 由ER图可以生成结构体
type Post struct {
Id int64 `json:"id"`
ParentId int64 `json:"parent_id"`
Content string `json:"content"`
CreateTime int64 `json:"create_time"`
}
type Topic struct {
Id int64 `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
CreateTime int64 `json:"create_time"`
}
- 使用DAO封装数据对象
- 作用:
- 一个 DAO 封装一张表,一张表的CURD等操作细节,全部封装入内
- 优点:
- 在管理开发时,通过数据库访问对象可以避免反复的 SQL 命令书写
- 在管理开发时,通过数据库访问对象可以避免反复的 JDBC 开发步骤书写
- 作用:
type PostDao struct {
}
type TopicDao struct {
}
- 使用单例模式创建数据对象(适合高并发下只执行一次的场景,减少存储的浪费)
var (
topicDao *TopicDao
topicOnce sync.Once
)
// NewTopicDaoInstance 单例模式获取话题DAO
func NewTopicDaoInstance() *TopicDao {
topicOnce.Do(
func() {
topicDao = &TopicDao{}
})
return topicDao
}
var (
postDao *PostDao
postOnce sync.Once
)
// NewPostDaoInstance 单例模式获取回帖DAO
func NewPostDaoInstance() *PostDao {
postOnce.Do(
func() {
postDao = &PostDao{}
})
return postDao
}
- 查询话题和回帖列表的方法
var (
topicIndexMap map[int64]*Topic //通过建立索引的方式提升性能
postIndexMap map[int64][]*Post //通过建立索引的方式提升性能
)
// QueryTopicById 根据话题id获取话题结构体的所有内容
func (*TopicDao) QueryTopicById(id int64) *Topic {
return topicIndexMap[id]
}
// QueryPostsByParentId 根据parentId获取回帖列表的切片
func (*PostDao) QueryPostsByParentId(parentId int64) []*Post {
return postIndexMap[parentId]
}
- 项目不使用任何数据库,为了提升查询性能,建个哈希 map 索引吧!
- filepath = ./data/
- 使用文件扫描器,具体使用方法:Golang/17.文件扫描器bufio.NewScanner.go at master · yyzyyyzy/Golang (github.com)
- 反序列化存储到 topic,再通过参数传递给 topicIndexMap
func initTopicIndexMap(filePath string) error {
open, err := os.Open(filePath + "topic") //filepath=./data/
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 { //反序列化存储到topic结构体
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
}
Service(逻辑层):
- 作用:打包 PageInfo,对数据层的接口进一步封装,给视图层提供更加高级的访问接口
- 代码思路:
- 创建 PageInfo 实例
type PageInfo struct {
Topic *repository.Topic
PostList []*repository.Post
}
// QueryPageInfo 根据话题id给出页面信息,包括相关话题和回复列表
func QueryPageInfo(topicId int64) (*PageInfo, error) {
return NewQueryPageInfoFlow(topicId).Do()
}
// NewQueryPageInfoFlow 返回对应的上下文内容
func NewQueryPageInfoFlow(topId int64) *QueryPageInfoFlow {
return &QueryPageInfoFlow{
topicId: topId,
}
}
// QueryPageInfoFlow 相当于一个查询的上下文context,为该context实现相应的方法来实现查询过程。
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
}
Controller(视图层):
- 作用:视图层位于逻辑层的上层,调用逻辑层的接口将操作封装给最终的 client
- 代码思路:
- 创建 PageData 的 View 对象
type PageData struct {
Code int64 `json:"code"`
Msg string `json:"msg"`
Data interface{} `json:"data"`
}
- 客户端传递参数为 string 类型,结构体存储的是 int 类型,需要使用 ParseInt 做参数转换
- 业务错误码:分为参数转换失败错误、查询失败错误
// QueryPageInfo 接受一个string类型的话题id,然后返回相应的页面数据
// 除了数据还包含状态码和输出信息用于报告查询结果和错误等
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,
}
}
Server.go(运行主体):
- 作用:初始化项目,定义 Restful 路由,查询相关信息,运行代码
- 代码思路:
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
}
测试运行
$ go run server.go
本项目使用 Apifox 调试 API 结果如下:
输入1,2可以正确查询到信息,输入-1则回返回错误码,输入3虽然返回了success信息,但是数据字段是全空的,可以看出,输出结果达到了我们的预期。
课后习题
-
针对工程实践项目,我们需要实现以下三个需求:
-
支持发布帖子
-
本地ID生成需要不重复、唯一性
-
Append 文件,更新索引,注意Map的并发安全性问题
-
解题思路:
-
针对支持发布帖子,可以采取以下办法:
- 详情见代码
-
针对生成 ID 唯一性,可以采取以下方法:
- 使用生成随机10位的字符串
func RandomString(n int) string { var letters = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") result := make([]byte, n) rand.Seed(time.Now().Unix()) for i := range result { result[i] = letters[rand.Intn(len(letters))] } return string(result) }- 雪花算法生成唯一ID
package snowflake import ( "fmt" "time" "github.com/sony/sonyflake" ) var ( sonyFlake *sonyflake.Sonyflake // 实例 sonyMachineID uint16 // 机器ID ) func getMachineID() (uint16, error) { // 返回全局定义的机器ID return sonyMachineID, nil } // 需传入当前的机器ID func Init(machineId uint16) (err error) { sonyMachineID = machineId t, _ := time.Parse("2006-01-02", "2022-02-09") // 初始化一个开始的时间 settings := sonyflake.Settings{ // 生成全局配置 StartTime: t, MachineID: getMachineID, // 指定机器ID } sonyFlake = sonyflake.NewSonyflake(settings) // 用配置生成sonyflake节点 return } // GetID 返回生成的id值 func GetID() (id uint64, err error) { // 拿到sonyflake节点生成id值 if sonyFlake == nil { err = fmt.Errorf("snoy flake not inited") return } id, err = sonyFlake.NextID() return }func main() { if err := Init(1);err!=nil{ fmt.Printf("Init failed,err:%v\n",err) return } id,_ := GetID() fmt.Println(id) } -
针对Append 文件,更新索引,注意Map的并发安全性问题,可以采取以下办法:
-
使用读写锁
var ( topicIndexMap map[int64]*Topic postIndexMap map[int64][]*Post rwMutex sync.RWMutex )func (*PostDao) InsertPost(post *Post) error { f, err := os.OpenFile("./data/post", os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600) if err != nil { return err } defer f.Close() marshal, _ := json.Marshal(post) if _, err = f.WriteString(string(marshal)+"\n"); err != nil { return err } rwMutex.Lock() //加锁 postList, ok := postIndexMap[post.ParentId] if !ok { postIndexMap[post.ParentId] = []*Post{post} } else { postList = append(postList, post) postIndexMap[post.ParentId] = postList } rwMutex.Unlock() //解锁 return nil } -