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

个人项目:社交支付项目(小老板)
作者:三哥,https://j3code.cn
项目文档:https://www.yuque.com/g/j3code/dvnbr5/collaborator/join?token=CFMcFNwMdhpp6u2s&source=book_collaborator#
预览地址(未开发完):http://admire.j3code.cn/small-boss
内网穿透部署,第一次访问比较慢
评论功能相信是很多论坛、视频的基础功能了,而本次我写的个人项目也会涉及到该功能,所以是时候出个文章好好聊聊评论功能了。
1、分析
相信面对评论的这个业务大家都不陌生,就好比你看到一条非常有意思的帖子肯定会忍不住的去称赞对方,进而去给他留言评论,就像下面这样:

上图就是一个非常简单的一级评论,直接针对帖子内容进行评论。
当然评论的目标肯定不光只局限于帖子内容,还可以针对别人的评论(一级)做评价,也称为二级评论,就像下面这样:

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

通过这一套流程下来,我们才是真正的把评论这个业务走完。那现在我们来捋一下这上面出现了几种评论:
直接评论帖子的一级评论
对一级评论进行评价的二级评论
对二级评论进行评价的二级评论
这里没有三级或者说套娃式的分层下去,我是觉得没必要这样,就如图上展示的那样,二级评论的相互评价显示成 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
1CREATE TABLE `sb_post_comment_parent` (
2 `id` bigint(20) NOT NULL,
3 `item_id` bigint(20) NOT NULL COMMENT '条目id',
4 `user_id` bigint(20) NOT NULL COMMENT '用户id',
5 `content` varchar(1000) COLLATE utf8mb4_german2_ci NOT NULL COMMENT '内容',
6 `like_count` int(4) DEFAULT '0' COMMENT '点赞数',
7 `is_publisher` tinyint(1) DEFAULT '0' COMMENT '是否为发布者',
8 `is_delete` tinyint(1) DEFAULT '0' COMMENT '是否删除',
9 `create_time` datetime DEFAULT NULL COMMENT '创建时间',
10 PRIMARY KEY (`id`),
11 KEY `k01` (`item_id`)
12) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_german2_ci
13
14CREATE TABLE `sb_post_comment_child` (
15 `id` bigint(20) NOT NULL,
16 `item_id` bigint(20) NOT NULL COMMENT '条目id',
17 `parent_id` bigint(20) NOT NULL COMMENT '父评论id,也即第一级评论',
18 `reply_id` bigint(20) DEFAULT NULL COMMENT '被回复的评论id(没有则是回复父级评论,有则是回复这个人的评论)',
19 `user_id` bigint(20) NOT NULL COMMENT '评论人id',
20 `content` varchar(1000) COLLATE utf8mb4_german2_ci NOT NULL COMMENT '内容',
21 `like_count` int(4) DEFAULT '0' COMMENT '点赞数',
22 `is_publisher` tinyint(1) DEFAULT '0' COMMENT '是否为发布者',
23 `is_delete` tinyint(1) DEFAULT '0' COMMENT '是否删除',
24 `create_time` datetime DEFAULT NULL COMMENT '创建时间',
25 PRIMARY KEY (`id`),
26 KEY `k01` (`parent_id`),
27 KEY `k02` (`item_id`)
28) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_german2_ci
表结构出来了,那就要开始编码实现了。
2、实现
2.1 插入评论
插入评论的逻辑很简单,先根据参数区分出是一级评论还是二级评论,然后就是组装参数保存到数据库即可。
1)controller
位置:cn.j3code.community.api.v1.controller
1 4j
2
3
4
5 (UrlPrefixConstants.WEB_V1 + "/post")
6public class PostController {
7 private final PostService postService;
8
9 /**
10 * 帖子评论
11 *
12 * @param request
13 */
14 ("/comment")
15 public CommentVO comment(@Validated @RequestBody PostCommentRequest request) {
16 return postService.comment(request);
17 }
18}
PostCommentRequest 对象
位置:cn.j3code.community.api.v1.request
1
2public class PostCommentRequest {
3 /**
4 * 帖子id
5 */
6 (message = "帖子id不为空")
7 private Long postId;
8 /**
9 * 回复id,如:a 回复 b 的评论,那么回复id 就是 b 的评论id
10 */
11 private Long replyId;
12
13 /**
14 * 父级评论id
15 */
16 private Long parentId;
17
18 /**
19 * 内容
20 */
21 (message = "评论内容不为空")
22 private String content;
23}
CommentVO 对象
位置:cn.j3code.community.api.v1.vo
1
2public class CommentVO {
3
4 (shape = JsonFormat.Shape.STRING)
5 private Long id;
6
7 /**
8 * 条目id
9 */
10 (shape = JsonFormat.Shape.STRING)
11 private Long itemId;
12
13 /**
14 * 评论用户id
15 */
16 private Long userId;
17
18 /**
19 * 头像
20 */
21 private String avatarUrl;
22
23 /**
24 * 昵称
25 */
26 private String nickName;
27
28 /**
29 * 他的父级评论id
30 */
31 (shape = JsonFormat.Shape.STRING)
32 private Long parentId;
33
34 /**
35 * 被回复的评论id(没有则是回复父级评论,有则是回复这个人的评论)
36 */
37 (shape = JsonFormat.Shape.STRING)
38 private Long replyId;
39
40 private ReplyInfo replyInfo;
41
42 /**
43 * 内容
44 */
45 private String content;
46
47 /**
48 * 是否为发布者
49 */
50 private Boolean publisher;
51
52 /**
53 * 点赞数
54 */
55 private Integer likeCount;
56
57 /**
58 * 当前登陆者是否点赞:true 已点赞
59 */
60 private Boolean like;
61
62 /**
63 * 创建时间
64 */
65 private LocalDateTime createTime;
66
67 /**
68 * 该评论的所有孩子评论(分页)
69 */
70 private IPage<CommentVO> childCommentPage;
71
72
73 /**
74 * 这是一个冗余字段(是否回复),给前端用的,默认 false
75 */
76 private Boolean reply = Boolean.FALSE;
77
78 /**
79 * 这是一个冗余字段(回复内容),给前端用的,
80 */
81 private String replyContent;
82
83
84 public static class ReplyInfo {
85 /**
86 * 评论用户id
87 */
88 private Long userId;
89
90 /**
91 * 被回复的内容
92 */
93 private String content;
94
95 /**
96 * 头像
97 */
98 private String avatarUrl;
99
100 /**
101 * 昵称
102 */
103 private String nickName;
104 }
这个对象比较复杂,主要就是为了向前端展示评论 + 用户 + 被回复评论信息的一个复合对象。
2)service
位置:cn.j3code.community.service
1public interface PostService extends IService<Post> {
2 CommentVO comment(PostCommentRequest request);
3}
4 4j
5
6
7public class PostServiceImpl extends ServiceImpl<PostMapper, Post>
8 implements PostService {
9
10 private final CommentConverter commentConverter;
11 private final PostCommentParentService postCommentParentService;
12 private final PostCommentChildService postCommentChildService;
13
14
15 public CommentVO comment(PostCommentRequest request) {
16 CommentVO commentVO = null;
17 // 区分出一级评论还是二级评论
18 if (Objects.isNull(request.getParentId()) && Objects.isNull(request.getReplyId())) {
19 // 一级
20 commentVO = oneComment(request);
21 } else if (Objects.nonNull(request.getParentId()) && Objects.nonNull(request.getReplyId())) {
22 // 回复 二级 评论的 二级 评论
23 commentVO = twoComment(request);
24 } else if (Objects.nonNull(request.getParentId()) && Objects.isNull(request.getReplyId())) {
25 // 回复 一级 的 二级 评论
26 commentVO = twoComment(request);
27 } else {
28 throw new SysException("评论参数出错!");
29 }
30
31 commentVO.setPublisher(Boolean.TRUE);
32 commentVO.setLikeCount(0);
33 commentVO.setCreateTime(LocalDateTime.now());
34 return commentVO;
35 }
36
37 private CommentVO twoComment(PostCommentRequest request) {
38 PostCommentChild commentChild = commentConverter.converter(request);
39 commentChild.setUserId(SecurityUtil.getUserId());
40
41 postCommentChildService.save(commentChild);
42
43 return commentConverter.converter(commentChild);
44 }
45
46 private CommentVO oneComment(PostCommentRequest request) {
47 PostCommentParent commentParent = commentConverter.converterToOne(request);
48 commentParent.setUserId(SecurityUtil.getUserId());
49
50 postCommentParentService.save(commentParent);
51
52 return commentConverter.converter(commentParent);
53 }
54}
2.2 评论列表
针对评论查询,我们先明确一件事情就是,应该针对业务数据去查它对应的评论,也即本篇一直说的帖子。所以,只有传入帖子 ID,才会查询其评论数据。
那现在考虑一下如何出数据?
分页肯定是跑不了的,而且不仅一级评论要进行分页,二级同样是如此,就像下面这样:
一级

二级

而点赞记录我就不在这里提了,上篇已经实现过这个功能。
现在我们能知道查询一级评论的基本业务流程了:
先查分页查询一级评论
然后在填充一级评论的二级评论,注意这也是分页
下面看主要逻辑代码:
1
2public CommentListVO commentPage(CommentPageRequest request) {
3 CommentListVO vo = new CommentListVO();
4 // 先分页获取所有一级评论
5 vo.setCommentPageData(postCommentParentService.oneCommentPage(request));
6
7 if (CollectionUtils.isEmpty(vo.getCommentPageData().getRecords())) {
8 return vo;
9 }
10
11 // 用户评论点赞状态
12 Map<Long, Boolean> itemIdToLikeMap = likeService.getItemLikeState(vo.getCommentPageData().getRecords().stream().map(CommentVO::getId).collect(Collectors.toList()), CommentTypeEnum.POST_COMMENT);
13 // redis 中评论点赞数量
14 Map<Long, Integer> itemIdToLikeCountMap = likeService.getItemLikeCount(vo.getCommentPageData().getRecords().stream().map(CommentVO::getId).collect(Collectors.toList()), CommentTypeEnum.POST_COMMENT, Boolean.FALSE)
15 .getItemLikeCount();
16
17 // 填充评论点赞数量及当前用户点赞状态
18 vo.getCommentPageData().getRecords().forEach(parentComment -> {
19 parentComment.setLike(itemIdToLikeMap.get(parentComment.getId()));
20 parentComment.setLikeCount(parentComment.getLikeCount() + itemIdToLikeCountMap.get(parentComment.getId()));
21
22 // 再分页获取一级评论的二级评论,二级评论默认一页 3 条
23 request.setParentId(parentComment.getId());
24 request.setSize(3L);
25 request.setItemIdBefore(null);
26 parentComment.setChildCommentPage(postCommentChildService.twoCommentPage(request));
27
28 if (CollectionUtils.isNotEmpty(parentComment.getChildCommentPage().getRecords())) {
29 // 用户评论点赞状态
30 Map<Long, Boolean> childItemIdToLikeMap = likeService.getItemLikeState(parentComment.getChildCommentPage().getRecords().stream().map(CommentVO::getId).collect(Collectors.toList()), CommentTypeEnum.POST_COMMENT);
31 // redis 中评论点赞数量
32 Map<Long, Integer> childIdToLikeCountMap = likeService.getItemLikeCount(parentComment.getChildCommentPage().getRecords().stream().map(CommentVO::getId).collect(Collectors.toList()), CommentTypeEnum.POST_COMMENT, Boolean.FALSE)
33 .getItemLikeCount();
34
35 Set<Long> replyIds = new HashSet<>();
36 parentComment.getChildCommentPage().getRecords().forEach(childComment -> {
37 childComment.setLike(childItemIdToLikeMap.get(childComment.getId()));
38 childComment.setLikeCount(childComment.getLikeCount() + childIdToLikeCountMap.get(childComment.getId()));
39
40 if (Objects.nonNull(childComment.getReplyId())) {
41 replyIds.add(childComment.getReplyId());
42 }
43 });
44
45 // 回填二级评论的 回复信息(用户id)
46 if (CollectionUtils.isNotEmpty(replyIds)) {
47 Map<Long, PostCommentChild> replyIdMap = postCommentChildService.lambdaQuery()
48 .select(PostCommentChild::getId, PostCommentChild::getUserId, PostCommentChild::getContent)
49 .in(PostCommentChild::getId, replyIds)
50 .list().stream().distinct().collect(Collectors.toMap(PostCommentChild::getId, item -> item));
51
52 parentComment.getChildCommentPage().getRecords().forEach(childComment -> {
53 if (Objects.nonNull(childComment.getReplyId())) {
54 CommentVO.ReplyInfo replyInfo = new CommentVO.ReplyInfo();
55 replyInfo.setUserId(replyIdMap.get(childComment.getReplyId()).getUserId());
56 replyInfo.setContent(replyIdMap.get(childComment.getReplyId()).getContent());
57 childComment.setReplyInfo(replyInfo);
58 }
59 });
60 }
61
62 }
63 });
64
65 return vo;
66}
CommentPageRequest 对象
1
2public class CommentPageRequest extends QueryPage {
3
4 /**
5 * 评论条目id
6 */
7 (message = "评论条目id不为空")
8 private Long itemId;
9
10 /**
11 * 该条目之前的数据,分页情况下
12 */
13 private Long itemIdBefore;
14
15 /**
16 * 评论类型
17 */
18 (message = "评论类型不为空")
19 private CommentTypeEnum type;
20
21 /**
22 * 评论的父级评论id
23 */
24 private Long parentId;
25}
这样,我们就实现了一个一级评论的分页查询,并且该列表数据也会顺带的把二级评论也查询出来。
那紧接着,如果我想要对查出来的二级评论进行分页查询呢?显然上面的逻辑就不行了,因为它是查询一级评论顺带把二级评论查出来一页而已。
而我们现在是要对一级评论的二级评论进行分页查询,所以就要重新写一个二级评论的分页接口了,其主要实现逻辑如下:
1
2public IPage<CommentVO> towCommentPage(CommentPageRequest request) {
3 IPage<CommentVO> voiPage = postCommentChildService.twoCommentPage(request);
4
5 // 回填二级评论的 回复信息(用户id)
6
7 // 获取被回复的评论 id 集合
8 Set<Long> replyIds = voiPage.getRecords().stream().map(CommentVO::getReplyId)
9 .filter(Objects::nonNull).collect(Collectors.toSet());
10 if (CollectionUtils.isEmpty(replyIds)) {
11 return voiPage;
12 }
13
14 Map<Long, PostCommentChild> replyIdMap = postCommentChildService.lambdaQuery()
15 .select(PostCommentChild::getId, PostCommentChild::getUserId, PostCommentChild::getContent)
16 .in(PostCommentChild::getId, replyIds)
17 .list().stream().distinct().collect(Collectors.toMap(PostCommentChild::getId, item -> item));
18
19 voiPage.getRecords().forEach(childComment -> {
20 if (Objects.nonNull(childComment.getReplyId())) {
21 CommentVO.ReplyInfo replyInfo = new CommentVO.ReplyInfo();
22 replyInfo.setUserId(replyIdMap.get(childComment.getReplyId()).getUserId());
23 replyInfo.setContent(replyIdMap.get(childComment.getReplyId()).getContent());
24 childComment.setReplyInfo(replyInfo);
25 }
26 });
27 return voiPage;
28}
至此,我们的评论功能就完成了,如果对以上评论的设计与实现有任何疑问,或者不足的点,欢迎评论区讨论。