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

3,139 阅读5分钟

[toc]

前言

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

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

项目开发环境

  • 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 中的评论模型

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

/**
 * 处理 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 };
    },

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

创建子评论:主要通过 $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: "添加成功" };
    },

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

读取(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根据BSON类型匹配数组元素的查询运算符不同,type根据 BSON 类型匹配数组元素的查询运算符不同,type 聚合运算符不检查数组元素。相反,当传递一个数组作为其参数时,$type聚合运算符返回参数的类型,即"array"。

既然这样我就换种思路:

  1. 先将计算 child_total 字段的操作提前到 数组 lookup 操作之前
  2. 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 组件

dD1EER

  • 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
    },
});

最后

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

zpqm2H