如何设计用户评论表

发布时间:2023年12月21日
作者简介:大家好,我是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,直观一些。

代码示例(通用Mapper)

Comment

@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<>();
}

CommentMapper

public interface CommentMapper extends Mapper<Comment> {
}

CommentService

@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

进群,大家一起学习,一起进步,一起对抗互联网寒冬
文章来源:https://blog.csdn.net/smart_an/article/details/135121716
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。