GO语言工程实践课后作业:实现思路、代码以及路径记录 | 青训营

316 阅读12分钟

GO语言工程实践课后作业

前置

笔者环境

  • macos 10.15.7
  • Golang 1.18
  • GoLand 2022.01

读完本文可以获得

  1. 如何进行项目的需求分析,包括抽象业务实体,绘制ER图,设计项目层级结构等
  2. 如何使用Golang的gin框架搭建Web服务项目,管理项目依赖
  3. Golang项目的分层设计思想,包括Controller、Service、Repository等层的责任
  4. 如何设计查询流程控制和发布流程控制来组织Service层业务逻辑
  5. 如何设计线程安全的并发程序,使用sync.WaitGroup协调goroutine
  6. 如何设计全局唯一ID生成器,并在项目中应用

一、 需求分析

进入【第四届字节跳动青训营 - 暑假专场 Q&A】🙋...-青训营社区 (juejin.cn),我们将完成该社区话题页面的本地Web服务功能,不考虑前端实现。

Screen Shot 2023-08-02 at 3.13.47 PM.png

具体功能如下

  1. 展示话题(标题,文字描述)和回帖列表
  2. 对话题发布回帖(回帖id生成需要保证不重复、唯一性)

注意事项如下

  1. 话题和回帖使用文件存储
  2. 新加回帖追加到本地文件,同时需要更新索引,注意Map的并发安全问题

1.1 需求用例

用户可以浏览话题以及话题的回帖,也可以对话题进行回帖操作,因此可以抽象出两个实体Topic和PostList

Screen Shot 2023-07-28 at 7.34.56 PM.png

1.2 ER图

根据用例图,抽象出实体的属性,话题和帖子直接的联系是1:M

Screen Shot 2023-07-28 at 7.35.46 PM.png

1.3 分层结构

Screen Shot 2023-07-28 at 7.40.32 PM.png

按照上述代码分层结构,抽象出目录结构如下

.
├── controller  # 控制层
├── data 				# 本地存储
├── go.mod
├── go.sum
├── main.go
├── repository	# 数据层	
└── service			# 逻辑层

二、 项目搭建

我们使用 gin Web框架进行开发,使用 go mod 进行依赖管理。

2.1 新建项目

makdir topicPost 			# 项目目录
cd topicPost		
go mod init topicPost # 使用go mod 管理依赖
touch main.go     		# 创建go的入口文件

建议使用IDE工具进行项目搭建,如(GoLand 或 VSCode)

2.2 测试gin框架

  1. mian.go文件中添加如下测试代码(官方代码)

    package main
    
    import (
      "net/http"
      "github.com/gin-gonic/gin"
    )
    
    func main() {
      r := gin.Default()
      r.GET("/ping", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{
          "message": "pong",
        })
      })
      r.Run() // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")
    }
    
  2. 添加项目依赖

    go mod tidy
    

    使用go mod tidy命令将把项目所需要的依赖添加到go.mod

  3. 测试运行

    go run main.go
    

    项目启动后,在浏览器中输入localhost:8080/ping

    Screen Shot 2023-08-01 at 4.02.12 PM.png

2.3 新建目录

在需求分析的分层结构中已经抽象出了项目的目录结构,按照目录结构进行新建

Screen Shot 2023-08-01 at 4.07.49 PM.png

三、 实现思路

3.1 总体

  1. 先完成用户根据话题Id获取话题及其回帖
  2. 再完成用户根据话题Id进行回帖操作

3.2 步骤

  1. 根据ER图,我们先分别构造Topic和Post的本地数据,并存放到data目录下,便于后续请求数据的获取。

  2. 从data目录里读出Topic和Post的本地数据,并转成JSON格式存储到内存中

  3. 为Service层提供repository的查询功能

  4. 为Service层提供repository的回帖功能

  5. Service层接收TopicId,并返回话题和回帖列表

  6. Service层接收ParentId,并返回新增回帖Id

  7. Controller层处理页面信息请求,即获取话题及其回帖列表

  8. Controller层处理回帖发布请求

  9. 进行Router的路由设置

  10. 运行测试

四、代码开发

4.1 data本地数据

在data目录下新建topicpost两个文件

topic文件数据

{"id":1,"title":"青训营来啦!","content":"哈喽~欢迎你加入青训营大家庭!","create_time":1650437625}

post文件数据

{"id":1855946374730608640,"parent_id":1,"content":"HelloWorld","create_time":1690808012}

4.2 读取data本地数据

当启动项目时,需要将数据全部读入到内存中,(本项目数据小,可以一次性读入到内存),然后再进行后续逻辑的处理。

4.2.1 数据实体

根据ER图抽象出来两个实体:Topic和Post,将两个实体转为struct

在repository目录下新建topic.gopost.go

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

4.2.2 元索引

当从文件中读取Topic和Post的数据时,要将它们放到内存中,因此就需要一个数据结构进行存储。

  • 通过Topic Id来获取Topic,因此可以使用map[int64] *Topic进行存储,key是Topic Id,value是Topic的指针。
  • 通过Topic Id来获取该话题的回帖,而回帖可能有多个,因此可以使用map[int64] [] *Post进行存储,key是Topic Id,value是Post指针类型的数组。
定义元索引

在repository目录下新建initData.go文件,用于定义元索引和对其进行初始化(即读取data目录下的两个文件)

var (
	topicIndexMap map[int64]*Topic  // 存储话题元索引
	postIndexMap  map[int64][]*Post // 存储回帖列表索引
	rwMutex       sync.RWMutex      // 用于保证新增回帖时Map的线程安全
)

topicIndexMap 和 postIndexMap 这两个 map 可能会被多个 goroutine 同时访问和修改。

在本项目中,不涉及topicIndexMap的修改,因此不需要加锁。

但创建新回帖时,会存在数据竞争的风险,多个 goroutine 并发修改,可能导致数据不一致或者错误。

为了保证线程安全,这里使用了 sync.RWMutex 读写锁。

初始化元索引

处理步骤

  1. 打开文件
  2. 解析JSON为Topic结构体
  3. 构建索引map
// Init函数处理初始化逻辑
func Init(filePath string) error {
	var err error
	err = initTopicIndexMap(filePath)
	err = initPostIndexMap(filePath)

	return err
}

// 根据文件初始化话题索引
func initTopicIndexMap(filePath string) error {
	open, err := os.Open(filePath + "topic") // 打开文件
	defer open.Close()                       // 函数结束时关闭文件
	if err != nil {
		return err
	}
	scanner := bufio.NewScanner(open)     // 创建一个扫描器来读取文件内容,将返回值赋给 scanner 变量
	topicTmpMap := make(map[int64]*Topic) // 创建一个空的映射类型变量
	for scanner.Scan() {                  // 循环遍历 scanner 扫描器,每次扫一行
		text := scanner.Text() // 获取当前扫描到的文本内容
		var topic Topic
		err := json.Unmarshal([]byte(text), &topic) // 解析文本为JSON,并转为Topic结构体
		if err != nil {
			return err
		}
		topicTmpMap[topic.Id] = &topic // 将话题数据存储到映射中

	}
	topicIndexMap = topicTmpMap // 构建话题元索引

	return nil
}

// 根据文件初始化帖子索引
func initPostIndexMap(filePath string) error {
	open, err := os.Open(filePath + "post")
	defer open.Close()
	if err != nil {
		return err
	}
	scanner := bufio.NewScanner(open)
	postTmpMap := make(map[int64][]*Post)
	for scanner.Scan() {
		text := scanner.Text()
		var post Post
		err := json.Unmarshal([]byte(text), &post)
		if 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
}

4.3 Repository层

Repository层需要为Service层提供数据的查询和更新(修改、增加、删除)操作。

单例Dao

使用单例模式来提供一个线程安全的、易于管理的全局Dao对象,使其可以被高效共享。

全局单例Dao对象也有一些潜在问题

  • 效率不高:锁和频繁的同步开销会降低性能。
  • 代码侵入:全局实例强制要求依赖注入。
  • 扩展性差:一个实例难以应对复杂业务。
  • 并发访问:共享实例如果处理过长会阻塞其他请求。
  • 代码复用性差:实例捆绑全局状态,不易重用。

因为本项目的操作简单,因此采用全局单例模式

repository/topic.go

repository/topic.go文件提供返回指定TopicId的Topic实体

由于TopicDao只涉及读取Topic数据,不涉及写入,所以不加线程安全锁,并且为每个线程创建实例,无需同步和锁的开销。

package repository

import (
	"fmt"
)

// 话题实体
type Topic struct {
	Id         int64  `json:"Id"`
	Title      string `json:"title"`
	Content    string `json:"Content"`
	CreateTime int64  `json:"CreateTime"`
}

// 处理Topic数据操作
type TopicDao struct {
	// 内部可以添加dao需要的私有字段
}

var topicDao *TopicDao // 全局TopicDao指针

// 获取TopicDao实例
func NewTopicDaoInstance() *TopicDao {
	return &TopicDao{}
}

// 通过TopicId查询Topic
func (*TopicDao) QueryTopicById(id int64) (*Topic, error) {
	// 查找topic
	topic, ok := topicIndexMap[id]

	if !ok {
		return nil, fmt.Errorf("topic %d not found", id)
	}

	return topic, nil
}
repository/post.go

repository/pos.got文件提供返回指定parentId的Post列表

因为需要创建新的回帖,所以采用全局单例Dao来保证线程安全

package repository

import (
	"encoding/json"
	"fmt"
	"os"
	"sync"
)

// 回帖实体
type Post struct {
	Id         int64  `json:"id"`
	ParentId   int64  `json:"parent_id"`
	Content    string `json:"content"`
	CreateTime int64  `json:"create_time"`
}

// 处理Post数据操作
type PostDao struct {
	// 内部可以添加dao需要的私有字段
}

var (
	postDao  *PostDao  // postDao全局指针
	postOnce sync.Once // 确保全局postDao单例
)

// 获取postDao实例
func NewPostDaoInstance() *PostDao {
	// 初始化postDao
	postOnce.Do(
		func() {
			postDao = &PostDao{}
		})
	return postDao
}

// 通过parentId查询回帖数组
func (*PostDao) QueryPostByParentId(parentId int64) ([]*Post, error) {

	// 查找 PostFormIndex
	// 未找到返回错误
	posts, ok := postIndexMap[parentId]
	if !ok {
		return nil, fmt.Errorf("topic %d has no one post", parentId)
	}

	return posts, nil
}

// 创建新Post
func (*PostDao) CreatePost(post *Post) error {
	// 打开并写入Post到文件
	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) // 序列化Post结构体为json格式
	// 写入文件,并在每个Post后面加上换行符
	if _, err = f.WriteString(string(marshal) + "\n"); err != nil {
		return err
	}
	rwMutex.Lock()                              // 加上读写锁,保证线程安全
	defer rwMutex.Unlock()                      // 函数结束时释放锁
	postList, ok := postIndexMap[post.ParentId] // 从postIndexMap中根据post.ParentId获取对应的回帖列表
	if !ok {
		postIndexMap[post.ParentId] = []*Post{post} // 如果不存在,就创建一个新的列表并存入map中
	}
	postList = append(postList, post)      // 将新的回帖追加到列表中
	postIndexMap[post.ParentId] = postList // 更新回帖列表元索引
	return nil
}

4.4 Service层-查询

根据TopicId查询一个页面的信息,包括Topic和Post列表。我们使用查询流程控制来驱动整个流程,实现查询话题及其回帖逻辑。

查询流程控制说明

查询流程控制对象(Query Flow)是一种常见的Service层处理方式,主要原因有

  • 将一个查询功能的不同步骤模块化,提高内聚性。
  • 方便参数校验、业务处理、结果组装的逻辑分离。
  • 可以引入中间状态对象,而不是直接返回结果。
  • 易于日志记录和调试。
  • 流程控制对象可以重用。
  • 查询代码更易读和理解。

流程控制方式主要优点是将逻辑拆分得更清晰,更方便测试和扩展,有助于实现统一的服务接口。

查询流程控制步骤

  • 定义了PageInfo结构体,用于存储返回的页面信息。
  • 定义了QueryPageInfoFlow结构体,用于封装查询流程的属性和方法。
  • 定义了NewQueryPageInfoFlow函数,用于初始化查询流程。
  • 定义了Do方法,用于执行查询流程的各个步骤,并处理错误。
  • 定义了QueryPageInfo函数,用于创建并执行查询流程,并返回结果。
  • 定义了checkParam方法,用于检查参数是否合法。
  • 定义了prepareInfo方法,用于准备信息,包括获取主题和回帖列表。这里使用了并发和等待组来提高效率。
  • 定义了packPageInfo方法,用于包装结果为PageInfo结构体。

并行处理

话题元索引和回帖列表元索引可以同时根据TopicId进行数据的获取,因此可以并行获取。

本程序中使用了sync.WaitGroup来进行协调协程。

  • wg.Add(2)表示待加入 WaitGroup 的协程数量是2个,因为有两个并发查询

  • defer wg.Done()在每个并发查询中加入,可以在协程结束前将计数-1。

  • wg.Wait(),等待2个协程完成。

代码实现

在service层新建queryPageInfo.go文件

package service

import (
	"articlePost.cn/repository"
	"errors"
	"sync"
)

// 定义返回结果
type PageInfo struct {
	Topic    *repository.Topic
	PostList []*repository.Post
}

// 查询流程控制
type QueryPageInfoFlow struct {
	topicId  int64              // 流程属性
	topic    *repository.Topic  // 流程属性
	posts    []*repository.Post // 流程属性
	pageInfo *PageInfo          // 结果属性

}

// 初始化流程
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
}

// 入口函数,创建流程并运行
func QueryPageInfo(topicId int64) (*PageInfo, error) {
	return NewQueryPageInfoFlow(topicId).Do()
}

// 检查参数
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)
	var topicErr, postErr error
	// 启动goroutine并发查询话题
	go func() {
		defer wg.Done()
		topic, err := repository.NewTopicDaoInstance().QueryTopicById(f.topicId) // Repository查询话题
		if err != nil {
			topicErr = err
			return
		}
		f.topic = topic // 保存结果到flow中
	}()
	// 并发获取post列表
	go func() {
		defer wg.Done()
		posts, err := repository.NewPostDaoInstance().QueryPostByParentId(f.topicId) // Repository查询回帖
		if err != nil {
			topicErr = err
			return
		}
		f.posts = posts // 保存结果到flow中
	}()
	wg.Wait() // 等待两个goroutine结束,确保两个查询完成才继续流程

	// 检查错误并返回
	if topicErr != nil {
		return topicErr
	}
	if postErr != nil {
		return postErr
	}

	return nil
}

// 包装结果
func (f *QueryPageInfoFlow) packPageInfo() error {
	// 封装为返回结构
	f.pageInfo = &PageInfo{
		Topic:    f.topic,
		PostList: f.posts,
	}
	return nil
}

4.5 Service层-发布

根据TopicId新增回帖,使用发布流程控制来驱动整个流程(与查询流程类似),实现新增回帖。

idworker.IdWorker

因为回帖id生成需要保证不重复、唯一性,笔者一开始想使用时间戳,但考虑到并发情况下时间戳可能一样,于是就看了老师的代码,使用Id生成器来生成全局唯一ID。

idworker.IdWorker包中的NextId()方法是线程安全的,它内部使用了锁来保护共享数据,以确保在多线程环境中能够正确地生成唯一的ID。NextId()方法使用了Snowflake算法来生成全局唯一的长整形ID。该算法使用时间戳作为一部分来生成ID,但是它还使用了其他信息来确保生成的ID是全局唯一的。例如,它还使用了机器ID和序列号来确保即使在同一时间戳下,也能够生成不同的ID。

代码实现

在service层新建publishPost.go文件

package service

import (
	"articlePost.cn/repository"
	"errors"
	idworker "github.com/gitstliu/go-id-worker"
	"time"
	"unicode/utf16"
)

// 发布帖子流程控制
type PublishPostFlow struct {
	content string
	topicId int64
	postId  int64
	post    repository.Post
}

// ID生成器
var idGen *idworker.IdWorker

// 初始化ID生成器
func init() {
	idGen = &idworker.IdWorker{}
	idGen.InitIdWorker(1, 1)
}

// 发布帖子函数
func PublishPost(topicId int64, content string) (int64, error) {
	// 创建流程实例并执行
	return NewPublishPostFlow(topicId, content).Do()
}

// 创建发布帖子流程实例
func NewPublishPostFlow(topicId int64, content string) *PublishPostFlow {
	return &PublishPostFlow{
		content: content,
		topicId: topicId,
	}
}

// 执行发布流程
func (f *PublishPostFlow) Do() (int64, error) {
	// 1. 参数校验
	if err := f.checkParam(); err != nil {
		return 0, err
	}
	// 2. 处理帖子信息
	if err := f.dealInfo(); err != nil {
		return 0, err
	}
	// 3. 返回生成的帖子ID
	return f.postId, nil
}

// 参数校验
func (f *PublishPostFlow) checkParam() error {
	if f.topicId <= 0 {
		return errors.New("topic id must be larger than 0")
	}
	if len(utf16.Encode([]rune(f.content))) >= 500 {
		return errors.New("content length must be less than 500")
	}
	return nil
}

// 处理帖子信息
func (f *PublishPostFlow) dealInfo() error {
	// 创建帖子结构体
	post := &repository.Post{
		ParentId:   f.topicId,
		Content:    f.content,
		CreateTime: time.Now().Unix(),
	}
	// 生成ID
	id, err := idGen.NextId()
	if err != nil {
		return err
	}
	post.Id = id
	// repository创建Post
	if err := repository.NewPostDaoInstance().CreatePost(post); err != nil {
		return err
	}
	// 保存返回的ID
	f.postId = post.Id
	return nil

}

4.6 Controller层-查询

Controller层将调用Service层来处理Http请求,并封装响应体返回。

主要步骤是

  1. 解析请求参数
  2. 调用Service查询
  3. 封装响应结果

controller/pageInfo.go

新建controller/pageInfo.go文件

package controller

import (
	"articlePost.cn/service"
	"strconv"
)

// 全局Response结构信息
type PageData struct {
	Code int64       `json:"code"` // 状态码
	Msg  string      `json:"msg"`  // 返回信息
	Data interface{} `json:"data"` // 返回数据
}

// 处理查询页面请求
func QueryPageInfo(topicIdStr string) *PageData {
	// 1.参数解析
	topicId, err := strconv.ParseInt(topicIdStr, 10, 64)
	// 解析错误返回
	if err != nil {
		return &PageData{
			Code: -1,
			Msg:  err.Error(),
		}
	}
	// 2.调用Service层查询
	pageInfo, err := service.QueryPageInfo(topicId)
	// 解析错误返回
	if err != nil {
		return &PageData{
			Code: -1,
			Msg:  err.Error(),
		}
	}
	// 3. 封装Response结果
	return &PageData{
		Code: 0,
		Msg:  "success",
		Data: pageInfo,
	}

}

4.7 Controller层-发布

新建controller/post.go文件

package controller

import (
	"articlePost.cn/service"
	"strconv"
)

// 根据Topic id 新增回帖
func CreatePost(topicIdStr string, content string) *PageData {
	// 1.参数解析
	topicId, _ := strconv.ParseInt(topicIdStr, 10, 64)
	// 2.调用Service层查询
	postId, err := service.PublishPost(topicId, content)
	// 解析错误返回
	if err != nil {
		return &PageData{
			Code: -1,
			Msg:  err.Error(),
		}
	}
	// 3. 封装Response结果
	return &PageData{
		Code: 0,
		Msg:  "success",
		Data: map[string]int64{
			"post_id": postId,
		},
	}
}

4.8 Router路由

  • 通过构建路由,来调用Controller层,并返回结果。
  • 初始化元索引

main.go文件中修改

package main

import (
	"articlePost.cn/controller"
	"articlePost.cn/repository"
	"github.com/gin-gonic/gin"
	"net/http"
	"os"
)

func main() {
	// 1. 初始化元索引
	err := repository.Init("./data/")
	if err != nil {
		os.Exit(-1)
	}
	// 2. 创建路由
	router := gin.Default()
	// 3. 处理查询页面请求
	router.GET("/page/get/:id", func(c *gin.Context) {
		// 获取参数
		topicId := c.Param("id")
		// 调用Controller层处理请求
		data := controller.QueryPageInfo(topicId)
		// 返回响应
		c.JSON(http.StatusOK, data)
	})
	// 4. 处理发布帖子请求
	router.POST("/page/post/do", func(c *gin.Context) {
		// 获取参数
		topicId, _ := c.GetPostForm("topic_id")
		content, _ := c.GetPostForm("content")
		// 调用Controller层处理请求
		data := controller.CreatePost(topicId, content)
		// 返回响应
		c.JSON(200, data)
	})
	router.Run(":8080")

}

4.9 运行测试

  1. go run main.go 运行项目

  2. 在Postman中查询话题和帖子

    http://localhost:8080/page/get/1

    Screen Shot 2023-08-01 at 9.15.30 PM.png

    Screen Shot 2023-08-01 at 9.17.25 PM.png

  3. 发送帖子

    http://localhost:8080/page/post/do

    Screen Shot 2023-08-01 at 9.20.12 PM.png

  4. 再次查询

    http://localhost:8080/page/get/1

    Screen Shot 2023-08-01 at 9.21.24 PM.png

五、总结

通过这个项目可以全面了解一个Golang Web服务项目的设计思路,包括需求分析、框架搭建、分层设计、业务组织、并发处理、ID生成等知识点。可以掌握Golang编程的一般模式,为后续工程项目的学习奠定基础。