作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO
联系qq:184480602,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬
上一篇提到树形结构是非常经典的一种表设计模式,看似平平无奇,实则包罗万象。今天,我们借助“用户评论”的需求,再来领略一把树形结构的魅力。
下面是两张评论相关的截图,请大家观察一下结构上有什么不同:
第一张图的评论形式俗称“二级评论”,第二张图俗称“盖楼”。
“二级评论”和“盖楼”最大的不同是 :
二级评论只需要关注当前评论的上一级,而“盖楼”则需要把当前评论之前的所有评论按顺序展示出来。
没有太多表设计经验的同学可能已经晕了:我上面两张图啥区别都还没整明白呢,被你这么一说,更晕了。
我们逐个分析。
先看“盖楼”。我们把“我是煎饼侠”的上一条评论也显示出来:
“我是煎饼侠”的评论其实是对“bravo1988”评论的评论,但它并没有直接回复“bravo1988”,而是另起一层并把前面的评论引用过来,然后在最下面显示自己的评论内容。
再看“二级评论”:
针对“谢函”的“想咨询一个...”的评论,“程大治”是直接在该评论下显示自己的评论内容,并不会另起一层(用户Shayne_xxy那种才叫另起一层)。整个评论区的所有评论其实就两大类:一级评论、二级评论。
关于“一级评论”、“二级评论”的定义:
上图中,“想咨询一个...”和“这个好”属于一级评论,“程大治”的两条评论属于二级评论(大家移上去再看一遍)。一级评论下无论再怎么复杂(A评论B,B评论A,C评论B...),都是二级评论,而不是三级评论、四级评论。
如果把上图改为“盖楼”,就是这样:
“盖楼”和“二级评论”两种评论形式看起来好像大相径庭,其实数据库表设计是差不多的,区别在SQL查询以及前端展示,其中“盖楼”的难度要大一些。
大家也看到了,“盖楼”这种形式的评论不如“二级评论”来得直观,且实现较为复杂(每一条评论都要找到在它前面的所有评论,d评论找c评论,c评论找b评论,最终找到a评论),所以现在已经很少采用 “盖楼”的评论形式了。
本文主要讨论如何设计“二级评论表”。
请大家停下来重新观察并思考:如果让你来做这个需求,你会如何设计表结构,后端大概需要返回哪些字段呢?
我们最直观的感受是:既然评论总共有2级,那么我们设计两张表吧,用逻辑外键关联一级评论表和二级评论表。
但实际上,这个问题可以用一张表“自关联”解决,只需要在表中设计一个pid,让secondLevel.pid = firstLevel.id即可。
所以,结论是二级评论可以用一张表解决。
这个问题,其实是从前端展示的角度提出来的。我们知道,数据库存放的评论都是一条条独立的:
怎么最终在前端展示成这样呢:
还是树形结构,“二级评论”的树只有两级:
归根到底,用户评论这个需求还是对树形结构的实际应用。二级评论可以通过pid找到自己所属的一级评论,页面展示时,先遍历一级评论,直接展示在文章下方,再遍历一级评论的子评论(replies),把二级评论展示在一级评论下方即可。至于某个一级评论下的排序,可以默认id排序(一般等于时间排序)。如果有其他需要,可自定排序规则。
现在只剩最后一个问题了,怎么处理张三@李四这种展示效果?比如下图:
无论一级评论还是二级评论,除了展示评论本身,还要展示评论相关的用户信息、评论时间等。
而二级评论还多了一个属性:这条评论是谁对谁的回复。通常会用 “A 回复 B:xxx”或者“张三:@李四 xxx”这两种形式。
换句话说,后端接口需要返回:评论、用户(评论的作者)、评论时间、对谁的回复(被评论人)。
你可能会想,一级评论和二级评论的字段好像不同啊,二级评论还多了个“谁对谁的回复”,果然,还是要拆开两张表。
其实不用,设计评论表时统一设计content、user_id、to_user_id、create_time字段即可,一级评论如果to_user_id用不上,可以空着(也可以认为一级评论是对文章作者的回复,或者干脆to_user_id字段设置为0或null)。
总之,同一张表在兼容多种类型时,应该以多的一方考虑。在表设计时,可以“多退”(用不上就空着呗),但不能“少补”(没有就真的没有了,除非修改表结构)。
这里给出较为可行的表设计:
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for t_comment
-- ----------------------------
DROP TABLE IF EXISTS `t_comment`;
CREATE TABLE `t_comment` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '评论id',
`pid` int(11) DEFAULT NULL COMMENT '所属一级评论的id,如果当前评论为一级,则为0',
`target_id` int(11) NOT NULL COMMENT '评论所属文章id',
`content` varchar(255) COLLATE utf8mb4_bin NOT NULL COMMENT '评论内容',
`user_id` varchar(255) COLLATE utf8mb4_bin NOT NULL COMMENT '该条评论的作者',
`to_user_id` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '对谁回复,一级评论可以为null',
`likes_count` int(11) DEFAULT '0' COMMENT '当前评论的点赞数',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `key_target_id` (`target_id`) USING BTREE,
KEY `key_pid` (`pid`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- ----------------------------
-- Records of t_comment
-- ----------------------------
BEGIN;
INSERT INTO `t_comment` VALUES (1, 0, 10086, '这是第一条评论。', 'zhangsan', NULL, 1, '2020-03-17 11:06:39', '2020-03-17 14:06:13');
INSERT INTO `t_comment` VALUES (2, 0, 10086, '这是第二条评论。', 'lisi', NULL, 0, '2020-03-17 11:08:10', '2020-03-17 14:06:16');
INSERT INTO `t_comment` VALUES (3, 2, 10086, '你好啊,第二条评论。', 'zhangsan', 'lisi', 2, '2020-03-17 11:08:56', '2020-03-17 11:43:37');
INSERT INTO `t_comment` VALUES (4, 2, 10086, '哇,谢谢你的回复!', 'lisi', 'zhangsan', 0, '2020-03-17 11:09:57', '2020-03-17 12:02:40');
INSERT INTO `t_comment` VALUES (5, 0, 10086, '楼上两个细佬...', 'wangwu', NULL, 0, '2020-03-17 11:10:24', '2020-03-17 14:06:20');
INSERT INTO `t_comment` VALUES (6, 2, 10086, '回复一下而已,需要这么激动吗...', 'zhaoliu', 'lisi', 1, '2020-03-17 11:11:40', '2020-03-17 12:02:48');
COMMIT;
SET FOREIGN_KEY_CHECKS = 1;
为了方便展示时理清关系,这里我把user_id设置为VARCHAR,这样user_id就可以填入zhangsan、lisi,直观一些。
@Data
@Table(name = "t_comment")
public class Comment {
/**
* 评论id
*/
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY, generator = "SELECT LAST_INSERT_ID()")
private Integer id;
/**
* 所属一级评论的id,如果当前评论为一级,则为0
*/
private Integer pid;
/**
* 评论所属文章id
*/
@Column(name = "target_id")
private Integer targetId;
/**
* 评论内容
*/
private String content;
/**
* 该条评论的作者
*/
@Column(name = "user_id")
private String userId;
/**
* 对谁回复,一级评论可以为null
*/
@Column(name = "to_user_id")
private String toUserId;
/**
* 当前评论的点赞数
*/
@Column(name = "likes_count")
private Integer likesCount;
/**
* 创建时间
*/
@Column(name = "create_time")
private Date createTime;
/**
* 更新时间
*/
@Column(name = "update_time")
private Date updateTime;
/**
* 该评论下的回复,非数据库字段,用 @Transient
*/
@Transient
private List<Comment> replies = new ArrayList<>();
}
public interface CommentMapper extends Mapper<Comment> {
}
@Service
public class CommentService {
@Autowired
private CommentMapper commentMapper;
public List<Comment> getAllCommentsByTargetId(Integer targetId) {
Example example = new Example(Comment.class);
Example.Criteria criteria = example.createCriteria();
criteria.andEqualTo("targetId", targetId);
example.setOrderByClause("id asc");
List<Comment> commentList = commentMapper.selectByExample(example);
return commentList;
}
}
@SpringBootApplication
@MapperScan("com.bravo")// 记得加扫描
public class SpringbootDemoApplication {
public static void main(String[] args) {
SpringApplication.run(SpringbootDemoApplication.class, args);
}
}
@RunWith(SpringRunner.class)
@SpringBootTest
public class CommentTest {
@Autowired
private CommentService commentService;
@Autowired
private ObjectMapper objectMapper;
@Test
public void testComment() throws JsonProcessingException {
// =========查出targetId下所有评论(一篇文章下的所有评论)==========
List<Comment> commentList = commentService.getAllCommentsByTargetId(10086);
// =========对平铺数据进行嵌套整理==========
// 最终结果
List<Comment> result = new ArrayList<>();
// list转map,建立索引
Map<Integer, Comment> commentMap = new HashMap<>();
for (Comment comment : commentList) {
commentMap.put(comment.getId(), comment);
}
// 嵌套数据
for (Comment comment : commentList) {
/**
* 归纳评论:对文章的评论是第一级,对文章的评论的评论是第二级,把第二级评论塞到对应的第一级评论下,作为replies
*
* 《静夜思》
* 床前明月光
* 疑似地上霜
* -----------------------
* a:第一级评论1
* a 回复 b:第二级评论1
* b 回复 a:第二级评论2
*
* c:第一级评论2
* c 回复 d:第二级评论3
* d 回复 c:第二级评论4
*/
if (comment.getPid() == 0) {
// 一级评论
result.add(comment);
} else{
// 二级评论,那么肯定有一级评论且firstComment一定不为null
Comment firstComment = commentMap.get(comment.getPid());
// 把二级评论塞到一级评论下
firstComment.getReplies().add(comment);
}
}
prettyPrint(result);
}
private void prettyPrint(List<Comment> commentList) throws JsonProcessingException {
System.out.println(objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(commentList));
}
}
结果展示
JSON返回值(顺序和层级结构已经处理好了,前端只要展示即可):
[
? ?{
? ? ? ?"id": 1,
? ? ? ?"pid": 0,
? ? ? ?"targetId": 10086,
? ? ? ?"content": "这是第一条评论。",
? ? ? ?"userId": "zhangsan",
? ? ? ?"toUserId": null,
? ? ? ?"likesCount": 1,
? ? ? ?"createTime": "2020-03-17T03:06:39.000+0000",
? ? ? ?"updateTime": "2020-03-17T06:06:13.000+0000",
? ? ? ?"replies": []
? ?},
? ?{
? ? ? ?"id": 2,
? ? ? ?"pid": 0,
? ? ? ?"targetId": 10086,
? ? ? ?"content": "这是第二条评论。",
? ? ? ?"userId": "lisi",
? ? ? ?"toUserId": null,
? ? ? ?"likesCount": 0,
? ? ? ?"createTime": "2020-03-17T03:08:10.000+0000",
? ? ? ?"updateTime": "2020-03-17T06:06:16.000+0000",
? ? ? ?"replies": [
? ? ? ? ? ?{
? ? ? ? ? ? ? ?"id": 3,
? ? ? ? ? ? ? ?"pid": 2,
? ? ? ? ? ? ? ?"targetId": 10086,
? ? ? ? ? ? ? ?"content": "你好啊,第二条评论。",
? ? ? ? ? ? ? ?"userId": "zhangsan",
? ? ? ? ? ? ? ?"toUserId": "lisi",
? ? ? ? ? ? ? ?"likesCount": 2,
? ? ? ? ? ? ? ?"createTime": "2020-03-17T03:08:56.000+0000",
? ? ? ? ? ? ? ?"updateTime": "2020-03-17T03:43:37.000+0000",
? ? ? ? ? ? ? ?"replies": []
? ? ? ? ? ?},
? ? ? ? ? ?{
? ? ? ? ? ? ? ?"id": 4,
? ? ? ? ? ? ? ?"pid": 2,
? ? ? ? ? ? ? ?"targetId": 10086,
? ? ? ? ? ? ? ?"content": "哇,谢谢你的回复!",
? ? ? ? ? ? ? ?"userId": "lisi",
? ? ? ? ? ? ? ?"toUserId": "zhangsan",
? ? ? ? ? ? ? ?"likesCount": 0,
? ? ? ? ? ? ? ?"createTime": "2020-03-17T03:09:57.000+0000",
? ? ? ? ? ? ? ?"updateTime": "2020-03-17T04:02:40.000+0000",
? ? ? ? ? ? ? ?"replies": []
? ? ? ? ? ?},
? ? ? ? ? ?{
? ? ? ? ? ? ? ?"id": 6,
? ? ? ? ? ? ? ?"pid": 2,
? ? ? ? ? ? ? ?"targetId": 10086,
? ? ? ? ? ? ? ?"content": "回复一下而已,需要这么激动吗...",
? ? ? ? ? ? ? ?"userId": "zhaoliu",
? ? ? ? ? ? ? ?"toUserId": "lisi",
? ? ? ? ? ? ? ?"likesCount": 1,
? ? ? ? ? ? ? ?"createTime": "2020-03-17T03:11:40.000+0000",
? ? ? ? ? ? ? ?"updateTime": "2020-03-17T04:02:48.000+0000",
? ? ? ? ? ? ? ?"replies": []
? ? ? ? ? ?}
? ? ? ?]
? ?},
? ?{
? ? ? ?"id": 5,
? ? ? ?"pid": 0,
? ? ? ?"targetId": 10086,
? ? ? ?"content": "楼上两个细佬...",
? ? ? ?"userId": "wangwu",
? ? ? ?"toUserId": null,
? ? ? ?"likesCount": 0,
? ? ? ?"createTime": "2020-03-17T03:10:24.000+0000",
? ? ? ?"updateTime": "2020-03-17T06:06:20.000+0000",
? ? ? ?"replies": []
? ?}
]
之前提到了过,每条评论除了内容本身,还有用户信息:头像、昵称、简介等等:
而上面为了简单,JSON返回值中只有userId,并没有用户头像、昵称及个人简介。
解决办法也简单,一般评论肯定会做分页,所以一次查询的数量是有限的,我们可以查询出commentList后,用之前封装的ConvertUtil#resultToList收集所有评论的userId,再调用UserService.listUserInfoByIdList()查询所有用户信息,此时内存中有commentList和userList,而它们都有userId,不用我说大家也知道怎么做啦。
我们来看看掘金网站是怎么做的:
我们发现,把鼠标移到任意用户头像上时,会弹出一个tab页显示用户的信息,并且仔细观察的话,此时并没有触发异步请求,说明是后端嵌套好的。
[
{
"id": 2,
"pid": 0,
"targetId": 10086,
"content": "想咨询一个问题,就是如何获取跟 listview 一样某一个 item 的 view?",
"userId": "xiehan",
"toUserId": null,
"likesCount": 0,
"userInfo": {
"objectId": "57ab4807d342d30057867209",
"username": "谢函",
"avatarLarge": "",
"selfDescription": "",
"jobTitle": "",
"company": "",
"viewedEntriesCount": 423,
"collectedEntriesCount": 64,
"level": 0,
"isFollow": false
},
"toUserInfo": null,
"createTime": "2020-03-17T03:08:10.000+0000",
"updateTime": "2020-03-17T06:06:16.000+0000",
"replies": [
{
"id": 3,
"pid": 2,
"targetId": 10086,
"content": "RecyclerView.getChildAt",
"userId": "chengdazhi",
"toUserId": "xiehan",
"likesCount": 2,
"userInfo": {
"objectId": "56a9a4941532bc005304ab60",
"username": "程大治",
"avatarLarge": "https://user-gold-cdn.xitu.io/2016/11/29/f74e01b6a8cb2ced5da81e1aceac5e40",
"selfDescription": "计算机视觉研究 前Android开发",
"jobTitle": "科研实习",
"company": "微软亚洲研究院",
"viewedEntriesCount": 497,
"collectedEntriesCount": 41,
"level": 0,
"isFollow": false
},
"toUserInfo": {
"objectId": "57ab4807d342d30057867209",
"username": "谢函",
"avatarLarge": "",
"selfDescription": "",
"jobTitle": "",
"company": "",
"viewedEntriesCount": 423,
"collectedEntriesCount": 64,
"level": 0,
"isFollow": false
},
"createTime": "2020-03-17T03:08:56.000+0000",
"updateTime": "2020-03-17T03:43:37.000+0000",
"replies": []
}
]
}
]
当鼠标触发hover事件时,直接从当前Comment中取出UserInfo展示。
当然,掘金的这种做法会使得同一个作者的多条评论中带有相同的UserInfo,前后端传递到数据有很大的冗余。
如果是你,会怎么改进呢?
作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO
进群,大家一起学习,一起进步,一起对抗互联网寒冬