社区话题页面的Web本地服务实现 | 青训营

147 阅读5分钟

社区话题页面的Web本地服务

分析需求后可以得到结论:用户与两个实体,即Topic和PostList发生交互。

image.png 而Topic和Post的ER图如下,两者为1:n的关系。

image.png 项目采用分层架构,具体如下:

  1. Repository
  • 数据层负责处理与数据存储相关的操作,以及数据库的增删查改;
  • 封装了对数据库或其他数据存储的访问,隐藏了数据存储的具体实现细节,为上层提供了统一的数据访问接口;
  • 数据层通常包含数据访问对象、数据模型等,用于封装数据操作和数据结构
  1. Service
  • 处理核心业务逻辑输出;
  • 依赖数据层的数据,进行逻辑处理和计算,结果返回给视图层;
  • 与具体的数据存储无关只关注业务逻辑处理。
  1. Handler
  • 程序与用户交互的接口,负责接收请求和发送响应;
  • 包含控制器,接收用户的输入,转发给逻辑层进行处理,再将结果进行处理并返还给用户。

分层解析功能的实现原理

topic.go和post.go:

  • 定义TopicDaoPostDao两个结构体,用于添加对数据库的操作方法实现对对应数据的增删查改,该项目使用文件存储数据,因此结构体设置为空;

  • 全局变量 postDaotopicDaosync.Once 类型的变量 postOncepostDaotopicDao用于存储 PostDaoTopicDao 的实例,而 postOnce 则用于保证 postDaotopicDao 只会被初始化一次;

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结构体,包含userIdtopicId,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列表,返回结果正确。

image.png 使用Jame身份发布帖子(userId为2):

image.png

image.png