没有评论功能,就不能互动;虽然没多的人互动,但总得备着,万一有呢 😄
记录一下实现思路。实现了登录后发表评论,匿名发表评论,以及回复评论。
与相关模块关联
在 MongoDB 中比较提倡将相关的内容内嵌保存,例如可以将一片文章所有评论内容,作为一个数组与各个文章中保存在一起。这样做是可以做,但是考虑到我的评论是一个相对独立的功能,而且后面还有其他不同的模块可能都会有评论、留言功能,拎出来作为单独的模块来实现,结构清晰,便于管理,更加合适。
通过 kind
字段指定评论所属模块,kindId
关联该模块相关条目的 id,比如 { kind : "post" , kindId: ObjectId("5ffd90d683333600122e7f70")}
关联的是 post
中 id 为 5ffd90d683333600122e7f70
的内容
这里 kind
还单独列出一类为 general
,是为了用于一些没有对应模块的特殊页面,kind
为 general
的评论没有相应模块,也没有相应的条目 id,所以再新增加一个字段 identifier
来区别各个页面,目前这个 identifier
就交给前端来控制了。
记录评论的用户信息
小站为了降低评论门槛,允许匿名评论,只要填写评论内容就可以发表了。
所以这里再区分一下,如果用户登录了,将评论者的用户信息通过 createUser
关联上,如果未登录,则通过 anonymousInfo
保存评论者的用户信息
新增评论
如果新增评论,就不用传 parent
,如果是回复评论,通过 parent
指定要回复的评论 id
。
查的时候不存在 parent
都是一级评论,存在 parent
都是二级评论。
ps: 如果 parent
也是不是一级评论也是可以回复成功的,但是当前的查询语句不能查出来,没法展示。就只能通过前端控制,不提供回复二级评论的功能,哈哈 😂
评论查询
评论查询是当前功能的难点。
涉及到一些之前未接触过的查询语句,通过在聚合查询(aggregate
)中的 $lookup
使用管道 pipeline
并指定 $project
显性输出关联文档中需要的字段,而不要输出关联文档中所有字段。因为在 users
文档中,保存了用户的密码、用户邮箱等敏感信息,而这些信息在查看评论时候,并不需要。
实现源码
评论的 schema
const mongoose = require('mongoose');
const xss = require('xss');
const commentSchema = new mongoose.Schema({
// 评论内容
body: {
type: String,
required: true,
},
// 父评论id,没有父评论,就是一级评论
parent: {
type: mongoose.Types.ObjectId,
ref: 'Comment',
default: null,
},
// general 通用类型,不关联 kindId
kind: {
type: String,
required: true,
enum: ['general', 'post'],
},
kindId: {
type: mongoose.Types.ObjectId,
refPath: 'kind',
},
// 尽可能使用 kindId,当不存在 kindId 时,通过 identifier 作为某个页面、类别的标示
identifier: {
type: String,
},
// 是否为未登录用户
anonymous: {
type: Boolean,
default: false,
},
// 未登录用户的用户信息
anonymousInfo: {
username: {
type: String,
},
email: {
type: String,
},
url: {
type: String,
},
},
// 登录用户的用户信息,未登录用户没有创建者
createUser: {
ref: 'users',
type: mongoose.Types.ObjectId,
trim: true,
},
createdAt: {
type: Date,
default: Date.now,
},
});
commentSchema.pre('save', function xssBody() {
// 使用 xss 库处理输入的内容,防止 xss 攻击
this.body = xss(this.body);
if (this.anonymousInfo) {
if (this.anonymousInfo.username) {
this.anonymousInfo.username = xss(this.anonymousInfo.username);
}
if (this.anonymousInfo.email) {
this.anonymousInfo.email = xss(this.anonymousInfo.email);
}
if (this.anonymousInfo.url) {
this.anonymousInfo.url = xss(this.anonymousInfo.url);
}
}
});
module.exports = mongoose.model('Comment', commentSchema);
新建评论
const Comment = require('../models/comment');
const router = express.Router();
router.post(
'/result',
async (req, res) => {
const comment = new Comment();
comment.body = req.body.body.trim();
comment.kind = req.body.kind;
comment.kindId = req.body.kindId;
if (req.body.parent) {
comment.parent = req.body.parent;
}
if (req.user) {
comment.createUser = req.user.id;
comment.anonymous = false;
} else {
comment.anonymousInfo.username = req.body.username.trim() ? req.body.username.trim() : '匿名';
comment.anonymousInfo.email = req.body.email.trim();
comment.anonymousInfo.url = req.body.url.trim();
comment.anonymous = true;
}
try {
await comment.save();
res.render('comment/result', { result_msgs: ['留言成功'] });
} catch (e) {
res.render('comment/result', { result_msgs: ['留言失败'] });
}
},
);
查询评论
const Comment = require('../models/comment');
const comments = await Comment.aggregate([
{
$match: {
kind: 'post',
kindId: post._id,
parent: null,
},
},
{
$lookup: {
from: 'users', // 从哪个Schema中查询(一般需要复数,除非声明Schema的时候专门有处理)
as: 'createUser',
let: { createUser: '$createUser' },
pipeline: [
{
$match: {
$expr: {
$eq: ['$$createUser', '$_id'],
},
},
},
{ $project: { name: 1 } },
],
},
},
{
$project: {
parent: 1,
createdAt: 1,
anonymous: 1,
anonymousInfo: 1,
body: 1,
kind: 1,
kindId: 1,
createUser: { $arrayElemAt: ['$createUser', 0] },
},
},
{
$lookup: {
from: 'comments',
let: { id: '$_id' },
as: 'children',
pipeline: [
{ $match: { $expr: { $eq: ['$$id', '$parent'] } } },
{
$lookup: {
from: 'users',
as: 'createUser',
let: { createUser: '$createUser' },
pipeline: [
{
$match: {
$expr: {
$eq: ['$$createUser', '$_id'],
},
},
},
{ $project: { name: 1 } },
],
},
},
{
$project: {
parent: 1,
createdAt: 1,
anonymous: 1,
anonymousInfo: 1,
body: 1,
kind: 1,
kindId: 1,
createUser: { $arrayElemAt: ['$createUser', 0] },
},
},
],
},
},
]);
查询语句返回的数据结构
[
{
"_id": "600d91ded96de9001253ab43",
"parent": null,
"anonymous": false,
"createdAt": "2021-01-24T15:27:26.313Z",
"body": "评论1",
"kind": "post",
"kindId": "600d91cdd96de9001253ab42",
"createUser": {
"_id": "5e8f1a60eadeec001299ea75",
"name": "ryanlid"
},
"children": [
{
"_id": "600d923ed96de9001253ab46",
"parent": "600d91ded96de9001253ab43",
"anonymous": false,
"createdAt": "2021-01-24T15:29:02.370Z",
"body": "回复评论1",
"kind": "post",
"kindId": "600d91cdd96de9001253ab42",
"createUser": {
"_id": "5e8f1a60eadeec001299ea75",
"name": "ryanlid"
}
}
]
},
{
"_id": "600d91ebd96de9001253ab44",
"parent": null,
"anonymous": false,
"createdAt": "2021-01-24T15:27:39.390Z",
"body": "评论2",
"kind": "post",
"kindId": "600d91cdd96de9001253ab42",
"createUser": {
"_id": "5e8f1a60eadeec001299ea75",
"name": "ryanlid"
},
"children": []
},
{
"_id": "600d9211d96de9001253ab45",
"anonymousInfo": {
"username": "匿名评论",
"email": "test@example.com",
"url": "http://www.example.com"
},
"parent": null,
"anonymous": true,
"createdAt": "2021-01-24T15:28:17.651Z",
"body": "匿名评论内容1",
"kind": "post",
"kindId": "600d91cdd96de9001253ab42",
"children": [
{
"_id": "600d9275d96de9001253ab47",
"anonymousInfo": {
"username": "匿名回复用户",
"email": "test2@example.com",
"url": "http://www.example.com"
},
"parent": "600d9211d96de9001253ab45",
"anonymous": true,
"createdAt": "2021-01-24T15:29:57.730Z",
"body": "匿名回复内容1",
"kind": "post",
"kindId": "600d91cdd96de9001253ab42"
}
]
}
]