写在前面

话不多说,先看多级评论的最后效果:

image-20231023151853408

并且评论可以一直嵌套下去,实现了无限评论与回复。有点类似于抖音app的评论区。

仓库地址

关于多级评论demo,所有代码均放到了GitHub仓库:https://github.com/palp1tate/MultiLevelCommentDemo

有需要者可克隆使用。demo主要包括了三个功能:用户发表动态,发表评论,查看评论。

数据库设计

user表:

1
2
3
4
5
6
type User struct {
gorm.Model
Nickname string `gorm:"not null;index;varchar(20)"` // 昵称
Password string `gorm:"not null"` // 密码
Avatar string `gorm:"not null;"` // 头像
}

moment表:

1
2
3
4
5
6
type Moment struct {
gorm.Model
UserId int `gorm:"not null;index"`
User User `gorm:"foreignKey:UserId;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
Content string `gorm:"size:2048"`
}

这里给UserId设置了外键,参照UserID,并设置了级联删除与更新。

comment表:

1
2
3
4
5
6
7
8
9
10
type Comment struct {
gorm.Model
MomentId int `gorm:"not null;index"`
Moment Moment `gorm:"foreignKey:MomentId;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
UserId int `gorm:"not null;index"`
User User `gorm:"foreignKey:UserId;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
ParentId *int `gorm:"index;default:NULL"`
Parent *Comment `gorm:"foreignKey:ParentId;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
Content string `gorm:"not null;size:1024"`
}

评论表是比较关键的部分,MomentIdUserIdParentId均设置了外键与级联删除更新,这样做的目的是为了适应一些场景,比如动态被删了,那么评论也会都从表中删掉,父评论删了,那么它对应的子孙评论也会一并删除,这样处理才符合真实的业务逻辑。ParentId顾名思义就是父评论的ID,但由于初始评论没有父评论,所以默认为NULL,这里注意用*int而不用int的原因是:如果用后者,默认空值是0,那么即使创建评论数据时不传入该字段,也会当0处理,这样插入到数据库就会报错,因为ParentId是外键,而最开始的数据库不可能有ID为0的数据,所以就会发生参照外键的错误,导致插入失败,但是不设置外键就没有级联的效果,这样也不符合真实的场景。于是将该字段换位指针类型,这样默认空值为nil,插入到数据库就会显示NULL,这样就能实现插入第一条评论了,可见下图:
image-20231023154930333

关于评论还有一个结构体,用来表示评论树:

1
2
3
4
5
6
7
type CommentTree struct {
CommentId int `json:"cid"`
Content string `json:"content"`
Author map[string]interface{} `json:"author"`
CreatedAt string `json:"createdAt"`
Children []*CommentTree `json:"children"`
}

通过评论树就可以实现与子孙评论的无限嵌套。

如何使用

关于mysql连接初始化,路由配置,表单验证等均在可以在仓库找到相应的代码,这里不做赘述。

下面讲解真实的业务逻辑。

有动态才能被评论,所以我们先发布一条动态。发布动态的代码可以在仓库里找到。

image-20231023155705250

记住动态ID为2,发布评论时会用到。

下面附上发布评论的核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func AddComment(c *gin.Context) {
addCommentForm := form.AddCommentForm{}
if err := c.ShouldBind(&addCommentForm); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": err.Error(),
})
return
}
comment := model.Comment{
UserId: addCommentForm.UserId,
MomentId: addCommentForm.MomentId,
Content: addCommentForm.Content,
}
if addCommentForm.ParentId != 0 {
comment.ParentId = &addCommentForm.ParentId
}
global.DB.Create(&comment)
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": "success",
})
}

如果是第一条评论,也就是父评论,那么前端在传的时候ParentId0就好,因为它不可能有父评论。我的代码通过判断ParentId是0后,就不需要传入该字段,因为在刚才讲解数据库设计的时候,ParentId在数据库中默认为NULL

下面新建一条评论:

image-20231023160502410

为了之后的显示效果,我又多发布了几条评论。

查看评论是也是比较核心的部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func GetComments(c *gin.Context) {
var commentTrees []model.CommentTree
momentId := c.Query("mid")
if momentId == "" {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "mid不能为空",
})
return
}
mid, _ := strconv.Atoi(momentId)
commentTrees = GetMomentComment(mid)
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": "success",
"data": commentTrees,
})
}

GetMomentComment函数比较核心的函数,你只需要传入动态的ID进去,就可以获取到该动态的评论树。该函数会查询动态所有的一级评论,并把该一级评论的所有子孙评论放到它的Children里。同时该函数会调用GetMomentCommentChild函数,这个函数是根据某条父评论,以获取其所有子孙评论,使用了递归。通过这两个函数,便可以得到一株评论树,两个函数的细节可能会有难理解的地方,但对于我们API工程师来说,只要会调用就行😎。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
func GetMomentComment(mid int) []model.CommentTree {
var commentTrees []model.CommentTree
var comments []model.Comment
global.DB.Where("moment_id = ? AND parent_id IS NULL", mid).Order("id desc").Find(&comments)
for _, comment := range comments {
var user model.User
cid := int(comment.ID)
uid := comment.UserId
global.DB.Where("id = ?", uid).First(&user)
commentTree := model.CommentTree{
CommentId: cid,
Content: comment.Content,
Author: gin.H{"uid": uid, "nickname": user.Nickname, "avatar": user.Avatar},
CreatedAt: comment.CreatedAt.Format("2006-01-02 15:04"),
Children: []*model.CommentTree{},
}
GetMomentCommentChild(cid, &commentTree)
commentTrees = append(commentTrees, commentTree)
}
return commentTrees
}

func GetMomentCommentChild(pid int, commentTree *model.CommentTree) {
var comments []model.Comment
global.DB.Where("parent_id = ?", pid).Find(&comments)

// 查询二级及以下的多级评论
for i, _ := range comments {
var user model.User
cid := int(comments[i].ID)
uid := comments[i].UserId
global.DB.Where("id = ?", uid).First(&user)
child := model.CommentTree{
CommentId: cid,
Content: comments[i].Content,
Author: gin.H{"uid": user.ID, "nickname": user.Nickname, "avatar": user.Avatar},
CreatedAt: comments[i].CreatedAt.Format("2006-01-02 15:04"),
Children: []*model.CommentTree{},
}
commentTree.Children = append(commentTree.Children, &child)
GetMomentCommentChild(cid, &child)
}
}

以下是查看评论的效果:

image-20231023163321144

到这儿,实现多级评论就全部完成了,完整代码均在Github仓库中,欢迎查阅。