[toc]
前言
前段时间,我写了一个基于React+Express+MongoDB
的个人网站。frmachao.top,网站上有个叫做 知拾 的单页面应用,它主要是一个知识库类型的应用,人人都可以往里面记录自己认为有用的知识。
现在我想要添加对知识的评论功能
项目开发环境
- MongoDB 4.4.8
- Express 4.17.1
- React 16.13.1
知拾应用评论区的功能
- 支持嵌套评论,最大深度2层
- 支持精简版的 markdown 语法
- 那么评论提交的应该是html 而不是 text,(不支持编辑)
- 评论只能删除不能修改
- 评论应该支持分页
- 子评论也要支持分页
- 增加新评论时邮件通知
知识作者
或者被回复人
参考 B 站评论区
数据库字段设计
如图所示:Article、 Comment 、User
三个集合,通过MongoDB 的 ref 将三个集合关联起来。
对应到 Mongoose 中的评论模型
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)
- 添加
- 添加父评论
- 添加子评论
- 查询
- 获取父评论列表接口
- 获取子评论列表接口
- 删除
- 删除父评论
- 删除子评论
实际项目中对应的代码
Tips 项目中我使用了 GraphQL 接口风格 什么是Graphql
/**
* 处理 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]);
}
创建父评论:
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 };
},
现在我们添加一条父评论测试一下接口:
然后去数据库中查看:
创建子评论:主要通过 $push
操作符实现
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: "添加成功" };
},
现在我们添加一条子评论测试一下接口:
然后去数据库中查看:
读取(Retrieve)、和删除(Delete) 的接口类似,我这里就不贴了..
- 子评论分页主要通过
$slice
和$size
操作符实现 - 删除子评论 通过
$pull
操作法实现
不要相信前端传来的数据!
'author.role'的值应通过查询文章获取作者id 和 当前登陆用户id 在后端比较出来的, 同理 reply_to_author.role 也是一样。
遇到的问题
MongoDB 对嵌套的数组对象使用 $lookup操作法
因为 Comment 集合中的 child_comments 字段是一个数组对象
child_comments:[
{author:{user,role},reply_to_author,....}
]
当我做通过 $lookup 操作符做多表关联查找操作时,child_comments 中的字段会被覆盖
// 执行
{
$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 操作
{
$unwind: {
path: "$child_comments",
preserveNullAndEmptyArrays: true,
},
},
{
$lookup: {
from: "user",
localField: "child_comments.author.user",
foreignField: "id",
as: "child_comments.author.user",
},
},
最后 通过 group
重新生成数组
{
$group: {
_id: "$_id",
datumID: {
$first: "$datumID",
},
html: {
$first: "$html"
},
child_comments: {
$push: "$child_comments",
},
},
},
附上演示地址:MongoDB playground
数组中的字段不一定存在时 该如何判断
因为我要统计每条父评论下一共有多少子评论,来实现子评论的分页查询操作,这个主要通过 $size操作符来实现
$addFields: {
child_total: {
$size: "$child_comments"
},
},
但是上面代码执行后的结果并不是我预期的,因为之前在做lookup
操作时,如果 child_comments
字段为空就会得到这样的结果
{
"_id": ObjectId("5a934e000102030405000002"),
"child_comments": [
{
"author": {
"user": []
}
}
],
"datumID": null,
"html": "这是父评论2"
}
显然像这样的话,child_comments
数组的长度就会跟实际子评论数量不同,所以在计算child_total 要额外判断。
一开始我使用 $type 操作符 在聚合操作中做判断:
{
"$addFields": {
"child_total": {
$type: "$child_comments.author"
}
}
}
// 得到 child_total 居然是 array
翻阅文档后得知在聚合阶段 与type 聚合运算符不检查数组元素。相反,当传递一个数组作为其参数时,$type聚合运算符返回参数的类型,即"array"。
既然这样我就换种思路:
- 先将计算 child_total 字段的操作提前到 数组 lookup 操作之前
- 在
group
操作重新生成数组时,对child_comments
做如下处理
{
$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:
[],
},
},
},
},
这样就能得到我想要的结构了,我们测试一下
[
{
"_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 组件
- markdown-it Markdown 解析器
- 精简版 Markdown 不支持 htm 标签解析
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
},
});