GO语言工程实践作业笔记 | 青训营

65 阅读11分钟

环境配置

Windows 11 Golang 1.20 GoLand 2023

课后实践内容

  1. 支持对话题发布回帖。
  2. 回帖id生成需要保证不重复、唯一性。
  3. 新加回帖追加到本地文件,同时需要更新索引,注意Map的并发安全问题

分析

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

我们需要实现一个社区话题页面包含展示话题(标题,文字描述)和回帖列表,需要实现涉及服务端交互的两个功能———查看话题和回帖。

需求用例

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

image.png

ER图

image.png

分层结构

image.png

需求中,每一个topic对应着多个post,post中存有topicid来表示这个post属于哪个topic。有了模型实体,属性以及之间的联系,对我们后续做开发就提供了比较清晰的思路。有了实体模型,下一步就是思考代码。

项目编写

项目使用Gin高性能go web框架开发,使用Go Mod进行依赖管理

项目整体分为三层,model数据层、service逻辑层和controler视图层。 三层的功能分别是:

  • model数据层只关联底层数据模型,数据层面向逻辑层,对service层透明,屏蔽下游数据差异。
  • Servcie逻辑层处理核心业务逻辑,计算打包业务实体entiy,对应我们的需求,就是话题页面,包括话题和回帖列表,并上送给视图层。
  • Cortroller视图层负责处理和外部的交互逻辑,以view视图的形式返回给客户端,对于我们需求,我们封装json格式化的请求结果,api形式访问就好,

具体步骤

分析需求,一共有两个主要的对象,一个是贴子,就是用户发表的文章,一个是用户在其他用户下面发的评论,也就是话题和回帖,从前端页面上来说的话应该是我们点击一个文章,传入后端一个文章的id,然后后端通过文章id查询到对应的文章对象,然后再通过文章id查询到所有在该文章下面的评论和用户,返回前端进行显示。

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

在data目录下新建topicpost两个文件,两者文件数据如图:

image.png

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

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

根据ER图抽象出来两个实体:Topic和Post,将两个实体转为struct。在repository目录下新建topic.gopost.go

type Topic struct {
	Id         int64  `json:"Id"`
	Title      string `json:"title"`
	Content    string `json:"Content"`
	CreateTime int64  `json:"CreateTime"`
}

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

设计索引: 当从文件中读取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 读写锁。

初始化元索引: 打开文件;解析JSON为Topic结构体;构建索引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
}

为Service层提供repository的查询功能

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

在设计模式上,处于简便,使用单例Dao模式,它可以提供一个线程安全的、易于管理的全局Dao对象,使其可以被高效共享。

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/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
}

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

根据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个协程完成。
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
}

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

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

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

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

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

}

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

Controller层将调用Service层来处理Http请求,并封装响应体返回。主要步骤是:解析请求参数,调用Service查询,封装响应结果。

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,
	}

}

Controller层处理回帖发布请求

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,
		},
	}
}

进行Router的路由设置

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

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")

}

运行测试

运行项目,使用apifox或者postman来进行测试(这里使用postman):

image.png

image.png

总结

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