前言
很多与社交,电商等等相关的平台都需要 评论功能,那么如何设计,来实现高性能的评论服务呢?
- 本篇文章将从思维上对评论服务的性能拓展进行优化,并从数据库设计开始一步步构建。
- 文章篇幅较长,需要耐心地阅读理解,相信认真阅读的你一定能从文章中学到很多东西。
- 有不理解的地方可以发个评论提问,看到就会回复解释。
先上示例图:
需求分析
我们先对评论功能进行需求分析,来明确我们需要实现的有哪些东西。
- 首先评论有两种类型,一种是 根评论(即楼主) ,一种是回复评论(子评论,跟在楼主下面的评论) ;
- 每条评论包含评论用户信息的获取;(或者回复用户的信息)
- 前端渲染需要辨别并获取到评论的子评论;
- 评论查询的排序规则;
- 是否有需要热评的方案。
数据库设计
这里我提供一个参考图,以一个商品下的评论来举例:
字段及用途解释
- goods_id:表示商品id,也是一个查询单位的 id,将 通过此 id 查询出所有需要处理的评论,将被列为同一个评论集合里。
- user_id:评论用户的id,可以用来查询 我的评论 等功能,将商品下面的评论区也将用来查询用户信息(包含头像、名称等信息)。
- to_user_id:被回复的用户id,可以用来查询 回复我的评论 等功能,在评论区中同样用来查询用户信息。
- root_id:来表明本条评论的 根评论 是哪一条。将根据此条来 分配 子评论 应该在哪条根评论下方。
- hot:如果涉及到 热评 等功能,可以根据此字段实现。
- type : 用来申明本条评论是什么类型的评论,将在实现评论的查询逻辑中使用。这里使用枚举值 root | answer 来表示 根评论 or 子评论。
- create_time:创建评论的时间,除了用来显示也将在查询逻辑中使用。
评论区查询代码示例
业务逻辑
首先我们要知道评论区的评论排序规则(因为知道这个才能正常编写我们的业务逻辑),其实每个 app的评论区都有自己的规则,我们就按照以下方案来举例。
- 如果 最新的评论我们想优先看到(也就是出现在上面的根评论是最新的) 。 且假定 时间流向为正向。
- 我们以 子评论为顺序(新的回复评论在下面) 来查询。这样评论会更加可读有逻辑。每条根评论的子评论是按时间顺序的实现。
我们用一张图来直观展示上诉策略:(注意各个评论的时间)
使用到的 VO 对象
大概简单看下评论区的显示的两个 VO对象:
/**
* 获取商品评论信息
*
* @param goodsId 商品id
* @return 评论信息
*/
List<CommentVO> getGoodsComments(Integer goodsId);
// 为一个根评论的集合
/**
* 商品根评论实体
*/
@Data
@NoArgsConstructor
public class CommentVO {
Integer id;
Integer goodsId;
/**
* 评论用户信息
*/
UserSimpleVO userInfo;
Integer rootId;
String content;
Integer hot;
String type;
Timestamp createTime;
/**
* 该评论下的子评论
*/
List<CommentAnswerVO> answerCommentList;
}
/**
* 商品回复评论实体
*/
@Data
@NoArgsConstructor
public class CommentAnswerVO {
Integer id;
Integer goodsId;
/**
* 评论用户信息
*/
UserSimpleVO userInfo;
/**
* 回复的用户信息,为根评论时此项为null
*/
UserSimpleVO answerUserInfo;
Integer rootId;
String content;
Integer hot;
String type;
Timestamp createTime;
}
代码示例了两个对象。CommentVO、CommentAnswerVO。
- CommentVO表示根评论,所以属性中,有一个List 属性。
注意:这里的List 指的是在条评论下面进行的所有用户的交互回复 ,而不仅仅只是回复楼主的(这点跟大部分评论区实现类似)。
- CommentAnswerVO 对象 大致与根评论对象相同,主要少了 回复列表 属性。
所以,如果返回一整个评论区列表。主要就是返回一个 List 对象。
难点分析
所以主要需要处理的难点在于查询,其它操作(增、删、改)正常来,注意要填充的字段就行
- 评论以外的信息高效查询(如:在微服务项目中分库后不能进行联表查时,用户信息的查询。后文代码示例中会考虑到)
- 根评论、子评论按照时间正确排序后赋值到根评论的属性中。
反向示例
如果以逻辑比较简单的情况来写,我们可以一次先按时间逆序来查询所有根评论存入集合,也就是
select * from comment where type='root' order by create_time DESC
在查出所有的根评论之后再根据根评论id进行时间顺序查找 子评论,也就是
select * from comment where root_id = ? order by create_time ASC
如果是这样来操作的话,也是可以实现评论区的查询。但这样的话,只要根评论有几条,那么查询数据库的次数就是有 n+1 次,这会让数据库的压力增大。
有的同学可能会认为,不能结合 DESC 和 ASC 直接一次完成查找吗?
答案是:还真不行,一次查询需要进行两次排序 的 字段是相同的,这是不能使用 sql 来完成的
优化措施
刚才所讲的问题也就是查询次数过多的问题。那应该如何应对呢?
其实在介绍数据库字段的时候已经埋下伏笔,我们只需要查出所有符合条件的评论集合,在Java程序中再来做这个排序的工作即可。
这里直接查询所有该商品下的评论区评论,如
select * from comment where goods_id = ?
这里需要使用到一个逻辑:
在没有排序的情况下,也就是默认按照 主键 id(自增策略)来排序。那么优先创建的评论id也一定更小,也就是时间更早的评论id也更小。
那么根据这一点,接下来,我们可以借助一个 map(为什么用map,后文有解释) 来完成 子评论的赋值。
代码示例
@Override
public List<CommentVO> getGoodsComments(Integer goodsId) {
LambdaQueryWrapper<Comment> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Comment::getGoodsId, goodsId).orderByAsc(Comment::getCreateTime);
// 查询出属于该帖子的所有评论
List<Comment> comments = query().getBaseMapper().selectList(wrapper);
// 如果为空返回空列表
if (comments.size() == 0) {
return new ArrayList<>();
}
// 统计用户id,并进行去重
Set<Integer> userIdSet = new HashSet<>();
for (Comment comment : comments) {
userIdSet.add(comment.getUserId());
}
// 查询出所有有关的用户信息
List<Integer> userIds = new ArrayList<>(userIdSet);
Map<Integer, UserSimpleVO> userInfo = userClient.getUserDeatilInfoMap(userIds).getData();
List<CommentVO> rootComment = new LinkedList<>();
// 创建关联map,key为根id,值为 子评论集合
Map<Integer, List<CommentAnswerVO>> answerCommentMap = new HashMap<>(10);
Map<CommentVO, Comment> linkMap = new HashMap<>(10);
for (Comment comment : comments) {
// 倒序存入根评论,正序存入回复
if (comment.getType().equals(CommentType.ROOT.getValue())) {
CommentVO commentVO= new CommentVO();
BeanUtils.copyProperties(comment, commentVO);
// 设置用户信息
commentVO.setUserInfo(userInfo.get(comment.getUserId()));
// 从头部插入根评论,并创建map
rootComment.add(0, commentVO);
answerCommentMap.put(comment.getRootId(), new ArrayList<>());
linkMap.put(commentVO, comment);
} else {
// 时间排序的集合可以保证已经创建了根评论的map
List<CommentAnswerVO> commentAnswerVOList = answerCommentMap.get(comment.getRootId());
CommentAnswerVO commentAnswerVO = new CommentAnswerVO();
BeanUtils.copyProperties(comment, commentAnswerVO);
// 设置用户信息和回复用户信息
commentAnswerVO.setUserInfo(userInfo.get(comment.getUserId()));
commentAnswerVO.setAnswerUserInfo(userInfo.get(comment.getToUserId()));
commentAnswerVOList.add(commentAnswerVO);
}
}
// 第二次遍历将子评论赋值到根评论
for (Map.Entry<CommentVO, Comment> entry : linkMap.entrySet()) {
entry.getKey().setAnswerCommentList(answerCommentMap.get(entry.getValue().getRootId()));
}
return rootComment;
}
整体业务逻辑
结合上诉代码过这个流程
- 查询出商品所有的评论,如果没有评论直接返回空集合。
- 统计用户id,如果是微服务项目,用户信息不能在本服务直接获取。就需要暴露一个client来获取批量用户信息,返回结果为一个 key为userId,value为用户信息的 map,便于后期赋值评论对象的用户信息。如果不是微服务也是同理,使用 userService获取用户信息。
为什么这么做呢?同样是避免多次查询数据库,因为在同一个评论区中,很多评论是同一个人发的,提前查好所有不同人的信息,(在这种情况下,已经已知了评论用户或者回复的用户id)我们就可以直接使用 userInfoMap.get(userId) 来获取用户信息了,同样大大减轻了数据库压力。
- 接下来创建 List ,表示整个评论区(也是本接口 要响应的内容)
(这部分是本个业务逻辑最难理解的部分,细细查看多加思考)
- 创建 Map<Integer, List> key为 根评论id,value为子评论列表
这个 map 及 map 的值 list。将在第一次遍历到遇到根评论时创建。接下来 子评论 也将再这里逐一添加到列表当中。
注意:子评论在进行添加到列表时,这个列表一定在遍历到改评论时之前创建了。因为根评论一定会比 回复它的评论优先创建。
- 创建 关联 linkMap,用于后面根评论赋值 CommentAnswerVO属性。
Map<CommentVO, comment> linkMap
我们在添加 根评论 CommentVO 的那次遍历中,也添加了 这个 map的一个值
- 遍历最开始获取的所有评论集合。讲评论分类并放入具体的集合类中。
- 通过 entrySet 设置 CommentAnswerVO属性,完成子评论填充。
这样整个评论区对象 List 就完成啦,直接响应给前端就可以了。
前端只需要根据 type 字段 判断这是个什么类型的评论 直接完成渲染工作就能轻松渲染了。
前端模板
该模板主要提供一个渲染思路(两层 for 循环实现),可以根据自己的情况对代码进行修改。
<template>
<!-- 评论区-->
<div class="comment">
<h1 style="margin-left: 100px;">商品评论区</h1>
<div v-for="item in commentInfo" :key="item">
<div class="daa">
<img class="bigAvatar" :src="item.userInfo.avatarUrl">
<div style="width: 1000px;">
<div class="dba">
<h3>{{ item.userInfo.nickname }}</h3>
<p>{{item.createTime.substring(0,19)}}</p>
</div>
<div class="dba">
<p style="margin-top: 20px;">{{ item.content }}</p>
<p @click="sendContent('answer', item.id, item.userInfo.id)">回复</p>
</div>
</div>
</div>
<div class="daa" style="margin: 20px 50px;" v-for="answer in item.answerCommentList" :key="answer">
<img class="smallAvatar" :src="answer.userInfo.avatarUrl">
<div style="width: 1000px;">
<div class="dba">
<h3>{{ answer.userInfo.nickname }} 回复 {{ answer.answerUserInfo.nickname}}</h3>
<p>{{answer.createTime.substring(0,19)}}</p>
</div>
<div class="dba">
<p style="margin-top: 20px;">{{ answer.content }}</p>
<p @click="sendContent('answer', item.id, answer.userInfo.id)">回复</p>
</div>
</div>
</div>
</div>
<div class="dba" style="width: 80%;margin: 20px auto">
<img :src="userInfo.avatarUrl" class="smallAvatar">
<el-input placeholder="输入评论内容" v-model="content" style="width: 700px;"></el-input>
<el-button type="primary" @click="sendContent('root')">发送评论</el-button>
</div>
</div>
</div>
</template>
<script>
import {getComment} from "@/api/product";
import {sendComment} from "@/api/comment";
export default {
name: "DetailInfo",
data() {
return {
// 评论信息
commentInfo: '',
// 用户信息
userInfo: JSON.parse(localStorage.getItem('userInfo')),
// 发送评论内容
content: ''
}
},
mounted() {
this.showComment();
},
methods: {
showComment(id){
getComment(id).then(res =>{
this.commentInfo = res.data.data
})
},
sendContent(type, rootId, toUserId){
if(type === 'answer'){
this.$prompt('输入回复内容', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
}).then(({ value }) => {
if(value === '' || value === null){
this.$message.error('评论内容不能为空')
return;
}
let formData = new FormData()
formData.append('goodsId', this.detailInfo.goodsView.id)
formData.append('content', value)
formData.append('type', type)
if(type === 'answer'){
formData.append('rootId', rootId)
formData.append('toUserId', toUserId)
}
sendComment(formData).then(res =>{
console.log('sendComment ==>', res)
this.$message.success('评论成功')
this.content = ''
this.refresh()
})
}).catch(() => {
this.$message({
type: 'info',
message: '取消输入'
});
});
return;
}
if(this.content === ''){
this.$message.error('评论内容不能为空')
return;
}
let formData = new FormData()
formData.append('goodsId', this.detailInfo.goodsView.id)
formData.append('content', this.content)
formData.append('type', type)
if(type === 'answer'){
formData.append('rootId', rootId)
formData.append('toUserId', toUserId)
}
sendComment(formData).then(res =>{
console.log('sendComment ==>', res)
this.$message.success('评论成功')
this.content = ''
})
}
}
}
</script>
<style lang="less" scoped>
.bigAvatar{
width: 100px;
height: 100px;
border-radius: 50%;
}
.smallAvatar{
width: 60px;
height: 60px;
border-radius: 50%;
}
</style>
方案劣势
认真阅读完的小伙伴们我想应该 get 到笔者的方案中实现评论的精华了吧。当然实现的方案肯定不只这一种,主要是对思维上的提升才是对我们最有帮助的。
相信大家也看出来了这个方案的劣势,这个方案会一次直接将所有相关的评论都查出来,然后进行排序和赋值操作。如果评论过多,效率也会变低。所以本文提供的方案更适合一些小型网站,评论不会太多的系统实现。
那么如何优化呢?
主要思路就是:
-
对根评论进行分页获取,不要一次加载所有的根评论
-
子评论另外加载,先加载根评论,然后如果用户有兴趣,再触发加载子评论的事件。如果是此方案的话,可以在数据库中增加一个字段来记录该根评论下有多少子评论。这样前端能够更好地给予用户提示。这样做业务逻辑也会简单很多。
-
如果再细一些,为避免子评论过多,子评论也是需要进行分页操作的。
前两种方案结合起来,对于性能以及灵活性也都会有更好地提升。
总结起来就是我们不仅要实现功能,还要尽可能地提高程序的效率,这样无论是对服务器压力,还有用户体验来讲都能有更好的改善。
最后可以看看我的开源项目: i集大校园(类似于一个定位为校园里的微博)
i集大校园软件服务端,基于SpringCloud Alibaba 微服务组件及部分分布式技术实现服务之间关联及协作进行前后端分离项目实现。计划实现微信小程序和app两端同步。
使用技术栈为:Spring Boot、Spring Cloud Alibaba、rabbitMQ、JWT、minIO、mysql、redis、ES、docker、Jenkins、mybatis-plus
前端使用 微信小程序编写。
欢迎一起参加开源贡献和star项目哈!