背景由来
青训营话题页。
需求分析
- 实现一个展示话题和回帖列表的后端HTTP接口。
- 话题和回帖数据使用本地文件存储数据。
- 支持对话题发布回帖。
- 回帖id生成保证不重复、唯一性。
- 新加回帖追加到本地文件,同时更新索引,并且注意Map并发安全问题。
实体模型
用户可以发布话题、可以对话题进行回帖、也可以浏览话题和回帖列表。因此我们可以抽象得到用户User可以操作的两个实体Topic和PostList。
Topic和Post的结构和它们两者之间的关系可以用E-R图来表示。
erDiagram
Topic ||--o{ Post : contains
Topic {
int id
string title
string content
int create_time
}
Post {
int id
int topic_id
string content
int create_time
}
其中,回帖列表和话题通过TopicId相关联,它们之间的关系为一个话题可包含多个回帖,并且任意一个回帖必须包含在一个话题之中,也就是TopicId不能为空值。
于是,我们可以定义出Topic和Post两个结构体,并且指定结构体字段的Tag为json,使结构体和json的字段名相匹配。
type Topic struct {
Id int64 `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
CreateTime int64 `json:"create_time"`
}
// Post 回帖和话题通过TopicId相关联,关系为一对多。
type Post struct {
Id int64 `json:"id"`
TopicId int64 `json:"topic_id"`
Content string `json:"content"`
CreateTime int64 `json:"create_time"`
}
分层结构
File本地文件Data
使用Topic和Post两个本地文件存储数据,并且采用json的数据形式逐行存储。
Topic:
{"id":1,"title":"青训营来啦!","content":"小姐姐,快到碗里来~","create_time":1650437625}
{"id":2,"title":"青训营来啦!","content":"小哥哥,快到碗里来~","create_time":1650437640}
Post:
{"id":1,"parent_id":1,"content":"小姐姐快来1","create_time":1650437616}
{"id":2,"parent_id":1,"content":"小姐姐快来2","create_time":1650437617}
{"id":3,"parent_id":1,"content":"小姐姐快来3","create_time":1650437618}
Repository数据层
数据层的主要功能是从Data本地文件中读取数据,为其建立查询索引index,并实现通过id查询相应对象的方法,从而提供反序列化后的结构体Model给Service层做进一步的封装。
Service逻辑层
接受Repository传递的结构体,对其中的数据进行校验,清洗掉不符合的数据,然后从结构体中获取待封装的数据,最后将这些数据组装成一个实体Entity,提供给Controller层进行外部视图的交互,比如展示页面等操作。
Controller视图层
通过接受Service层传输等实体,构建页面View对象,其中包含业务状态码,提供前端的调用接口。
实现过程
展示话题和回帖列表
就是要实现根据话题的id查询话题和该话题下的回帖列表数据的功能。
Repository
为Data建立索引Map
- TopicIndexMap
对于话题,我们将以id为索引建立topicIndexMap,其中KV对的结构是[topic.id]: [*struct Topic],并且为了避免拷贝的开销我们仅存储结构体的引用。
我们可定义:
var topicIndexMap map[int64]*Topic
接下来我们定义一个函数,它通过接受的文件路径打开Data中的Topic文件,并且逐行读入其中的数据,将其反序列化为结构体,再将这些结构体索引化,最终生成topicIndexMap。
func initTopicIndex(filepath string) error
首先打开Data文件,并将其中的数据转化为字节流,放入bufio.Scanner中。
open, err := os.Open(filepath + "Topic")
if err != nil {
return err
}
scanner := bufio.NewScanner(open)
然后逐行读取,将读取的字节流反序列化为结构体,然后按照结构体的Id存放至map中。
topicTmpIndex := make(map[int64]*Topic)
for scanner.Scan() {
text := scanner.Text()
var topic Topic
err = json.Unmarshal([]byte(text), &topic)
if err != nil {
return err
}
topicTmpIndex[topic.Id] = &topic
}
topicIndexMap = topicTmpIndex
- PostIndexMap
和建立topicIndexMap类似的方法建立postIndexMap,不过需要使用post.topic_id作为索引,且存储的是post引用的切片。
因此,我们需要先为post切片创建对应的索引,再把包含相同topic_id的post加入到相同索引下的切片中。
func initPostIndex(filepath string) error {
open, err := os.Open(filepath + "Post")
if err != nil {
return err
}
scanner := bufio.NewScanner(open)
postTmpIndex := 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 := postTmpIndex[post.TopicId]
if !ok {
postTmpIndex[post.TopicId] = []*Post{&post}
continue
}
posts = append(posts, &post)
postTmpIndex[post.TopicId] = posts
}
postIndexMap = postTmpIndex
return nil
}
最后定义一个初始化函数用于初始化数据索引,并将其导出,提供给外部使用。
func Init(filepath string) error {
err := initTopicIndex(filepath)
if err != nil {
return err
}
err = initPostIndex(filepath)
if err != nil {
return err
}
return nil
}
通过索引查询数据
我们分别为通过id查询话题和通过id查询话题下的回帖列表定义函数。
func QueryTopicById(id int64) *Topic {
return topicIndexMap[id]
}
func QueryPostListByTopicId(topicId int64) []*Post {
return postIndexMap[topicId]
}
现在我们可以在Service层中通过调用这两个函数和传入id获取对应的数据。
Service
我们将Topic和Post组装成实体PageInfo,并且按照以下流程执行。
graph LR
参数校验 --> 准备数据 --> 组装实体
我们最终要向Controller层传入PageInfo实体,其结构定义为:
type PageInfo struct {
Topic *repository.Topic
Posts []*repository.Post
}
我们定义一个函数,通过topicId查询话题和回帖,并将它们按流程组装为PageInfo实体。
var pageInfo *PageInfo
func QueryPageInfo(topicId int64) (*PageInfo, error) {
pageInfo = &PageInfo{}
//check
if topicId <= 0 {
return nil, errors.New("topicId is smaller than 0")
}
//prepare
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
topic := repository.QueryTopicById(topicId)
pageInfo.Topic = topic
}()
go func() {
defer wg.Done()
posts := repository.QueryPostListByTopicId(topicId)
pageInfo.Posts = posts
}()
wg.Wait()
//pack
return pageInfo, nil
}
其中,我们使用sync.WaitGroup并行查询话题和回帖列表,提高并发性和并发安全性。
Controller
最后,我们将PageInfo实体封装成PageData对象,其中包含业务状态码,为前端提供信息。
其结构体定义以及封装函数如下:
type PageData struct {
Code int64 `json:"code"`
Msg string `json:"msg"`
Data interface{} `json:"data"`
}
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}
}
建立服务
最终,我们初始化数据索引,通过gin开放HTTP服务,通过GET请求获取对应id的话题和回帖列表。
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 := controller.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
}
测试
我们使用curl工具,向本地服务发送GET请求,并通过url传输所查询的id,查看其返回结果。
curl --location --request GET 'http://0.0.0.0:8080/community/page/get/2'|jq
查询结果符合预期,实现成功。