Go语言进阶课后作业: 实现一个青训营话题页HTTP接口 | 青训营

67 阅读9分钟

1 项目描述

项目来源:后端基础班《Go 语言进阶 - 工程进阶》课程项目。主讲老师已经把搭建好的框架发布到Github:github.com/Moonlight-Z… 。完成后的完整代码在我的Gitee仓库:gitee.com/robbinyang/…

项目功能:

  1. 实现一个展示话题(标题,文字描述)和回帖列表的单个页面;
  2. 暂不考虑前端页面,仅仅实现一个后端http接口
  3. 话题和回帖数据用本地文件存储

课后作业要求:

  1. 增加发布帖子的功能
  2. 本地存储的topic id, post id需要保证唯一性
  3. 新发布的帖子需要append到文件里面,更新索引,并且要注意Map的并发安全问题

2 项目设计

2.1 项目接口定义

2.1.1 GET /community/page/get/:id - 获取一个话题页和所有回复的帖子信息(原始框架已经实现)

syntax = "proto2"
package community.core

message topic_page_query_request { //查询话题页的完整信息
  required int64 topic_id = 1; //这个字段就是嵌入在URL里面的"id"
}

message topic_page_query_result {
  required int32 code = 1; //请求的状态码,0-成功,其他值-失败
  optional string msg = 2; // 返回状态的描述
  required PageInfo data = 3; //话题页信息和所有帖子的信息
}

message PageInfo {
  required Topic topic = 1;
  repeated Post post_list = 2; //0个或多个关联的帖子
}

message Topic {
  required int64 id = 1;
  required string title = 2; //话题页标题
  required string content = 3; // 话题页内容
  required int64 create_time = 4; // 话题页发布的时间,Unix时间戳,精确到秒
}

message Post {
  required int64 id = 1;
  required int64 parent_id = 2; //帖子所属的父对象(即话题)的id
  required string content = 3; //帖子的内容
  required int64 create_time = 4; // 帖子发布的时间,Unix时间戳,精确到秒
}

2.1.2 POST /community/post/publish/ - 发布一则帖子

默认所有request的参数都是以form-data或x-www-form-urlencoded的格式编码在请求体里面。

syntax = "proto2"
package community.core

message publish_post_request {
  required int64 parent_id = 1; //帖子所属的父对象(即话题)的id
  required string content = 2; //帖子的内容
}

message publish_post_response {
  required int32 code = 1; //请求的状态码,0-成功,其他值-失败
  optional string msg = 2; // 返回状态的描述
  required Post post = 3; //成功发布的帖子信息,当未成功发布时这个字段应该设为null
}

message Post {
  required int64 id = 1;
  required int64 parent_id = 2; //帖子所属的父对象(即话题)的id
  required string content = 3; //帖子的内容
  required int64 create_time = 4; // 帖子发布的时间,Unix时间戳,精确到秒
}

2.2 数据层设计

我们主要识别出了两个实体:话题页TOPIC和评论帖POST,其中话题页对评论帖的关系是一对多(0个或更多),而评论帖对话题页是唯一从属的关系。在数据库设计里面,我们很自然地想到可以在POST表里面设计一个外部键,指向POST从属的TOPIC表的id键。我们将这个外部键命名为topic_id。于是数据层的关系图就完成了:

erDiagram 

TOPIC {int id string title string content datetime create_time}
POST {int id int topic_id string content datetime create_time}
TOPIC ||--o{ POST : contains

3 代码编写

根据上课讲的例子,用户发布一条帖子时候服务端架构里的调用顺序是:

  1. 客户端发送一个POST请求给Gin服务器,根据路由设置Gin将这个请求传递给controller层的PublishController
  2. PublishController从用户请求中提取出有用的参数,传递给service层的PublishService进行业务处理
  3. PublishService利用提供的参数产生一个Post类型的实例,作为要创建的Post记录,然后调用repository层的PostDao的方法执行Post记录插入
  4. PostDao调用内部封装的数据持久化操作将Post记录存储到数据库等中,然后通知上一层的PublishService操作成功
  5. PublishService再通知上一层的PublishController成功插入新的Post
  6. PublishController接收到创建成功的消息,将成功的状态和创建的Post对象封装到JSON响应体里面返回给客户端

下面我就按照controller-service-repository的顺序展示一下需要增加的代码.

3.1 controller

我们首先创建一个新路由"/community/post/publish/"并绑定到这个controller包的PUblish方法上面。

打开根目录下的server.go,对main函数做以下修改:

import (
    //"github.com/Moonlight-Zhao/go-project-example/cotroller"
    //"github.com/Moonlight-Zhao/go-project-example/repository"
    //改为我自己的gitee路径
    controller "gitee.com/robbinyang/youth-camp/controller"
    "gitee.com/robbinyang/youth-camp/repository"
)

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 := cotroller.QueryPageInfo(topicId)
        data := controller.QueryPageInfo(topicId)
        c.JSON(200, data)
    })
    //加入发布帖子的路由
    r.POST("/community/post/publish", controller.Publish)
    err := r.Run()
    if err != nil {
        return
    }
}

接下来修改controller层。在controller文件夹下面创建一个新的名为publish_post_controller.go的文件并写入以下内容。

package controller

import (
	"net/http"
	"strconv"
	"gitee.com/robbinyang/youth-camp/repository"
	"gitee.com/robbinyang/youth-camp/service"
	"gopkg.in/gin-gonic/gin.v1"
)

type PublishPostResponse struct {
	Code int32            `json:"code"`          //请求的状态码,0-成功,其他值-失败
	Msg  string           `json:"msg,omitempty"` // 返回状态的描述
	Post *repository.Post `json:"post"`          //成功发布的帖子信息,当未成功发布时这个字段应该设为null
}

func Publish(c *gin.Context) {
	parentId := c.PostForm("parent_id")
	content := c.PostForm("content")

	// 如果解析topic_id参数时出现问题
	topicId, err := strconv.ParseInt(parentId, 10, 64)
	if err != nil {
		c.JSON(http.StatusBadRequest, &PublishPostResponse{
			Code: 1,
			Msg:  err.Error(),
			Post: nil,
		})
		return
	}

	//如果用户所给的帖子内容为空
	if content == "" {
		c.JSON(http.StatusBadRequest, &PublishPostResponse{
			Code: 1,
			Msg:  "Error: Post content is empty",
			Post: nil,
		})
		return
	}

	//其他情况下,将参数传给service层进一步处理
	publishedPost, err := service.PublishPost(topicId, content)
	if err != nil {
		c.JSON(http.StatusInternalServerError, &PublishPostResponse{
			Code: 1,
			Msg:  err.Error(),
			Post: nil,
		})
		return
	} else {
		c.JSON(http.StatusOK, &PublishPostResponse{
			Code: 0,
			Msg:  "",
			Post: publishedPost,
		})
	}
}

从第17行到第58行全部都是Publish这个controller的定义。这个controller内部逻辑是先检查parent_id是否能正确解析为int64,再检查用户发表的帖子内容是否为空,最后当两者都正常时它调用service层的逻辑进行真正的业务。

3.2 service

service文件夹下面创建publish_post_service.go并写入以下内容。

package service

import (
	"time"

	"gitee.com/robbinyang/youth-camp/repository"
)

func PublishPost(topicId int64, postContent string) (publishedPost *repository.Post, err error) {
	p := &repository.Post{
		ParentId:   topicId,
		Content:    postContent,
		CreateTime: time.Now().Unix(),
	}
	publishedPost, err = repository.NewPostDaoInstance().AddPost(p)
	if err != nil {
		publishedPost = nil
	}
	return
}

在service层,我们根据controller层传入的参数创建了一个Post对象。ParentIdContent这两个键直接用的是controller给的参数,而CreateTime则是直接由当前的时间生成。time包的Now函数返回一个表示当前时间的time.Time对象,加上Unix方法之后它就变成从1970年1月1日(GMT)开始经过的秒数。当Post对象赋值完毕,我们就把它传给repository层的PostDao类(这里因为repository的PostDao使用了单例模式,所以我们需要调用repository.NewPostDaoInstance才能访问到PostDao的单例)。

3.3 repository

在repository层的修改主要分为3个:

  1. 创建了一个SafePostMap类型,用来提供并发安全的帖子索引访问。并且将db_init.go里面用来保存帖子索引的全局变量postIndexMap声明成SafePostMap类型
  2. 修改了PostDao类型的QueryPostsByParentId方法
  3. PostDao类型中加入AddPost方法,也就是用来被service层调用的方法

repository/safe_map.go:

package repository

import "sync"

type SafePostMap struct {
	sync.RWMutex
	postIndex map[int64][]*Post
	highestId int64
}

func NewSafePostMap(rawMap map[int64][]*Post) *SafePostMap {
	maxId := int64(-1)
	for _, postList := range rawMap {
		for _, post := range postList {
			if post.Id > maxId {
				maxId = post.Id
			}
		}
	}
	return &SafePostMap{
		postIndex: rawMap,
		highestId: maxId,
	}
}

func (sp *SafePostMap) GenNextId() int64 { //产生一个不重复的post id
	sp.Lock()
	id := sp.highestId + 1
	sp.highestId = id
	sp.Unlock()
	return id
}

func (sp *SafePostMap) AppendPost(key int64, value *Post) {
	sp.Lock()
	sp.postIndex[key] = append(sp.postIndex[key], value)
	sp.Unlock()
}

func (sp *SafePostMap) ReadPostList(key int64) []*Post {
	sp.RLock()
	posts := make([]*Post, len(sp.postIndex[key]))
	copy(posts, sp.postIndex[key])
	sp.RUnlock()
	return posts
}

SafePostMap类型主要是为了封装一个map[int64][]*Post的对象,也就是我们在内存中为所有发布的帖子创建的索引。值得注意的一点是对SafePostMap内部封装的帖子索引访问的时候必须要调用这个类的公共方法。这些方法中都包含了读写锁的逻辑,为并发写入索引提供了安全性。

repository/db_init.go:

var(
    // 定义postIndexMap为并发安全的类型
    // postIndexMap   map[int64][]*Post
    postIndexMap   SafePostMap
)

repository/post.go:

//前面没有更改,省略
func (*PostDao) QueryPostsByParentId(parentId int64) []*Post {
	return postIndexMap.ReadPostList(parentId)
}

func (*PostDao) AddPost(p *Post) (*Post, error) {
	var err error = nil
	// 检查topic是否存在
	parentTopic := NewTopicDaoInstance().QueryTopicById(p.ParentId)
	if (*parentTopic == Topic{}) {
		err = fmt.Errorf("Error: parent topic with id %d does not exist!", p.ParentId)
		return nil, err
	}

	newPost := &Post{
		Id:         postIndexMap.GenNextId(),
		ParentId:   parentTopic.Id,
		Content:    p.Content,
		CreateTime: p.CreateTime,
	}

	postIndexMap.AppendPost(newPost.ParentId, newPost)

	// 将Post持久化到文件
	fout, err := os.OpenFile(filepath.Join(dataFilePrefix, "post"), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
	defer fout.Close()
	if err != nil {
		return nil, fmt.Errorf("Error opening file: %#v", err)
	}
	defer fout.Close()
	marshalledPost, _ := json.Marshal(newPost)
	fmt.Println(string(marshalledPost))
	_, err = fmt.Fprintf(fout, "\n%s", string(marshalledPost))
	if err != nil {
		return nil, fmt.Errorf("Error appending to file: %#v", err)
	}
	return newPost, nil
}

这里主要讲一下AddPost方法。这个方法首先确认传入的Post对象确实对应一个存在的Topic对象(也就是NewTopicDaoInstance().QueryTopicById返回的Topic对象不应该为空)。然后创建的Post对象先被加入到内存索引postIndexMap中,后被持久化到文件记录里面。持久化到文件的时候我们利用json包将Post对象序列化成JSON字符串(json.Marshal),这样就可以方便写入到文件里。

4 测试

应用了以上更改之后,我们可以编译运行程序。

$ go build . && ./youth-camp

4.1 测试发布帖子功能

使用Postman等API测试工具向"localhost:8080/community/post/publish/"发送一个POST请求,请求体包含两个参数,parent_id=1,content="小姐姐快来6"。收到如下的响应说明帖子发布成功。

{
    "code": 0,
    "post": {
        "id": 12,
        "parent_id": 1,
        "content": "小姐姐快来6",
        "create_time": 1693317858
    }
}

4.2 测试查看话题页信息功能

使用Postman等API测试工具向"localhost:8080/community/page/get/1"发送一个GET请求。收到如下的响应:

{
    "code": 0,
    "msg": "success",
    "data": {
        "topic": {
            "id": 1,
            "title": "青训营来啦!",
            "content": "小姐姐,快到碗里来~",
            "create_time": 1650437625
        },
        "post_list": [
            {
                "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
            },
            {
                "id": 4,
                "parent_id": 1,
                "content": "小姐姐快来4",
                "create_time": 1650437619
            },
            {
                "id": 5,
                "parent_id": 1,
                "content": "小姐姐快来5",
                "create_time": 1650437620
            },
            {
                "id": 11,
                "parent_id": 1,
                "content": "小姐姐快来6",
                "create_time": 1693312642
            },
         ]
    }
}

最后一条id为11的帖子就是上一个测试中插入的,由此可确信发布帖子功能能够正常更新Post索引。

5 小结

本文以一个青训营话题页后端接口为框架,在controller-service-repository的三层架构上实现发布帖子的功能。其中还对内置的帖子索引进行重构,利用读写锁确保并发访问帖子索引的安全性。还对发布帖子的接口做了测试,表明修改后的程序功能能够满足更改的需求。