评论功能有多简单,两张表就行

个人项目:社交支付项目(小老板)

作者:三哥,j3code.cn

项目文档:www.yuque.com/g/j3code/dv...

预览地址(未开发完):admire.j3code.cn/small-boss

  • 内网穿透部署,第一次访问比较慢

评论功能相信是很多论坛、视频的基础功能了,而本次我写的个人项目也会涉及到该功能,所以是时候出个文章好好聊聊评论功能了。

1、分析

相信面对评论的这个业务大家都不陌生,就好比你看到一条非常有意思的帖子肯定会忍不住的去称赞对方,进而去给他留言评论,就像下面这样:

上图就是一个非常简单的一级评论,直接针对帖子内容进行评论。

当然评论的目标肯定不光只局限于帖子内容,还可以针对别人的评论(一级)做评价,也称为二级评论,就像下面这样:

大伙不会觉得这就完事了吧,当然还有后续的评论了,如用户对二级的评论做评价,就像这样:

通过这一套流程下来,我们才是真正的把评论这个业务走完。那现在我们来捋一下这上面出现了几种评论:

  1. 直接评论帖子的一级评论
  2. 对一级评论进行评价的二级评论
  3. 对二级评论进行评价的二级评论

这里没有三级或者说套娃式的分层下去,我是觉得没必要这样,就如图上展示的那样,二级评论的相互评价显示成 XXX 回复 XXX 就一清二楚了。

确定好这些概念之后,咱们再来回过头看看一级评论,我们可以抽出那些字段出来:

  • 被评论的帖子ID(这是帖子ID)
  • 评论的用户ID
  • 评论的内容
  • 评论点赞数

好,那我们再来看看直接评论一级评论的二级评论能抽出那些字段出来:

  • 被评论的父级评论ID(这是评论ID)
  • 评论的用户ID
  • 评论的内容
  • 评论点赞数

最后,我们再来看看评论二级评论的二级评论能抽出那些字段出来:

  • 父级评论ID(这是评论ID)
  • 被回复的评论ID
  • 评论的用户ID
  • 评论的内容
  • 评论点赞数

上面我只是大致的抽了一下从图中就能发现的字段,现在我们把关注点放在帖子上,其中是不是有一个帖子评论数量的字段,如果按照上面抽出来的字段,能否实现获取帖子的所有评论。

我想,你会现根据帖子ID,查询所有一级评论,然后再加上二级评论中父级评论是一级评论ID的数据,这样就可以得出评论总数。

但我觉得这样麻烦了,我直接给二级评论冗余一个帖子评论ID不好吗,这样直接查一下就出来了评论总数。

所以,我们在二级评论中在加一个字段:

  • 被评论的帖子ID(这是帖子ID)

ok,到此貌似评论的字段都已经抽的差不多了,而紧接着我们会面临一个问题,就是这些评论是统一放在一张表中,还是两张表中。

我给出的答案是,一二级评论,拆开存储,用两张表来实现评论功能。

为啥?

虽然,一二级评论的字段只有仅仅的一两个只差,但是我觉得评论数据等增长量还是有一丢丢快的,而且有些业务只需要查一级评论即可,等进入到详情页面的时候才回去查询第二级评论,这样能很好的分散表的读写压力。

当然,我这个也不是标准选择,一起还是从你的系统、业务场景触发做出现在吧!

我因为有一二级分开查,评论数据量增长可能也比较快,所以会选择用两张表来分开存储,如果你们有不同的意见,欢迎评论讨论。

那确定好用两张表来存储评论数据之后,我们能得出如下 SQL:

  • sb_post_comment_parent
  • sb_post_comment_child
sql 复制代码
CREATE TABLE `sb_post_comment_parent` (
    `id` bigint(20) NOT NULL,
    `item_id` bigint(20) NOT NULL COMMENT '条目id',
    `user_id` bigint(20) NOT NULL COMMENT '用户id',
    `content` varchar(1000) COLLATE utf8mb4_german2_ci NOT NULL COMMENT '内容',
    `like_count` int(4) DEFAULT '0' COMMENT '点赞数',
    `is_publisher` tinyint(1) DEFAULT '0' COMMENT '是否为发布者',
    `is_delete` tinyint(1) DEFAULT '0' COMMENT '是否删除',
    `create_time` datetime DEFAULT NULL COMMENT '创建时间',
    PRIMARY KEY (`id`),
    KEY `k01` (`item_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_german2_ci

CREATE TABLE `sb_post_comment_child` (
    `id` bigint(20) NOT NULL,
    `item_id` bigint(20) NOT NULL COMMENT '条目id',
    `parent_id` bigint(20) NOT NULL COMMENT '父评论id,也即第一级评论',
    `reply_id` bigint(20) DEFAULT NULL COMMENT '被回复的评论id(没有则是回复父级评论,有则是回复这个人的评论)',
    `user_id` bigint(20) NOT NULL COMMENT '评论人id',
    `content` varchar(1000) COLLATE utf8mb4_german2_ci NOT NULL COMMENT '内容',
    `like_count` int(4) DEFAULT '0' COMMENT '点赞数',
    `is_publisher` tinyint(1) DEFAULT '0' COMMENT '是否为发布者',
    `is_delete` tinyint(1) DEFAULT '0' COMMENT '是否删除',
    `create_time` datetime DEFAULT NULL COMMENT '创建时间',
    PRIMARY KEY (`id`),
    KEY `k01` (`parent_id`),
    KEY `k02` (`item_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_german2_ci

表结构出来了,那就要开始编码实现了。

2、实现

2.1 插入评论

插入评论的逻辑很简单,先根据参数区分出是一级评论还是二级评论,然后就是组装参数保存到数据库即可。

1)controller

位置:cn.j3code.community.api.v1.controller

java 复制代码
@Slf4j
@ResponseResult
@AllArgsConstructor
@RestController
@RequestMapping(UrlPrefixConstants.WEB_V1 + "/post")
public class PostController {
    private final PostService postService;

    /**
     * 帖子评论
     *
     * @param request
     */
    @PostMapping("/comment")
    public CommentVO comment(@Validated @RequestBody PostCommentRequest request) {
        return postService.comment(request);
    }
}

PostCommentRequest 对象

位置:cn.j3code.community.api.v1.request

java 复制代码
@Data
public class PostCommentRequest {
    /**
     * 帖子id
     */
    @NotNull(message = "帖子id不为空")
    private Long postId;
    /**
     * 回复id,如:a 回复 b 的评论,那么回复id 就是 b 的评论id
     */
    private Long replyId;

    /**
     * 父级评论id
     */
    private Long parentId;

    /**
     * 内容
     */
    @NotNull(message = "评论内容不为空")
    private String content;
}

CommentVO 对象

位置:cn.j3code.community.api.v1.vo

java 复制代码
@Data
public class CommentVO {

    @JsonFormat(shape = JsonFormat.Shape.STRING)
    private Long id;

    /**
     * 条目id
     */
    @JsonFormat(shape = JsonFormat.Shape.STRING)
    private Long itemId;

    /**
     * 评论用户id
     */
    private Long userId;

    /**
     * 头像
     */
    private String avatarUrl;

    /**
     * 昵称
     */
    private String nickName;

    /**
     * 他的父级评论id
     */
    @JsonFormat(shape = JsonFormat.Shape.STRING)
    private Long parentId;

    /**
     * 被回复的评论id(没有则是回复父级评论,有则是回复这个人的评论)
     */
    @JsonFormat(shape = JsonFormat.Shape.STRING)
    private Long replyId;

    private ReplyInfo replyInfo;

    /**
     * 内容
     */
    private String content;

    /**
     * 是否为发布者
     */
    private Boolean publisher;

    /**
     * 点赞数
     */
    private Integer likeCount;

    /**
     * 当前登陆者是否点赞:true 已点赞
     */
    private Boolean like;

    /**
     * 创建时间
     */
    private LocalDateTime createTime;

    /**
     * 该评论的所有孩子评论(分页)
     */
    private IPage<CommentVO> childCommentPage;


    /**
     * 这是一个冗余字段(是否回复),给前端用的,默认 false
     */
    private Boolean reply = Boolean.FALSE;

    /**
     * 这是一个冗余字段(回复内容),给前端用的,
     */
    private String replyContent;

    @Data
    public static class ReplyInfo {
        /**
         * 评论用户id
         */
        private Long userId;

        /**
         * 被回复的内容
         */
        private String content;

        /**
         * 头像
         */
        private String avatarUrl;

        /**
         * 昵称
         */
        private String nickName;
    }

这个对象比较复杂,主要就是为了向前端展示评论 + 用户 + 被回复评论信息的一个复合对象。

2)service

位置:cn.j3code.community.service

java 复制代码
public interface PostService extends IService<Post> {
    CommentVO comment(PostCommentRequest request);
}
@Slf4j
@AllArgsConstructor
@Service
public class PostServiceImpl extends ServiceImpl<PostMapper, Post>
    implements PostService {

    private final CommentConverter commentConverter;
    private final PostCommentParentService postCommentParentService;
    private final PostCommentChildService postCommentChildService;

    @Override
    public CommentVO comment(PostCommentRequest request) {
        CommentVO commentVO = null;
        // 区分出一级评论还是二级评论
        if (Objects.isNull(request.getParentId()) && Objects.isNull(request.getReplyId())) {
            // 一级
            commentVO = oneComment(request);
        } else if (Objects.nonNull(request.getParentId()) && Objects.nonNull(request.getReplyId())) {
            // 回复 二级 评论的 二级 评论
            commentVO = twoComment(request);
        } else if (Objects.nonNull(request.getParentId()) && Objects.isNull(request.getReplyId())) {
            // 回复 一级 的 二级 评论
            commentVO = twoComment(request);
        } else {
            throw new SysException("评论参数出错!");
        }

        commentVO.setPublisher(Boolean.TRUE);
        commentVO.setLikeCount(0);
        commentVO.setCreateTime(LocalDateTime.now());
        return commentVO;
    }

    private CommentVO twoComment(PostCommentRequest request) {
        PostCommentChild commentChild = commentConverter.converter(request);
        commentChild.setUserId(SecurityUtil.getUserId());

        postCommentChildService.save(commentChild);

        return commentConverter.converter(commentChild);
    }

    private CommentVO oneComment(PostCommentRequest request) {
        PostCommentParent commentParent = commentConverter.converterToOne(request);
        commentParent.setUserId(SecurityUtil.getUserId());

        postCommentParentService.save(commentParent);

        return commentConverter.converter(commentParent);
    }
}

2.2 评论列表

针对评论查询,我们先明确一件事情就是,应该针对业务数据去查它对应的评论,也即本篇一直说的帖子。所以,只有传入帖子 ID,才会查询其评论数据。

那现在考虑一下如何出数据?

分页肯定是跑不了的,而且不仅一级评论要进行分页,二级同样是如此,就像下面这样:

一级

二级

而点赞记录我就不在这里提了,上篇已经实现过这个功能。

现在我们能知道查询一级评论的基本业务流程了:

  1. 先查分页查询一级评论
  2. 然后在填充一级评论的二级评论,注意这也是分页

下面看主要逻辑代码:

java 复制代码
@Override
public CommentListVO commentPage(CommentPageRequest request) {
    CommentListVO vo = new CommentListVO();
    // 先分页获取所有一级评论
    vo.setCommentPageData(postCommentParentService.oneCommentPage(request));

    if (CollectionUtils.isEmpty(vo.getCommentPageData().getRecords())) {
        return vo;
    }

    // 用户评论点赞状态
    Map<Long, Boolean> itemIdToLikeMap = likeService.getItemLikeState(vo.getCommentPageData().getRecords().stream().map(CommentVO::getId).collect(Collectors.toList()), CommentTypeEnum.POST_COMMENT);
    // redis 中评论点赞数量
    Map<Long, Integer> itemIdToLikeCountMap = likeService.getItemLikeCount(vo.getCommentPageData().getRecords().stream().map(CommentVO::getId).collect(Collectors.toList()), CommentTypeEnum.POST_COMMENT, Boolean.FALSE)
            .getItemLikeCount();

    // 填充评论点赞数量及当前用户点赞状态
    vo.getCommentPageData().getRecords().forEach(parentComment -> {
        parentComment.setLike(itemIdToLikeMap.get(parentComment.getId()));
        parentComment.setLikeCount(parentComment.getLikeCount() + itemIdToLikeCountMap.get(parentComment.getId()));

        // 再分页获取一级评论的二级评论,二级评论默认一页 3 条
        request.setParentId(parentComment.getId());
        request.setSize(3L);
        request.setItemIdBefore(null);
        parentComment.setChildCommentPage(postCommentChildService.twoCommentPage(request));

        if (CollectionUtils.isNotEmpty(parentComment.getChildCommentPage().getRecords())) {
            // 用户评论点赞状态
            Map<Long, Boolean> childItemIdToLikeMap = likeService.getItemLikeState(parentComment.getChildCommentPage().getRecords().stream().map(CommentVO::getId).collect(Collectors.toList()), CommentTypeEnum.POST_COMMENT);
            // redis 中评论点赞数量
            Map<Long, Integer> childIdToLikeCountMap = likeService.getItemLikeCount(parentComment.getChildCommentPage().getRecords().stream().map(CommentVO::getId).collect(Collectors.toList()), CommentTypeEnum.POST_COMMENT, Boolean.FALSE)
                    .getItemLikeCount();

            Set<Long> replyIds = new HashSet<>();
            parentComment.getChildCommentPage().getRecords().forEach(childComment -> {
                childComment.setLike(childItemIdToLikeMap.get(childComment.getId()));
                childComment.setLikeCount(childComment.getLikeCount() + childIdToLikeCountMap.get(childComment.getId()));

                if (Objects.nonNull(childComment.getReplyId())) {
                    replyIds.add(childComment.getReplyId());
                }
            });

            // 回填二级评论的 回复信息(用户id)
            if (CollectionUtils.isNotEmpty(replyIds)) {
                Map<Long, PostCommentChild> replyIdMap = postCommentChildService.lambdaQuery()
                        .select(PostCommentChild::getId, PostCommentChild::getUserId, PostCommentChild::getContent)
                        .in(PostCommentChild::getId, replyIds)
                        .list().stream().distinct().collect(Collectors.toMap(PostCommentChild::getId, item -> item));

                parentComment.getChildCommentPage().getRecords().forEach(childComment -> {
                    if (Objects.nonNull(childComment.getReplyId())) {
                        CommentVO.ReplyInfo replyInfo = new CommentVO.ReplyInfo();
                        replyInfo.setUserId(replyIdMap.get(childComment.getReplyId()).getUserId());
                        replyInfo.setContent(replyIdMap.get(childComment.getReplyId()).getContent());
                        childComment.setReplyInfo(replyInfo);
                    }
                });
            }

        }
    });

    return vo;
}

CommentPageRequest 对象

java 复制代码
@Data
public class CommentPageRequest extends QueryPage {

    /**
     * 评论条目id
     */
    @NotNull(message = "评论条目id不为空")
    private Long itemId;

    /**
     * 该条目之前的数据,分页情况下
     */
    private Long itemIdBefore;

    /**
     * 评论类型
     */
    @NotNull(message = "评论类型不为空")
    private CommentTypeEnum type;

    /**
     * 评论的父级评论id
     */
    private Long parentId;
}

这样,我们就实现了一个一级评论的分页查询,并且该列表数据也会顺带的把二级评论也查询出来。

那紧接着,如果我想要对查出来的二级评论进行分页查询呢?显然上面的逻辑就不行了,因为它是查询一级评论顺带把二级评论查出来一页而已。

而我们现在是要对一级评论的二级评论进行分页查询,所以就要重新写一个二级评论的分页接口了,其主要实现逻辑如下:

java 复制代码
@Override
public IPage<CommentVO> towCommentPage(CommentPageRequest request) {
    IPage<CommentVO> voiPage = postCommentChildService.twoCommentPage(request);

    // 回填二级评论的 回复信息(用户id)

    // 获取被回复的评论 id 集合
    Set<Long> replyIds = voiPage.getRecords().stream().map(CommentVO::getReplyId)
        .filter(Objects::nonNull).collect(Collectors.toSet());
    if (CollectionUtils.isEmpty(replyIds)) {
        return voiPage;
    }

    Map<Long, PostCommentChild> replyIdMap = postCommentChildService.lambdaQuery()
        .select(PostCommentChild::getId, PostCommentChild::getUserId, PostCommentChild::getContent)
        .in(PostCommentChild::getId, replyIds)
        .list().stream().distinct().collect(Collectors.toMap(PostCommentChild::getId, item -> item));

    voiPage.getRecords().forEach(childComment -> {
        if (Objects.nonNull(childComment.getReplyId())) {
            CommentVO.ReplyInfo replyInfo = new CommentVO.ReplyInfo();
            replyInfo.setUserId(replyIdMap.get(childComment.getReplyId()).getUserId());
            replyInfo.setContent(replyIdMap.get(childComment.getReplyId()).getContent());
            childComment.setReplyInfo(replyInfo);
        }
    });
    return voiPage;
}

至此,我们的评论功能就完成了,如果对以上评论的设计与实现有任何疑问,或者不足的点,欢迎评论区讨论。

相关推荐
BD_Marathon2 小时前
【Flink】部署模式
java·数据库·flink
鼠鼠我捏,要死了捏5 小时前
深入解析Java NIO多路复用原理与性能优化实践指南
java·性能优化·nio
ningqw5 小时前
SpringBoot 常用跨域处理方案
java·后端·springboot
你的人类朋友5 小时前
vi编辑器命令常用操作整理(持续更新)
后端
superlls5 小时前
(Redis)主从哨兵模式与集群模式
java·开发语言·redis
胡gh5 小时前
简单又复杂,难道只能说一个有箭头一个没箭头?这种问题该怎么回答?
javascript·后端·面试
一只叫煤球的猫6 小时前
看到同事设计的表结构我人麻了!聊聊怎么更好去设计数据库表
后端·mysql·面试
uzong6 小时前
技术人如何对客做好沟通(上篇)
后端
叫我阿柒啊7 小时前
Java全栈工程师面试实战:从基础到微服务的深度解析
java·redis·微服务·node.js·vue3·全栈开发·电商平台
颜如玉7 小时前
Redis scan高位进位加法机制浅析
redis·后端·开源