Go语言上手-工程实践(含课后实践解答) | 青训营笔记

1,357 阅读14分钟

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

由于此次课内容非常多,信息密度很高又十分重要,所以本文很长,对课堂内容进行了全面总结并推荐了不少学习资料,后面还对实战项目代码进行了详细解析,最后给出了课后实践内容的解析和代码。

课程导学链接

【Go 语言原理与实践学习资料】第三届字节跳动青训营-后端专场 - 掘金

课程项目地址

GitHub - Moonlight-Zhao/go-project-example at V0

课程PPT链接

Go语言入门-工程实践

并发编程

这一部分课程主要分为4个大的知识点,分别是协程Goroutine通道Channel锁Lock线程同步WaitGroup,贴出的链接都是Go-by-example的链接,其中通道部分内容较多也非常重要,建议全部认真学习。此外掘金上的Go并发编程实战也写的不错,推荐阅读。

下面将课堂上讲到的重要知识点做一个罗列总结

  • 并发vs并行

    并行指的是两件任务在不同的CPU核上同时执行。

    并发指的是两件任务执行的时间存在重叠,并发程序可以在多个核上并行运行,也可以是在一个核上分时复用。

    以上参考这里,课堂上对二者关系做了一个简化处理,但不妨碍理解其核心意思。如下图

    Untitled.png

  • 协程vs线程

    协程即为Go语言中原生支持的轻量级线程,其运行在用户态,有Go运行时负责维护,一次启动几万个协程都没问题。下图为二者对比。

    Untitled 1.png

  • CSP并发模型——通过通信共享内存

    相信写过多线程程序的同学都知道多线程共享内存多么容易产生bug,而加锁读写操作也会降低程序性能。而Go语言采用了CSP(Communicating Sequential Processes)并发模型,其协程之间通过通道Channel实现通信从而共享内存,很多时候可以实现无锁编程。二者区别如下图所示。

    Untitled 2.png

  • 带缓冲的通道——生产者消费者模型

    带缓冲的通道即可看作经典的生产者消费者模型

    Untitled 3.png

  • 并发安全Lock

  • WaitGroup协程同步

依赖管理

课上讲解了依赖管理的演进历史(如下图),并详细解释了过去的依赖管理方法的问题,出于实用主义考虑不必知道太多的历史细节,只要知道当前的Go Module方法的工作机制和原理即可。

Untitled 4.png

这里我优先推荐阅读Go官方撰写的Using Go Module博客,篇幅不大却非常实用,英文不好的同学推荐阅读掘金文章go mod使用。下面列出版本管理常用的命令

// 初始化module
$ go mod init module-name
// 列出当前module和所有依赖
$ go list -m all
// 下载或升级相应module
$ go get module-name
// 列出包的可用版本号
$ go list -m -versions module-name
// 下载特定版本module
$ go get module-name@v1.3.1
// 清理未使用的module,下载所需的包(非常常用)
$ go mod tidy

此外考虑到网络因素(Go官网在国内被墙,Github访问速度不稳定等)和安全稳定性因素,Go module的分发采用了PROXY模型,这里我推荐设置七牛云 - Goproxy.cn代理,按照页面说明操作即可。

Untitled 5.png

测试

Go语言自带完善的测试框架,可提供单元测试和Benchmark测试。

单元测试和基准测试

单元测试规则,一图胜千言,具体的例子可以看Go-by-example的单元测试和基准测试例子,其中还讲解了表驱动测试的例子,很值得学习。对于测试相关的命令,这里不推荐大家记忆,使用go help testgo help testflag来查看命令参数并根据所需使用即可,查阅文档的习惯对于编程来说非常重要,有时候搜到的二手资料有些问题反而会让你花掉更多的时间。

Untitled 6.png

Mock测试

针对IO相关的应用,常规的测试方法会受到测试文件数据等的变化的限制,没办法保持一致性,所以这里引入了mock测试的概念。不过我认为可以仅作了解,知道有这么个工具可以解决IO相关的测试问题,等到实际用到时再来学习。

项目实践

这一部分我认为是整个课程的精华部分,针对一个发帖评论页面的后端实现进行了需求分析、代码开发、测试运行的整个项目流程详细介绍,我认为这对青训营极简版抖音项目具有很大的参考意义。课上老师囿于时间因素未详细讲解,这里我将对整个流程以及代码进行详细解析,作为学习记录,也供同学们参考。

需求分析

首先明确我们的需求,鉴于这是一个教学性的demo项目,所以尽可能剥离复杂的业务因素,将最核心的逻辑展现给大家。所以不考虑前端的实现,仅仅实现一个本地web服务,提供接口给前端使用;再者数据存储简单地使用文件存储,而不使用数据库;核心需求为展示话题和回帖。

Untitled 7.png

具体的话题和回帖文件如下(仅包含两个话题,每个话题5条回帖)

Untitled 8.png

Untitled 9.png

设计阶段

首先针对该需求中的两个实体——话题和回复,根据上述文件内容设计相应的结构体。

// topic.go
type Topic struct {
	Id         int64  `json:"id"`
	Title      string `json:"title"`
	Content    string `json:"content"`
	CreateTime int64  `json:"create_time"`
}

// post.go
type Post struct {
	Id         int64  `json:"id"`
	ParentId   int64  `json:"parent_id"`
	Content    string `json:"content"`
	CreateTime int64  `json:"create_time"`
}

接下来设计整个后端的结构,实践中常用数据层-逻辑层-视图层的分层结构来设计后端系统,详见下图。这样的系统可以将数据和逻辑进行分离,降低代码耦合度,结构更清晰可读性强,也方便测试。

Untitled 10.png

此外该项目中对数据的访问还用到了DAO(Data Access Object)模式,简而言之就是将对数据的直接访问做了一层封装,不让上层应用直接访问数据对象,而是通过DAO对象提供的接口来获取数据。其实这也就是数据层的的抽象所在,让逻辑层不用知道我到底是用redis还是sql又或者是直接文件存储数据,只需要访问DAO接口即可。

代码开发

数据层

数据层的底层数据结构前面已经设计过了。这里我们考虑数据层需要暴露给上层的接口,根据前面提到的DAO模式,我们需要提供给上层一个DAO对象并提供相应的访问数据的方法,具体下来有如下三个API

func NewTopicDaoInstance() *TopicDao                          // 获取话题DAO
func (*PostDao) QueryPostsByParentId(parentId int64) []*Post  // 根据话题id获取回复
func NewPostDaoInstance() *PostDao                            // 获取回复DAO
func (*TopicDao) QueryTopicById(id int64) *Topic              // 根据话题id获取话题

这里DAO对象是一个空对象,用到了单例模式,使用sync.Once来保证只被创建一次,其API的实现如下

// topic.go
type TopicDao struct {
}
var (
	topicDao  *TopicDao
	topicOnce sync.Once
)
func NewTopicDaoInstance() *TopicDao {
	topicOnce.Do(
		func() {
			topicDao = &TopicDao{}
		})
	return topicDao
}

// post.go
type PostDao struct {
}
var (
	postDao *PostDao
	postOnce sync.Once
)
func NewPostDaoInstance() *PostDao {
	postOnce.Do(
		func() {
			postDao = &PostDao{}
		})
	return postDao
}

而相应的话题和回复获取,项目则采用了建立索引的方式来提高性能,首先读取文件创建索引map(限于篇幅这部分代码(db_init.go)不进行讲解,其中用到了文件读写map创建等知识点,自行学习,笔记聚焦于程序主体逻辑),然后相应的方法中只需要直接根据id获取map相应内容即可,如下

// topic.go
func (*TopicDao) QueryTopicById(id int64) *Topic {
	return topicIndexMap[id]
}

// post.go
func (*PostDao) QueryPostsByParentId(parentId int64) []*Post {
	return postIndexMap[parentId]
}

逻辑层

前面数据层对存储数据进行了封装给出了DAO接口,逻辑层的任务则是将DAO接口进一步封装,以给视图层提供更加高级的访问接口,具体来讲则为以下API

type PageInfo struct {
	Topic    *repository.Topic
	PostList []*repository.Post
}

// 根据id给出页面信息,包括话题和回复列表
func QueryPageInfo(topicId int64) (*PageInfo, error)  

其具体实现不难,将DAO接口的数据组合到一起返回即可,不过这里实现中定义了一个QueryPageInfoFlow类型,相当于一个查询的上下文context,为该context实现相应的方法来实现查询过程。

type QueryPageInfoFlow struct {
	topicId  int64
	pageInfo *PageInfo

	topic   *repository.Topic
	posts   []*repository.Post
}

func QueryPageInfo(topicId int64) (*PageInfo, error) {
	return NewQueryPageInfoFlow(topicId).Do()
}

func NewQueryPageInfoFlow(topId int64) *QueryPageInfoFlow {
	return &QueryPageInfoFlow{
		topicId: topId,
	}
}

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
}

可以看到具体的实现逻辑是分成了三个部分checkParam(),prepareInfo(),packPackInfo()。其中checkParam()其实在我们这个简单应用中可以不用,但是工程实践中却非常必要,这里项目也是为了让我们体会工程开发的过程将一些值得注意的点都拎了出来。其具体实现如下

func (f *QueryPageInfoFlow) checkParam() error {
	if f.topicId <= 0 {
		return errors.New("topic id must be larger than 0")
	}
	return nil
}

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
}

func (f *QueryPageInfoFlow) packPageInfo() error {
	f.pageInfo = &PageInfo{
		Topic:    f.topic,
		PostList: f.posts,
	}
	return nil
}

值得注意的是获取话题和回复信息程序中使用了go协程来并行获取提高相应速度。

视图层

视图层位于逻辑层的上层,调用逻辑层的接口将操作封装给最终的client(这个例子里就是我们的server)来使用,其需要提供的API如下

type PageData struct {
	Code int64       `json:"code"`
	Msg  string      `json:"msg"`
	Data interface{} `json:"data"`
}
func QueryPageInfo(topicIdStr string) *PageData

可以看到其接受一个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

最终我们的Server使用视图层提供的API来访问数据,使用了RESTful的gin框架来提供web服务,gin框架的教程可以参看官方gin教程,中文教程可以参看简明gin教程。理解gin框架之后下面的程序其实非常简单,这里不过多赘述

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
}

测试运行

首先运行起server.go,然后在另一个命令行输入curl指令即可

Untitled 11.png

输入1,2可以正确查询到信息,输入-1则回返回错误码,输入3虽然返回了success信息,但是数据字段却是全空的。

Server端的输出为

Untitled 12.png

可以看到四个请求的HTTP状态都是200,说明成功连接并返回信息了。

其实我们还可以在浏览器中输入相应网址进行访问,如下图所示

Untitled 13.png

课后实践

课后发布了一个小任务,有如下要求

  • 支持发布帖子
  • 本地Id生成需要保证不重复、唯一性
  • Append文件,更新索引,注意Map的并发安全问题

发布帖子功能与查询帖子不同,需要发送POST请求,也就需要在Server中绑定一个POST方法,同时还需要底层提供添加帖子的API以供调用。至于本地id的唯一性,可以简单的维护一个id最大值,每次新的id加一即可。最后需要注意Map的并发安全问题,参考知乎文章Go 并发之三种线程安全的 map,我们这里并不涉及高并发大数据等性能问题,简单地使用其中第一种读写锁来保证并发安全即可。

下面的讲解中并不会将代码的每一处改动都详细列出,仅列出最关键的代码,此外对于Go语言我也是一名新手,针对该任务仅仅是完成其基本功能,谈不上代码规范和优雅。一切都以简单容易实现为先,希望能有一些启发。

完整项目代码可以查看我的GitHub仓库:

Little-stuff/go-project-example at main · zhangyi1357/Little-stuff

带读写锁的map具体实现如下,由于还没有学习泛型,这里针对两种不同类型的map分别创建了相应的带读写锁map。

// 以下实现参考《Go 并发之三种线程安全的 map - AFreeCoder的文章 - 知乎》
// 链接: https://zhuanlan.zhihu.com/p/356739568
package repository

import "sync"

type RWTopicMap struct {
	sync.RWMutex
	m map[int64]*Topic
}

func NewRWTopicMap(n int) *RWTopicMap {
	return &RWTopicMap{
		m: make(map[int64]*Topic),
	}
}

func (m *RWTopicMap) Get(k int64) (*Topic, bool) {
	m.RLock()
	defer m.RUnlock()
	v, existed := m.m[k]
	return v, existed
}

func (m *RWTopicMap) Set(k int64, v *Topic) {
	m.Lock()
	defer m.Unlock()
	m.m[k] = v
}

type RWPostMap struct {
	sync.RWMutex
	m map[int64][]*Post
}

func NewRWPostMap(n int) *RWPostMap {
	return &RWPostMap{
		m: make(map[int64][]*Post),
	}
}

func (m *RWPostMap) Get(k int64) ([]*Post, bool) {
	m.RLock()
	defer m.RUnlock()
	v, existed := m.m[k]
	return v, existed
}

func (m *RWPostMap) Set(k int64, v []*Post) {
	m.Lock()
	defer m.Unlock()
	m.m[k] = v
}

接着需要把相应的读取topic和post的API实现都改成如上的Get()Set()方法

func (*TopicDao) QueryTopicById(id int64) *Topic {
	topic, _ := topicIndexRWMap.Get(id)
	return topic
}

func (*PostDao) QueryPostsByParentId(parentId int64) []*Post {
	postList, _ := postIndexRWMap.Get(parentId)
	return postList
}

接下来我们自顶向下地来编写我们所需要的功能,针对于Server,我们加入了如下POST请求的绑定,我们接受到一个请求后取出其原始数据反序列化存到结构体pageInfo中,然后将这个结构体传给cotroller.AddNewPage(),最后将返回的pageData发回给client。

r.POST("/community/page/post", func(c *gin.Context) {
		b, err := c.GetRawData()
		if err != nil {
			log.Println("GetRawData() failed")
			c.JSON(http.StatusBadRequest, nil)
			return
		}
		var pageInfo service.PageInfo
		err = json.Unmarshal(b, &pageInfo)
		if err != nil {
			log.Println("Unmarshal() failed")
			c.JSON(http.StatusBadRequest, nil)
			return
		}
		pageData := cotroller.AddNewPage(&pageInfo)
		c.IndentedJSON(200, pageData)
	})

Cotroller层中的AddNewPage()实现如下。

// in cotroller
func AddNewPage(newPageInfo *service.PageInfo) *PageData {
	err := service.AddNewPage(newPageInfo)
	if err != nil {
		return &PageData{
			Code: -2,
			Msg:  err.Error(),
		}
	}
	return &PageData{
		Code: 0,
		Msg:  "success",
		Data: newPageInfo,
	}
}

比较简单我们就不做过多讲解了,直接深入到下一层serviceAddNewPage()中,

// in service
func AddNewPage(newPageInfo *PageInfo) error {
	id, err := repository.NewTopicDaoInstance().AddNewTopic(newPageInfo.Topic)
	if err != nil {
		return err
	}
	err = repository.NewPostDaoInstance().AddNewPost(newPageInfo.PostList, id)
	if err != nil {
		return err
	}
	return nil
}

注意我们AddNewTopic()得到返回的一个id,这个id就是新的topicid,然后将这个id发给AddNewPost(),这时候新的post就可以将这个id作为其ParentId。让我们接着看如何加入新的topicpost

func (*TopicDao) AddNewTopic(topic *Topic) (int64, error) {
	maxTopicId++
	topic.Id = maxTopicId
	topic.CreateTime = time.Now().Unix()
	topicIndexRWMap.Set(maxTopicId, topic)
	f, err := os.OpenFile("./data/topic", os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600)
	if err != nil {
		return -1, err
	}
	defer f.Close()
	f.WriteString("\n")
	b, _ := json.Marshal(topic)
	_, err = f.Write(b)
	if err != nil {
		return -1, err
	}
	return maxTopicId, nil
}

func (*PostDao) AddNewPost(postList []*Post, id int64) error {
	for _, post := range postList {
		post.ParentId = id
		post.CreateTime = time.Now().Unix()
	}
	postIndexRWMap.Set(id, postList)
	f, err := os.OpenFile("./data/post", os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600)
	if err != nil {
		return err
	}
	defer f.Close()

	var b []byte
	for _, post := range postList {
		f.WriteString("\n")
		b, _ = json.Marshal(post)
		_, err = f.Write(b)
		if err != nil {
			return err
		}
	}

	return nil
}

这两段代码看起来很长,但是其中有很多重复部分都是用于将数据写入到文件中的,出于时间因素考虑我并没有将其抽象出来,感兴趣的同学可以试试看。还有一个值得注意的点是我们维护了一个maxId变量,这个量记录了当前最大的帖子id,当我们加入一个新的帖子的时候该值加1作为新帖子的id,这样就可以保证帖子的id不重复。不过回复的id是可能会重复的,我这里并没有对其进行处理。

至此整个添加帖子的功能就实现完毕啦。可能初看起来会觉得一头雾水,但是只要从顶向下一点点分析下来,理清楚每一层应该做哪些事情,其实实现起来也没有那么困难。

下面是运行的一些结果,我这里使用POSTMAN进行测试运行。

Untitled 14.png

Untitled 15.png

Untitled 16.png

Untitled 17.png