本文最后更新于:
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 站评论区
数据库字段设计
如图所示:Article、 Comment 、User
三个集合,通过MongoDB 的 ref 将三个集合关联起来。
对应到 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({ 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 }, author: { user: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }, role: { type: String, required: true, default: "normal" }, }, create_time: { type: Date, default: Date.now }, update_time: { type: Date, default: Date.now }, child_comments: [ { author: { user: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }, role: { type: String, required: true, default: "normal" }, }, reply_to_author: { user: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }, role: { type: String, required: true, default: "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
1 2 3 4 5 6 7 8 9
|
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 }; },
|
现在我们添加一条父评论测试一下接口:
然后去数据库中查看:
创建子评论:主要通过 $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; 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 字段是一个数组对象
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": { "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" } } }
|
翻阅文档后得知在聚合阶段 与$type根据 BSON 类型匹配数组元素的查询运算符不同,$type 聚合运算符不检查数组元素。相反,当传递一个数组作为其参数时,$type聚合运算符返回参数的类型,即”array”。
既然这样我就换种思路:
- 先将计算 child_total 字段的操作提前到 数组 lookup 操作之前
- 在
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 组件
- markdown-it Markdown 解析器
- 精简版 Markdown 不支持 htm 标签解析
1 2 3 4 5 6 7 8 9 10 11 12
| const mdParserComment = MarkdownIt({ html: false, linkify: true, typographer: true, highlight: function (str, lang) { if (lang && hljs.getLanguage(lang)) { return hljs.highlight(str, { language: lang }).value; }
return ""; }, });
|
最后
我的个人网站–》知拾—》评论 欢迎大家使用。