faker-douyin-8. 视频评论功能实现

606 阅读8分钟

1. 视频评论实体类定义

新建分支

git checkout main 
git pull 
git checkout -b feature/comment

原项目采用的是一级评论设计,也就是评论不可以回复,现在的社交app评论功能大都支持楼中楼套娃,设计的表也有几种方案,目前我先和开源项目保持一致,之后再调整为主流。在model/entity目录下新建comment.go文件

package entity

import "gorm.io/gorm"

type TableComment struct {
   gorm.Model
   UserId         uint64
   VideoId        uint64
   CommentContent string
}

func (tableComment TableComment) TableName() string {
   return "comments"
}

2. commentDao

package dao

import (
   "faker-douyin/model/entity"
   "fmt"
)

func GetCommentById(commentId uint64) (entity.TableComment, error) {
   var comment entity.TableComment
   if err := Db.Where("id = ?", commentId).Fisrt(&comment).Error; err != nil {
      fmt.Println("GetCommentById failed, err:", err)
      return comment, err
   }
   return comment, nil
}

func DeleteCommentById(commentId uint64) error {
   err := Db.Where("id = ?", commentId).Delete(&entity.TableComment{}).Error
   if err != nil {
      return err
   }
   return nil
}

func InsertComment(userId uint64, videoId uint64, commentContent string) (entity.TableComment, error) {
   var comment entity.TableComment
   var user entity.TableUser
   var video entity.TableVideo
   if err := Db.Where("id = ?", userId).First(&user).Error; err != nil {
      fmt.Println("user not found, user_id:", userId)
      return comment, err
   }
   if err := Db.Where("id = ?", videoId).First(&video).Error; err != nil {
      fmt.Println("video not found, video_id:", videoId)
      return comment, err
   }
   comment.UserId = userId
   comment.VideoId = videoId
   comment.CommentContent = commentContent
   Db.Create(&comment)
   return comment, nil
}

func GetCommentIdList(videoId uint64) ([]uint64, error) {
   var idList []uint64
   if err := Db.Model(&entity.TableComment{}).Select("id").Where("video_id = ?", videoId).Find(&idList).Error; err != nil {
      return idList, err
   }
   return idList, nil
}

func GetCommentList(videoId uint64) ([]entity.TableComment, error) {
   var comments []entity.TableComment
   if err := Db.Where("video_id = ?", videoId).Find(&comments).Error; err != nil {
      return comments, err
   }
   return comments, nil
}

func Count(videoId uint64) (int64, error) {
   var count int64
   err := Db.Model(&entity.TableComment{}).Where("video_id = ?", videoId).Count(&count).Error
   if err != nil {
      return count, err
   }
   return count, nil
}

3. commentDao_test

迁移表结构

err = Db.AutoMigrate(&entity.TableUser{}, &entity.TableVideo{}, entity.TableComment{})

创建测试文件commentdao_test.go

package dao

import (
   "faker-douyin/global"
   "fmt"
   "testing"
)

func TestGetCommentById(t *testing.T) {
   global.LoadConfig()
   Init()
   comment, err := GetCommentById(2)
   if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
      t.Error(err)
   }
   fmt.Println(comment)
}

func TestInsertComment(t *testing.T) {
   global.LoadConfig()
   Init()
   comment, err := InsertComment(1, 9, "测试评论")
   if err != nil {
      t.Error(err)
   }
   fmt.Println(comment)
}

func TestDeleteCommentById(t *testing.T) {
   global.LoadConfig()
   Init()
   err := DeleteCommentById(1)
   if err != nil {
      t.Error(err)
   }
}

func TestGetCommentIdList(t *testing.T) {
   global.LoadConfig()
   Init()
   idList, err := GetCommentIdList(9)
   if err != nil {
      t.Error(err)
   }
   fmt.Println(idList)
}

func TestGetCommentList(t *testing.T) {
   global.LoadConfig()
   Init()
   comments, err := GetCommentList(9)
   if err != nil {
      t.Error(err)
   }
   fmt.Println(comments)
}

func TestCount(t *testing.T) {
   global.LoadConfig()
   Init()
   count, err := Count(9)
   if err != nil {
      t.Error(err)
   }
   fmt.Println(count)
}

4. 输入输出模型定义

package request

// CommentActionReq 新增评论或者删除评论
type CommentActionReq struct {
   ActionType     uint64 `json:"action_type" binding:"required"` // 1表示删除 2表示新增
   CommentId      uint64 `json:"comment_id"`
   VideoId        uint64 `json:"video_id"`
   CommentContent string `json:"comment_content"`
}

// CommentListReq 获取视频评论评论列表
type CommentListReq struct {
   VideoId uint64 `json:"video_id" binding:"required"`
}
package response

// CommentInfoRes 获取评论列表中单个评论信息
type CommentInfoRes struct {
   Id       uint64      `json:"id"`
   UserInfo UserInfoRes `json:"user_info"` // 评论的用户信息,方便前端展示
   Content  string      `json:"content"`
}

// CommentList 实现sort.Interface接口
type CommentList []CommentInfoRes

func (c CommentList) Len() int {
   return len(c)
}

func (c CommentList) Less(i, j int) bool {
   return c[i].Id > c[j].Id
}

func (c CommentList) Swap(i, j int) {
   c[i], c[j] = c[j], c[i]
}

5. CommentService定义与实现

package service

import (
   "faker-douyin/model/dto/response"
   "faker-douyin/model/entity"
)

type CommentService interface {
   Count(videoId uint64) (uint64, error)
   CommentInfo(commentId uint64) (entity.TableComment, error)
   InsertComment(userId uint64, videoId uint64, commentContent string) (entity.TableComment, error)
   DeleteComment(commentId uint64) error
   CommentList(videoId uint64) ([]response.CommentInfoRes, error)
}

CommentService依赖UserService,当需要获取评论列表信息时,需要获取用户信息给前端直接展示,这里调用dao.GetCommentList获取到视频实体类信息之后还需要遍历视频实体类信息去组装用户信息,可以开启goroutine异步提升性能

package service

import (
   "faker-douyin/model/dao"
   "faker-douyin/model/dto/response"
   "faker-douyin/model/entity"
   "fmt"
   "sort"
   "sync"
)

type CommentServiceImpl struct {
   UserService
}

func (c CommentServiceImpl) CommentInfo(commentId uint64) (entity.TableComment, error) {
   comment, err := dao.GetCommentById(commentId)
   if err != nil {
      return comment, err
   }
   return comment, nil
}

func (c CommentServiceImpl) Count(videoId uint64) (uint64, error) {
   //TODO implement me
   panic("implement me")
}

func (c CommentServiceImpl) InsertComment(userId uint64, videoId uint64, commentContent string) (entity.TableComment, error) {
   comment, err := dao.InsertComment(userId, videoId, commentContent)
   if err != nil {
      return comment, err
   }
   return comment, nil
}

func (c CommentServiceImpl) DeleteComment(commentId uint64) error {
   err := dao.DeleteCommentById(commentId)
   if err != nil {
      return err
   }
   return nil
}

func (c CommentServiceImpl) CommentList(videoId uint64) ([]response.CommentInfoRes, error) {
   commentTableList, err := dao.GetCommentList(videoId)
   fmt.Println(commentTableList)
   // 查询失败,返回
   if err != nil {
      return nil, err
   }
   // 评论数为零直接返回,同时防止对空指针进行操作
   if len(commentTableList) == 0 {
      return nil, nil
   }
   // 并发调用UserService,提升性能
   commentList := make([]response.CommentInfoRes, 0, len(commentTableList))
   var wg sync.WaitGroup
   wg.Add(len(commentTableList))
   for _, commentTable := range commentTableList {
      var oneCommentInfo response.CommentInfoRes
      // 传入循环变量作为临时变量,防止bug
      go func(commentTable entity.TableComment) {
         oneComment(&commentTable, &oneCommentInfo)
         commentList = append(commentList, oneCommentInfo)
         wg.Done()
      }(commentTable)
      fmt.Println("one comment info", oneCommentInfo)
   }
   wg.Wait()
   // 根据id倒序,也就是根据创建时间倒序
   sort.Sort(response.CommentList(commentList))
   return commentList, nil
}

func oneComment(comment *entity.TableComment, commentInfo *response.CommentInfoRes) {
   usi := UserServiceImpl{}
   userInfo, err := usi.GetTableUserById(comment.UserId)
   if err != nil {
      fmt.Println("UserService.GetTableUserById failed, user_id:", comment.UserId)
   }
   commentInfo.Id = uint64(comment.ID)
   commentInfo.UserInfo.Id = userInfo.Id
   commentInfo.UserInfo.Name = userInfo.Name
   commentInfo.Content = comment.CommentContent
   fmt.Println(commentInfo)
}

6. 引入redis缓存提升获取视频评论总数的性能

获取视频信息时常常需要获取评论总数以供前端展示,如果每次获取视频信息时都在comment表进行count操作,势必会影响性能(当视频评论数很多时),因此决定将视频评论数换存在redis中,redis不过多介绍,在middleware目录下新建redis.go文件,go操作redis我选择用这个github.com/redis/go-re…

go get github.com/redis/go-redis/v9
package middleware

import (
   "context"
   "faker-douyin/global"
   "github.com/redis/go-redis/v9"
)

var Ctx = context.Background() //空的上下文,调用redis函数时都需要传

var RdbVCid *redis.Client //redis db11 -- video_id + comment_id
var RdbCVid *redis.Client //redis db12 -- comment_id + video_id

// InitRedis 初始化Redis连接。
func InitRedis() {
   RdbVCid = redis.NewClient(&redis.Options{
      Addr:     global.Config.Redis.Host + ":" + global.Config.Redis.Port,
      Password: global.Config.Redis.Password,
      DB:       11, // lsy 选择将video_id中的评论id s存入 DB11.
   })

   RdbCVid = redis.NewClient(&redis.Options{
      Addr:     global.Config.Redis.Host + ":" + global.Config.Redis.Port,
      Password: global.Config.Redis.Password,
      DB:       12, // lsy 选择将comment_id对应video_id存入 DB12.
   })
}

插入/删除评论时,不仅需要考虑数据库,还要考虑redis

package service

import (
   "faker-douyin/global"
   "faker-douyin/middleware"
   "faker-douyin/model/dao"
   "faker-douyin/model/dto/response"
   "faker-douyin/model/entity"
   "fmt"
   "log"
   "sort"
   "strconv"
   "sync"
   "time"
)

type CommentServiceImpl struct {
   UserService
}

func NewCommentService(userService UserService) CommentService {
   return &CommentServiceImpl{
      &UserServiceImpl{},
   }
}

func (c CommentServiceImpl) CommentInfo(commentId int64) (entity.TableComment, error) {
   comment, err := dao.GetCommentById(commentId)
   if err != nil {
      return comment, err
   }
   return comment, nil
}

func (c CommentServiceImpl) Count(videoId int64) (int64, error) {
   // 先在缓存中查找
   count, err := middleware.RdbVCid.SCard(middleware.Ctx, strconv.Itoa(int(videoId))).Result()
   if err != nil {
      //return 0, err
      fmt.Println(err)
   }
   // 缓存中有数据,直接返回
   if count > 0 {
      return count, nil
   }
   // 在数据库中找
   cntDao, err := dao.Count(videoId)
   if err != nil {
      fmt.Println(err)
   }
   if cntDao > 0 {
      //查询评论id list
      cList, _ := dao.GetCommentIdList(videoId)
      //设置key值过期时间
      _, err = middleware.RdbVCid.Expire(middleware.Ctx, strconv.Itoa(int(videoId)),
         time.Duration(global.OneMonth)*time.Second).Result()
      if err != nil {
         log.Println("redis save one vId - cId expire failed")
      }
      //评论id循环存入redis
      for _, commentId := range cList {
         insertRedisVideoCommentId(strconv.Itoa(int(videoId)), strconv.FormatInt(commentId, 10))
      }
      log.Println("count comment save ids in redis")
   }
   //返回结果
   return cntDao, nil
}

func (c CommentServiceImpl) InsertComment(userId int64, videoId int64, commentContent string) (entity.TableComment, error) {
   // 先插入数据库
   comment, err := dao.InsertComment(userId, videoId, commentContent)
   if err != nil {
      return comment, err
   }
   // 再更新缓存
   insertRedisVideoCommentId(strconv.FormatInt(videoId, 10), strconv.FormatUint(uint64(comment.ID), 10))
   return comment, nil
}

func (c CommentServiceImpl) DeleteComment(commentId int64) error {
   // 先删除数据库数据
   err := dao.DeleteCommentById(commentId)
   if err != nil {
      return err
   }
   fmt.Println("dao.DeleteCommentById成功,comment_id: ", commentId)
   // 先看redis中是否有数据
   _, err = middleware.RdbCVid.Exists(middleware.Ctx, strconv.FormatInt(commentId, 10)).Result()
   if err != nil {
      fmt.Println("key not exist in comment_iv-video_id ", commentId)
   }
   fmt.Println("redis中存在key:comment_id ", commentId)
   // 有数据,直接删redis数据
   videoId, err := middleware.RdbCVid.Get(middleware.Ctx, strconv.FormatInt(commentId, 10)).Result()
   if err != nil {
      fmt.Println("get value from comment_id-video_id failed, ", commentId)
   }
   _, err = middleware.RdbCVid.Del(middleware.Ctx, strconv.FormatInt(commentId, 10)).Result()
   if err != nil {
      fmt.Println(err)
   }
   _, err = middleware.RdbVCid.SRem(middleware.Ctx, videoId, strconv.FormatInt(commentId, 10)).Result()
   if err != nil {
      fmt.Println(err)
   }
   fmt.Println("delete ", commentId, videoId)
   return nil
}

func (c CommentServiceImpl) CommentList(videoId int64) ([]response.CommentInfoRes, error) {
   commentTableList, err := dao.GetCommentList(videoId)
   fmt.Println(commentTableList)
   // 查询失败,返回
   if err != nil {
      return nil, err
   }
   // 评论数为零直接返回,同时防止对空指针进行操作
   if len(commentTableList) == 0 {
      return nil, nil
   }
   // 并发调用UserService,提升性能
   commentList := make([]response.CommentInfoRes, 0, len(commentTableList))
   var wg sync.WaitGroup
   wg.Add(len(commentTableList))
   for _, commentTable := range commentTableList {
      var oneCommentInfo response.CommentInfoRes
      // 传入循环变量作为临时变量,防止bug
      go func(commentTable entity.TableComment) {
         oneComment(&commentTable, &oneCommentInfo)
         commentList = append(commentList, oneCommentInfo)
         wg.Done()
      }(commentTable)
      fmt.Println("one comment info", oneCommentInfo)
   }
   wg.Wait()
   // 根据id倒序,也就是根据创建时间倒序
   sort.Sort(response.CommentList(commentList))
   return commentList, nil
}

func oneComment(comment *entity.TableComment, commentInfo *response.CommentInfoRes) {
   usi := UserServiceImpl{}
   userInfo, err := usi.GetByID(comment.UserId)
   if err != nil {
      fmt.Println("UserService.GetTableUserById failed, user_id:", comment.UserId)
   }
   commentInfo.Id = uint64(comment.ID)
   commentInfo.UserInfo.ID = userInfo.ID
   commentInfo.UserInfo.Username = userInfo.Username
   commentInfo.Content = comment.CommentContent
   fmt.Println(commentInfo)
}

func insertRedisVideoCommentId(videoId string, commentId string) {
   _, err := middleware.RdbVCid.SAdd(middleware.Ctx, videoId, commentId).Result()
   if err != nil {
      // 新增失败,暂时先上报日志,之后引入重试机制
      fmt.Println("add video_id-comment_id failed", videoId, commentId)
   }
   _, err = middleware.RdbCVid.Set(middleware.Ctx, commentId, videoId, 0).Result()
   if err != nil {
      // 新增失败,暂时先上报日志,之后引入重试机制
      fmt.Println("save comment_id-video_id failed, ", commentId, videoId)
   }
}

7. CommentController

package v1

import (
   "faker-douyin/model/common"
   "faker-douyin/model/dto/request"
   "faker-douyin/service"
   "faker-douyin/utils"
   "github.com/gin-gonic/gin"
   "strconv"
)

type CommentController struct {
}

// CommentAction POST /douyin/v1/comment/action/ 发表评论和删除评论
func (cc *CommentController) CommentAction(c *gin.Context) {
   var commentActionReq request.CommentActionReq
   // 请求参数绑定和校验
   err := c.ShouldBindJSON(&commentActionReq)
   if err != nil {
      common.FailWithMessage(err.Error(), c)
      return
   }
   id, _ := c.Get("userId")
   // 从上下文中获取用户id
   userId, err := strconv.Atoi(id.(string))
   // 依赖倒转原则,面向抽象层进行开发
   csi := service.NewCommentService(&service.UserServiceImpl{})
   // 删除逻辑
   if commentActionReq.ActionType == 1 {
      // 获取评论信息
      comment, err := csi.CommentInfo(commentActionReq.CommentId)
      if err != nil {
         common.FailWithMessage(err.Error(), c)
         return
      }
      // 当前评论非当前用户发表,无权删除
      if comment.UserId != int64(userId) {
         common.FailWithMessage("current comment is not created by this user", c)
         return
      }
      // 删除评论
      err = csi.DeleteComment(commentActionReq.CommentId)
      if err != nil {
         common.FailWithMessage(err.Error(), c)
         return
      }
      common.OkWithMessage("删除评论成功", c)
      return
   }
   // 新增逻辑
   if commentActionReq.ActionType == 2 {
      // 敏感词判断
      result, _ := utils.Filter.FindIn(commentActionReq.CommentContent)
      if result {
         common.FailWithMessage("评论包含敏感词,操作失败", c)
         return
      }
      // 插入评论
      comment, err := csi.InsertComment(int64(userId), commentActionReq.VideoId, commentActionReq.CommentContent)
      if err != nil {
         common.FailWithMessage(err.Error(), c)
         return
      }
      common.OkWithDetailed(comment, "新增评论成功", c)
      return
   }
   common.FailWithMessage("action type error", c)
}

// CommentList GET /douyin/v1/comment/list/ 获取评论列表
func (cc *CommentController) CommentList(c *gin.Context) {
   var commentListReq request.CommentListReq
   // 请求参数绑定和校验
   err := c.ShouldBindJSON(&commentListReq)
   if err != nil {
      common.FailWithMessage(err.Error(), c)
      return
   }
   // 依赖倒转原则,面向抽象层进行开发
   csi := service.NewCommentService(&service.UserServiceImpl{})
   // 获取评论列表
   commentList, err := csi.CommentList(commentListReq.VideoId)
   if err != nil {
      common.FailWithMessage(err.Error(), c)
      return
   }
   common.OkWithData(commentList, c)
}

8. 注册路由,测试

路由做了一些改变,就是把一个服务的路由处理函数变成了一个处理器类的方法

package router

import (
   v1 "faker-douyin/api/v1"
   "faker-douyin/middleware"
   "github.com/gin-gonic/gin"
)

func InitRouter(r *gin.Engine) {
   apiRouter := r.Group("/douyin/v1")

   userController := v1.UserController{}
   apiRouter.POST("/user/register/", userController.Register)
   apiRouter.POST("/user/login/", userController.Login)
   apiRouter.GET("/user/", middleware.Auth(), userController.UserInfo)

   videoController := v1.VideoController{}
   apiRouter.POST("/video/publish/", middleware.Auth(), videoController.Publish)
   apiRouter.GET("/video/feed/", videoController.Feed)
   apiRouter.GET("/video/publish/list/", videoController.List)

   commentController := v1.CommentController{}
   apiRouter.POST("/comment/action/", middleware.Auth(), commentController.CommentAction)
   apiRouter.GET("/comment/list/", commentController.CommentList)
}

新增评论

WechatIMG67.jpeg

新增评论包含敏感词

WechatIMG70.jpeg 删除评论失败,非当前用户发表

WechatIMG68.jpeg 删除成功

WechatIMG69.jpeg 其中redis的功能也正常,这里忘记截图

9. 遇到的问题

  • redis缓存一致性问题 原项目主认为在插入删除数据库时可以异步redis,只要在获取视频流时预先插入一个永远不会失效的key(video_id)值-1,就可以避免并发脏读。我对高并发下redis缓存一致性的没什么理解,但查阅了不少博文,大家的结论是先更新数据库,再删除redis(对于string类型),先插入-1也是一种解决方案,缓存在正常情况下永远不会失效。我的做法是先更新数据库,再更新redis,没有插入预设值。参考博客:xiaolincoding.com/redis/archi…
  • 服务依赖问题,当前评论服务依赖了用户服务,之后可能会依赖更多服务,当要获取一个评论服务实例时,初始化会比较麻烦,服务实例不是单例的(浪费内存),而且服务之间的依赖可能会产生循环依赖问题,之后会引入DI(依赖注入)工具wire(google项目),把服务间依赖和初始化交给容器处理
  • 作为一个开发新手,数据库设计的不是很好,可能会经常改动,目前gorm手写where条件sql拼接,把查询字段放在字符串里,不方便修改,而且手写常用dao函数很费时间,之后会使用gorm/gen工具,自动生成,快捷开发