环境配置
Windows 11 Golang 1.20 GoLand 2023
课后实践内容:
- 支持对话题发布回帖。
- 回帖id生成需要保证不重复、唯一性。
- 新加回帖追加到本地文件,同时需要更新索引,注意Map的并发安全问题
分析
进入【第四届字节跳动青训营 - 暑假专场 Q&A】🙋...-青训营社区 (juejin.cn),我们将完成该社区话题页面的本地Web服务功能,只考虑服务端。
我们需要实现一个社区话题页面包含展示话题(标题,文字描述)和回帖列表,需要实现涉及服务端交互的两个功能———查看话题和回帖。
需求用例
用户可以浏览话题以及话题的回帖,也可以对话题进行回帖操作,因此可以抽象出两个实体Topic和PostList
ER图
分层结构
需求中,每一个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目录下新建topic和post两个文件,两者文件数据如图:
从data目录里读出Topic和Post的本地数据,并转成JSON格式存储到内存中
当启动项目时,需要将数据全部读入到内存中,(本项目数据小,可以一次性读入到内存),然后再进行后续逻辑的处理。
根据ER图抽象出来两个实体:Topic和Post,将两个实体转为struct。在repository目录下新建topic.go和post.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):
总结
通过这个项目可以全面了解一个Golang Web服务项目的设计思路,包括需求分析、框架搭建、分层设计、业务组织、并发处理、ID生成等知识点。可以掌握Golang编程的一般模式,为后续工程项目的学习奠定基础。