网站评论模块 mongodb 字段设计及实现

本文最后更新于: 2022年10月10日 下午

[toc]

前言

前段时间,我写了一个基于React+Express+MongoDB的个人网站。http://101.132.140.62/,网站上有个叫做 知拾 的单页面应用,它主要是一个知识库类型的应用,人人都可以往里面记录自己认为有用的知识。

现在我想要添加对知识的评论功能

项目开发环境

  • MongoDB 4.4.8
  • Express 4.17.1
  • React 16.13.1

知拾应用评论区的功能

  • 支持嵌套评论,最大深度2层
  • 支持精简版的 markdown 语法
    • 那么评论提交的应该是html 而不是 text,(不支持编辑)
  • 评论只能删除不能修改
  • 评论应该支持分页
    • 子评论也要支持分页
  • 增加新评论时邮件通知 知识作者 或者 被回复人

参考 B 站评论区

FTZrwc
haVB0N

数据库字段设计

如图所示:Article、 Comment 、User 三个集合,通过MongoDB 的 ref 将三个集合关联起来。
g2V5mw

对应到 Mongoose 中的评论模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
const CommentSchema = new mongoose.Schema({
// 评论所在的文章 id 关联 Article 集合
datumID: { type: mongoose.Schema.Types.ObjectId, required: true },
html: { type: String, required: true, validate: /\S+/ },
//预留字段 被赞数
likes: { type: Number, default: 0 },
//预留字段 被踩数
unlikes: { type: Number, default: 0 },
// 评论作者信息 关联 User 集合
author: {
user: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true },
role: { type: String, required: true, default: "normal" }, // author|normal
},
// 创建日期
create_time: { type: Date, default: Date.now },
// 最后修改日期
update_time: { type: Date, default: Date.now },
child_comments: [
{
// 谁在评论 关联 User 集合
author: {
user: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true },
role: { type: String, required: true, default: "normal" }, // author|normal
},
// 对谁评论 关联 User 集合
reply_to_author: {
user: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true },
role: { type: String, required: true, default: "normal" }, // author|normal },
},
html: { type: String, required: true, validate: /\S+/ },
// 被赞数
likes: { type: Number, default: 0 },
// 被踩数
unlikes: { type: Number, default: 0 },
// 创建日期
create_time: { type: Date, default: Date.now },
},
],
});

评论功能实现

因为没有编辑评论内容的需求,所以只要实现增加(Create)、读取(Retrieve)、和删除(Delete)

  1. 添加
    • 添加父评论
    • 添加子评论
  2. 查询
    • 获取父评论列表接口
    • 获取子评论列表接口
  3. 删除
    • 删除父评论
    • 删除子评论

实际项目中对应的代码

Tips 项目中我使用了 GraphQL 接口风格 什么是Graphql

1
2
3
4
5
6
7
8
9
/**
* 处理 async 函数中的异常
* @param promise
*/
export function awaitWrap<T, U = Error>(promise: Promise<T>): Promise<[U | null, T | null]> {
return promise
.then<[null, T]>((data: T) => [null, data])
.catch<[U, null]>((err) => [err, null]);
}

创建父评论:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
add: async (args: Iadd, context: any) => {
const { datumID, html, author } = args;
if (!context.req.user) {
throw "未登陆用户无权限";
}
// 查询文章获取文章相关信息
const [errArticle, result] = await awaitWrap<ArticleDocument>(
Article.findOne({ _id: datumID }, { author: 1, title: 1 })
.populate({ path: "author" })
.exec()
);
if (errArticle) throw "添加评论失败1";
const [err, newComment] = await awaitWrap(
Comment.create({
datumID,
html,
author: {
user: author,
role: getRole(author, result.author._id.toString()),
},
child_comments: [],
})
);
if (err) throw "添加评论失败2";
const { req } = context;
// 邮件通知作者 谁评论
const sendApplyJoinEmail = () => {
const transporter = nodemailer.createTransport({
service: "126",
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD,
},
});
const mailOptions = {
to: result.author.email,
from: process.env.SMTP_USER,
subject: `"${context.req.user.profile.name}"评论了你的知识《${result.title}》`,
html: `点击下面链接处理:
<p/>
<a href="${req.headers.origin}/datum/view?id=${datumID}">${req.headers.origin}/datum/view?id=${datumID}</a>`,
};
transporter.sendMail(mailOptions);
};
sendApplyJoinEmail();
return { status: "ok", msg: "添加成功", commentID: newComment._id };
},

现在我们添加一条父评论测试一下接口:
tbIzXv
然后去数据库中查看:
NO3YVi

创建子评论:主要通过 $push 操作符实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
addChild: async (args: IaddChild, context: any) => {
const { id, datumID, html, author, reply_to_author } = args;
if (!context.req.user) {
throw "未登陆用户无权限";
}
// 查询文章获取文章作者
const [errArticle, result] = await awaitWrap(
Article.findOne({ _id: datumID }, { author: 1, title: 1 })
.populate({ path: "author" })
.exec()
);
if (errArticle) throw "添加评论失败";

const [err] = await awaitWrap(
Comment.updateOne(
{ _id: id },
{
$push: {
child_comments: {
author: {
user: author,
role: getRole(author, result.author._id.toString()),
},
reply_to_author: {
user: reply_to_author,
role: getRole(reply_to_author, result.author._id.toString()),
},
html,
},
},
}
).exec()
);
if (err) throw "添加评论失败";
const { req } = context;
// 发邮件给 reply_to_author
const [errUser, data] = await awaitWrap<UserDocument>(
User.findOne({ _id: reply_to_author }).exec()
);
if (errUser) throw "添加评论失败";
const sendApplyJoinEmail = () => {
const transporter = nodemailer.createTransport({
service: "126",
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD,
},
});
const mailOptions = {
to: data.email,
from: process.env.SMTP_USER,
subject: `"${context.req.user.profile.name}"在《${result.title}》回复了你`,
html: `点击下面链接处理:
<p/>
<a href="${req.headers.origin}/datum/view?id=${datumID}">${req.headers.origin}/datum/view?id=${datumID}</a>`,
};
transporter.sendMail(mailOptions);
};
sendApplyJoinEmail();
return { status: "ok", msg: "添加成功" };
},

现在我们添加一条子评论测试一下接口:
1IuESr
然后去数据库中查看:
x8m6z5

读取(Retrieve)、和删除(Delete) 的接口类似,我这里就不贴了..

  • 子评论分页主要通过 $slice$size 操作符实现
  • 删除子评论 通过$pull操作法实现

不要相信前端传来的数据!

‘author.role’的值应通过查询文章获取作者id 和 当前登陆用户id 在后端比较出来的, 同理 reply_to_author.role 也是一样。

遇到的问题

MongoDB 对嵌套的数组对象使用 $lookup操作法

因为 Comment 集合中的 child_comments 字段是一个数组对象

1
2
3
4
child_comments:[
{author:{user,role},reply_to_author,....}
]

当我做通过 $lookup 操作符做多表关联查找操作时,child_comments 中的字段会被覆盖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 执行
{
$lookup: {
from: "users",
localField: "child_comments.author.user",
foreignField: "_id",
as: "child_comments.author.user",
},
}
// 实际返回的 child_comments 数组对象中 author.role 字段没有了
"child_comments": {
"author": {
"user": [
{
"_id": ObjectId("5a934e000102030405000003"),
"email": "frmachao@126.com",
"id": "user1",
"name": "frmachao"
}
]
}
}

附上演示地址:MongoDB playground

通过搜索引擎 检索 mongodb+ array + lookup 我找到了解决办法

在stack overflow上有个问题,跟我遇到的情况基本一样: mongodb-lookup-with-array

解决办法: 通过 unwind + $group 来做

先将数组打平,然后执行 lookup 操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
$unwind: {
path: "$child_comments",
preserveNullAndEmptyArrays: true,

},

},
{
$lookup: {
from: "user",
localField: "child_comments.author.user",
foreignField: "id",
as: "child_comments.author.user",

},

},

最后 通过 group 重新生成数组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
$group: {
_id: "$_id",
datumID: {
$first: "$datumID",

},
html: {
$first: "$html"
},
child_comments: {
$push: "$child_comments",

},

},

},

附上演示地址:MongoDB playground

数组中的字段不一定存在时 该如何判断

因为我要统计每条父评论下一共有多少子评论,来实现子评论的分页查询操作,这个主要通过 $size操作符来实现

1
2
3
4
5
6
$addFields: {
child_total: {
$size: "$child_comments"
},
},

但是上面代码执行后的结果并不是我预期的,因为之前在做lookup操作时,如果 child_comments字段为空就会得到这样的结果

1
2
3
4
5
6
7
8
9
10
11
12
{
"_id": ObjectId("5a934e000102030405000002"),
"child_comments": [
{
"author": {
"user": []
}
}
],
"datumID": null,
"html": "这是父评论2"
}

显然像这样的话,child_comments 数组的长度就会跟实际子评论数量不同,所以在计算child_total 要额外判断。

一开始我使用 $type 操作符 在聚合操作中做判断:

1
2
3
4
5
6
7
8
{
"$addFields": {
"child_total": {
$type: "$child_comments.author"
}
}
}
// 得到 child_total 居然是 array

翻阅文档后得知在聚合阶段 与$type根据 BSON 类型匹配数组元素的查询运算符不同,$type 聚合运算符不检查数组元素。相反,当传递一个数组作为其参数时,$type聚合运算符返回参数的类型,即”array”。

既然这样我就换种思路:

  1. 先将计算 child_total 字段的操作提前到 数组 lookup 操作之前
  2. group操作重新生成数组时,对 child_comments做如下处理
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    {
    $set: {
    child_comments: {
    $switch: {
    branches: [{
    case:
    {
    $in:
    ["normal", "$child_comments.author.role"],
    },
    then: "$child_comments",
    },
    {
    case:
    {
    $in:
    ["author", "$child_comments.author.role"],
    },
    then: "$child_comments",
    },
    ],
    default:
    [],
    },
    },
    },
    },
    这样就能得到我想要的结构了,我们测试一下
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    [
    {
    "_id": ObjectId("5a934e000102030405000001"),
    "child_comments": [
    {
    "author": {
    "role": "author",
    "user": [
    {
    "_id": ObjectId("5a934e000102030405000003"),
    "email": "frmachao@126.com",
    "id": "user1",
    "name": "frmachao"
    }
    ]
    }
    }
    ],
    "child_total": 1,
    "datumID": null,
    "html": "这是父评论1"
    },
    {
    "_id": ObjectId("5a934e000102030405000002"),
    "child_comments": [],
    "child_total": 0,
    "datumID": null,
    "html": "这是父评论2"
    }
    ]
    附上演示地址:MongoDB playground

前端评论框实现

  • Ant Design
    • Comment 组件
    • Pagination 组件
    • Popconfirm 组件
    • Form 组件

dD1EER

  • markdown-it Markdown 解析器
    • 精简版 Markdown 不支持 htm 标签解析
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      const mdParserComment = MarkdownIt({
      html: false, // 不支持html嵌套
      linkify: true, // 支持url生成a链接
      typographer: true,
      highlight: function (str, lang) {
      if (lang && hljs.getLanguage(lang)) {
      return hljs.highlight(str, { language: lang }).value;
      }

      return ""; // use external default escaping
      },
      });

      最后

我的个人网站–》知拾—》评论 欢迎大家使用。

zpqm2H