1 项目描述
项目来源:后端基础班《Go 语言进阶 - 工程进阶》课程项目。主讲老师已经把搭建好的框架发布到Github:github.com/Moonlight-Z… 。完成后的完整代码在我的Gitee仓库:gitee.com/robbinyang/… 。
项目功能:
- 实现一个展示话题(标题,文字描述)和回帖列表的单个页面;
- 暂不考虑前端页面,仅仅实现一个后端http接口
- 话题和回帖数据用本地文件存储
课后作业要求:
- 增加发布帖子的功能
- 本地存储的topic id, post id需要保证唯一性
- 新发布的帖子需要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 代码编写
根据上课讲的例子,用户发布一条帖子时候服务端架构里的调用顺序是:
- 客户端发送一个POST请求给Gin服务器,根据路由设置Gin将这个请求传递给controller层的PublishController
- PublishController从用户请求中提取出有用的参数,传递给service层的PublishService进行业务处理
- PublishService利用提供的参数产生一个Post类型的实例,作为要创建的Post记录,然后调用repository层的PostDao的方法执行Post记录插入
- PostDao调用内部封装的数据持久化操作将Post记录存储到数据库等中,然后通知上一层的PublishService操作成功
- PublishService再通知上一层的PublishController成功插入新的Post
- 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对象。ParentId和Content这两个键直接用的是controller给的参数,而CreateTime则是直接由当前的时间生成。time包的Now函数返回一个表示当前时间的time.Time对象,加上Unix方法之后它就变成从1970年1月1日(GMT)开始经过的秒数。当Post对象赋值完毕,我们就把它传给repository层的PostDao类(这里因为repository的PostDao使用了单例模式,所以我们需要调用repository.NewPostDaoInstance才能访问到PostDao的单例)。
3.3 repository
在repository层的修改主要分为3个:
- 创建了一个
SafePostMap类型,用来提供并发安全的帖子索引访问。并且将db_init.go里面用来保存帖子索引的全局变量postIndexMap声明成SafePostMap类型 - 修改了
PostDao类型的QueryPostsByParentId方法 - 在
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的三层架构上实现发布帖子的功能。其中还对内置的帖子索引进行重构,利用读写锁确保并发访问帖子索引的安全性。还对发布帖子的接口做了测试,表明修改后的程序功能能够满足更改的需求。