社区话题页面的Web本地服务
分析需求后可以得到结论:用户与两个实体,即Topic和PostList发生交互。
而Topic和Post的ER图如下,两者为1:n的关系。
项目采用分层架构,具体如下:
Repository:
- 数据层负责处理与数据存储相关的操作,以及数据库的增删查改;
- 封装了对数据库或其他数据存储的访问,隐藏了数据存储的具体实现细节,为上层提供了统一的数据访问接口;
- 数据层通常包含数据访问对象、数据模型等,用于封装数据操作和数据结构
Service:
- 处理核心业务逻辑输出;
- 依赖数据层的数据,进行逻辑处理和计算,结果返回给视图层;
- 与具体的数据存储无关只关注业务逻辑处理。
Handler:
- 程序与用户交互的接口,负责接收请求和发送响应;
- 包含控制器,接收用户的输入,转发给逻辑层进行处理,再将结果进行处理并返还给用户。
分层解析功能的实现原理
topic.go和post.go:
-
定义
TopicDao和PostDao两个结构体,用于添加对数据库的操作方法实现对对应数据的增删查改,该项目使用文件存储数据,因此结构体设置为空; -
全局变量
postDao,topicDao和sync.Once类型的变量postOnce。postDao和topicDao用于存储PostDao和TopicDao的实例,而postOnce则用于保证postDao和topicDao只会被初始化一次;
var postDao *PostDao
var postOnce sync.Once
func NewPostDaoInstance() *PostDao {
postOnce.Do(
func() {
postDao = &PostDao{}
})
return postDao
}
func (*PostDao) QueryPostById(id int64) (*Post, error) {
var post Post
err := db.Where("id = ?", id).Find(&post).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
util.Logger.Error("find post by id err:" + err.Error())
return nil, err
}
return &post, nil
}
Once 是 Go 内置库 sync 中一个比较简单的并发原语,可以用于单例模式。常规的单例模式实现方法是加锁,但每次都会进行加锁、解锁操作,对性能的消耗比较大,而 Once 可以解决这个问题。Once 的使用很简单,只有一个对外暴露 Do(f func()) 方法,参数是函数。Do 函数只要被调用过一次,之后无论怎么调用,参数怎么变化,都不会生效。这里需要注意的是,Do(f)中的参数f是一个无参无返回值的函数。如果需要传入有参数的f,则可以将参数改成全局变量,或者用闭包实现 once.Do():
func closureF(x int ) func() {
return func() {
fmt.Println(x)
}
}
func main() {
var once sync.Once
x := 4
once.Do(closureF(x))
}
Service层:
查询帖子
Service层的主要任务是:依据业务核心逻辑对Repository层数据进行处理与计算,结果返回给Handler层。这里我们根据传入的topicId,返回一个页面的完整信息,包括TopicInfo(包括Topic,User)和一个PostInfo列表(包括Post,User)。PageInfo 结构体,用于表示页面信息,其中包括一个 Topic 字段和一个 PostList 字段。Topic 表示话题信息,类型为 *repository.Topic,而 PostList 表示帖子列表,类型为 []*repository.Post,即存储指定话题及其帖子的结构。
之后,我们通过查询流程控制来驱动整个流程,查询流程控制对象(Query Flow)是一种常见的Service层处理方式,其主要优点有:
- 将一个查询功能的不同步骤模块化,提高内聚性。
- 方便参数校验、业务处理、结果组装的逻辑分离。
- 可以引入中间状态对象,而不是直接返回结果。
- 易于日志记录和调试。
- 流程控制对象可以重用。
- 查询代码更易读和理解。
之后定义了NewQueryPageInfoFlow函数,用于初始化查询流程;定义了Do方法,用于执行查询流程的各个步骤,并处理错误。在Do方法中依次执行三个函数完成核心业务逻辑:
checkParam方法,用于检查参数是否合法,如果topicId小于等于0,返回错误prepareInfo方法,用于准备信息,包括获取主题和回帖列表。这里使用了并发和等待组来提高效率。它启动两个协程(goroutine)并使用sync.WaitGroup等待两个协程完成。一个goroutine用于通过调用数据层函数QueryTopicById(f.topicId)获取话题信息,并将结果存储在f.topic字段中;另一个goroutine用于通过数据层函数QueryPostsByParentId(f.topicId)获取帖子列表,并将结果存储在f.posts字段中。结束处的wg.Wait()等待两个协程完成,用于保证程序同步一致。此外,我们需要在PostList中存储完整的User信息,Post结构体中只有userId,因此使用MQueryUserById方法批量查询并将其存储于map结构体中(map[int64]*User)。packPageInfo()方法将查询到的topic和posts打包成PageInfo结构体,并存储在f.pageInfo字段中。这个过程中用到了之前prepareInfo方法中构建的userMap结构体。
type PageInfo struct {
TopicInfo *TopicInfo
PostList []*PostInfo
}
func QueryPageInfo(topicId int64) (*PageInfo, error) {
return NewQueryPageInfoFlow(topicId).Do()
}
func NewQueryPageInfoFlow(topId int64) *QueryPageInfoFlow {
return &QueryPageInfoFlow{
topicId: topId,
}
}
type QueryPageInfoFlow struct {
topicId int64
pageInfo *PageInfo
topic *repository.Topic
posts []*repository.Post
userMap map[int64]*repository.User
}
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 (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
go func() {
defer wg.Done()
topic, err := repository.NewTopicDaoInstance().QueryTopicById(f.topicId)
if err != nil {
topicErr = err
return
}
f.topic = topic
}()
//获取post列表
go func() {
defer wg.Done()
posts, err := repository.NewPostDaoInstance().QueryPostByParentId(f.topicId)
if err != nil {
postErr = err
return
}
f.posts = posts
}()
wg.Wait()
if topicErr != nil {
return topicErr
}
if postErr != nil {
return postErr
}
//获取用户信息
uids := []int64{f.topic.Id}
for _, post := range f.posts {
uids = append(uids, post.Id)
}
userMap, err := repository.NewUserDaoInstance().MQueryUserById(uids)
if err != nil {
return err
}
f.userMap = userMap
return nil
}
func (f *QueryPageInfoFlow) packPageInfo() error {
//topic info
userMap := f.userMap
topicUser, ok := userMap[f.topic.UserId]
if !ok {
return errors.New("has no topic user info")
}
//post list
postList := make([]*PostInfo, 0)
for _, post := range f.posts {
postUser, ok := userMap[post.UserId]
if !ok {
return errors.New("has no post user info for " + fmt.Sprint(post.UserId))
}
postList = append(postList, &PostInfo{
Post: post,
User: postUser,
})
}
f.pageInfo = &PageInfo{
TopicInfo: &TopicInfo{
Topic: f.topic,
User: topicUser,
},
PostList: postList,
}
return nil
}
发布帖子
与查询帖子相同,这里也定义了一个PublishPostFlow结构体,包含userId,topicId,content,postId。前三者通过Handler层传入,而postId由于我们在数据库中存储Post文件,因此只需要在创建post表时将id一列标注为AUTO_INCREMENT即可。若需要在不引入数据库的状态下实现,idworker.IdWorker包中的NextId()方法是线程安全的,它内部使用了锁来保护共享数据,以确保在多线程环境中能够正确地生成唯一的ID。NextId()方法使用了Snowflake算法来生成全局唯一的长整形ID。该算法使用时间戳作为一部分来生成ID,但是它还使用了其他信息来确保生成的ID是全局唯一的。
这里同样也有一个checkParam方法,检查userId是否小于等于零,以及内容是否大于500字。这里使用到了utf8.RuneCountInString函数,在检测中文字符数时可以考虑使用这个函数:Go 语言的内建函数 len(),可以用来获取切片、字符串、通道(channel)等的长度。其中中文等特殊字符一般占3个字节(不同于Java的2)。
而我们如果希望按习惯上的字符个数来计算,就需要使用 Go 语言中 UTF-8 包提供的 RuneCountInString() 函数,统计 Uncode 字符数量。
func (f *PublishPostFlow) checkParam() error {
if f.userId <= 0 {
return errors.New("userId id must be larger than 0")
}
if utf8.RuneCountInString(f.content) >= 500 {
return errors.New("content length must be less than 500")
}
return nil
}
Handler层
-
定义了一个名为
PageData的结构体,用于表示返回给客户端的页面数据。该结构体包含三个字段:Code表示返回码,Msg表示返回消息,Data表示返回的数据; -
定义了一个名为
QueryPageInfo的函数,用于处理查询页面信息的请求。它接收一个名为topicIdStr的字符串参数,该参数表示用户请求中的话题ID,将ID转换为int类型后作为参数调用service.QueryPageInfo(topicId)函数来查询页面信息; -
service.QueryPageInfo(topicId)会调用相应的逻辑层QueryPageInfo方法,获取页面信息,并返回查询结果的PageInfo数据给用户。
Server.go文件
该文件构建了Gin路由,调用Handler层完成相应的查询、发布功能,并通过JSON格式返回相应结果。
r := gin.Default()
r.Use(gin.Logger())
r.GET("/ping", func(c *gin.Context) {
c.JSON(200, gin.H{
"message": "pong",
})
})
r.GET("/community/page/get/:id", func(c *gin.Context) {
topicId := c.Param("id")
data := handler.QueryPageInfo(topicId)
c.JSON(200, data)
})
r.POST("/community/post/do", func(c *gin.Context) {
uid, _ := c.GetPostForm("uid")
topicId, _ := c.GetPostForm("topic_id")
content, _ := c.GetPostForm("content")
data := handler.PublishPost(uid, topicId, content)
c.JSON(200, data)
})
err := r.Run()
使用Postman测试,查询对应的话题、Post列表,返回结果正确。
使用Jame身份发布帖子(userId为2):