Golang基础篇2+工程项目|青训营笔记

321 阅读10分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第2篇笔记。

wallhaven-v9v3r5_1920x1080.png

前置学习资料

一、课前预习链接

【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):把每一个任务分配给每一个处理器独立完成。在同一时间点,任务一定是同时运行。 image.png

二、goroutine

  • 协程:轻量级的线程,不存在上下文切换,能在多个任务之间调度

  • 协程与线程/进程的区别:线程和进程的操作是由程序触发系统接口,最后的执行者是系统,它本质上是操作系统提供的功能。而协程的操作则是程序员指定的

  • 协程存在的意义:对于多线程应用,CPU 通过切片的方式来切换线程间的执行,线程切换时需要耗时。协程,则只使用一个线程,分解一个线程成为多个“微线程”,在一个线程中规定某个代码块的执行顺序

  • 协程的适用场景:当程序中存在大量不需要 CPU 的操作时(IO 密集型)

image.png

三、CSP并发模型

多线程的并发编码,需要频繁加锁解锁,而 Golang 使用 CSP 并发模型的思想,提倡使用通信共享内存,而不是共享内存来通信,其协程之间通过通道 Channel 实现通信从而共享内存,很多时候可以实现无锁编程。 本质上来说:

  1. 就是推荐 channel 不推荐 atomic(原子操作)和 sync(异步编码)。
  2. 但是一般来说,如果程序/模块复杂度不高,比如变量出现的地方不多,需要加锁的变量总体也不多,完全可以考虑用mutex。通常复杂度高的情况下,特别一大堆变量相互关联且都需要加锁,可能使用 chan,select 等更简洁明了,也更不易出错。

image.png

四、channel

有缓冲和无缓冲的channel有什么区别?

  1. 有缓冲 chan 不容易阻塞,非同步的
  2. 无缓冲 chan 是同步的,就是在一个协程里面塞(取),另外一个操作必须即刻执行否则就会阻塞 image.png

五、并发安全Lock

Golang/chapter_22(并发编程-线程安全) at master · yyzyyyzy/Golang (github.com)

线程同步的四种方法:

  • time.Sleep
  • sync.WaitGroup
  • channel
  • context

② 依赖管理

go mod 使用 - 掘金 (juejin.cn) image.png 自己使用的话,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下

③ 程序测试

一、单元测试(代码对错)

image.png

单元测试代码示例
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")
}

二、覆盖率测试(单元测试的覆盖率)

衡量代码有没有经过足够的单元测试,我们需要进一步使用覆盖率测试评估项目的整体测试水准

image.png

覆盖率测试代码示例
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)

四、性能测试(用时、内存占用)

image.png

image.png

image.png

性能测试代码示例
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)
	}
}

工程实践

一、需求设计

  • 架构设计 image.png

  • 需求用例(从用户考虑) image.png

  • ER 图(话题和帖子关系图) image.png

  • 项目结构

image.png

  • 项目依赖

image.png

  • 项目目录
|—Controller
    |—query_page_info.go
|—Service
    |—query_page_info.go
|—Repository
    |—db_init.go
    |—post.go
    |—topic.go
|—data   
    |—post.txt
    |—topic.txt

二、代码开发

Data(数据):

  1. data 数据由 post.txt 和 topic.txt 组成
  2. 每个 topic 话题有 5 条 post 回帖信息

image.png

image.png

Repository(数据层):

  • 作用:初步封装 DAO 接口和 Query 接口
  • 代码思路:
  1. 由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"`
}
  1. 使用DAO封装数据对象
    • 作用:
      • 一个 DAO 封装一张表,一张表的CURD等操作细节,全部封装入内
    • 优点:
      • 在管理开发时,通过数据库访问对象可以避免反复的 SQL 命令书写
      • 在管理开发时,通过数据库访问对象可以避免反复的 JDBC 开发步骤书写
type PostDao struct {
}

type TopicDao struct {
}
  1. 使用单例模式创建数据对象(适合高并发下只执行一次的场景,减少存储的浪费)
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
}
  1. 查询话题和回帖列表的方法
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]
}
  1. 项目不使用任何数据库,为了提升查询性能,建个哈希 map 索引吧!
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,对数据层的接口进一步封装,给视图层提供更加高级的访问接口
  • 代码思路:
  1. 创建 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
}
  1. 流程实现

image.png

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
  • 代码思路:
  1. 创建 PageData 的 View 对象
type PageData struct {
	Code int64       `json:"code"`
	Msg  string      `json:"msg"`
	Data interface{} `json:"data"`
}
  1. 客户端传递参数为 string 类型,结构体存储的是 int 类型,需要使用 ParseInt 做参数转换
  2. 业务错误码:分为参数转换失败错误、查询失败错误
// 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 结果如下:

image.png image.png

image.png image.png

输入1,2可以正确查询到信息,输入-1则回返回错误码,输入3虽然返回了success信息,但是数据字段是全空的,可以看出,输出结果达到了我们的预期。

课后习题

  • 针对工程实践项目,我们需要实现以下三个需求:

    • 支持发布帖子

    • 本地ID生成需要不重复、唯一性

    • Append 文件,更新索引,注意Map的并发安全性问题

解题思路:

  1. 针对支持发布帖子,可以采取以下办法:

    • 详情见代码
  2. 针对生成 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)
    }
    
  3. 针对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
    }
    

解题代码: