Go工程青训营话题页实现 | 青训营

129 阅读5分钟

背景由来

青训营话题页。

需求分析

  1. 实现一个展示话题和回帖列表的后端HTTP接口。
  2. 话题和回帖数据使用本地文件存储数据。
  3. 支持对话题发布回帖。
  4. 回帖id生成保证不重复、唯一性。
  5. 新加回帖追加到本地文件,同时更新索引,并且注意Map并发安全问题。

实体模型

用户可以发布话题、可以对话题进行回帖、也可以浏览话题和回帖列表。因此我们可以抽象得到用户User可以操作的两个实体TopicPostList
TopicPost的结构和它们两者之间的关系可以用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不能为空值。

于是,我们可以定义出TopicPost两个结构体,并且指定结构体字段的Tagjson,使结构体和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"`
}

分层结构

image.png

File本地文件Data

使用TopicPost两个本地文件存储数据,并且采用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查询相应对象的方法,从而提供反序列化后的结构体ModelService层做进一步的封装。

Service逻辑层

接受Repository传递的结构体,对其中的数据进行校验,清洗掉不符合的数据,然后从结构体中获取待封装的数据,最后将这些数据组装成一个实体Entity,提供给Controller层进行外部视图的交互,比如展示页面等操作。

Controller视图层

通过接受Service层传输等实体,构建页面View对象,其中包含业务状态码,提供前端的调用接口。

实现过程

展示话题和回帖列表

就是要实现根据话题的id查询话题和该话题下的回帖列表数据的功能。

Repository

为Data建立索引Map

  1. 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
  1. PostIndexMap

和建立topicIndexMap类似的方法建立postIndexMap,不过需要使用post.topic_id作为索引,且存储的是post引用的切片。
因此,我们需要先为post切片创建对应的索引,再把包含相同topic_idpost加入到相同索引下的切片中。

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

我们将TopicPost组装成实体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

image.png 查询结果符合预期,实现成功。