Back
Featured image of post 评论系统折腾记

评论系统折腾记

初试 TypeScript

上礼拜三上线了新的博客评论系统,经过这几天的修修改改后基本上满足了我的需求,这篇文章就来谈谈开发中遇到的一些问题。

起因

静态博客不同于 WordPress 等动态博客系统,没有自带的评论系统。目前市面上最流行的解决方案是 Disqus,不过因为前年发布的 GDPR 的限制,后台导出的数据已经不包括评论者的邮箱地址和 IP 了,如果以后想要转移到别的平台就不能获取评论者的 Gravatar,有点可惜。个人觉得评论数据应该由博主控制,也就有了这个自建评论系统这个想法。

三年前还在折腾静态博客的时候也折腾过一个类似的系统,采用了 Firebase Firestore 来储存数据。为了更加有效的预防 XSS,套了一层 Firebase Functions 来过滤数据,同时也避免泄露评论者的个人信息。可惜最后速度实在是不怎么样(要访问数据库,还没有缓存),但也给这次的项目提供了一些经验。(Firebase Functions 也使用了 Express)

开始写的时候定下的功能如下:

  • 无需登录 (输入昵称,邮箱即可评论)
  • 支持嵌套
  • 支持分页
  • 回复邮件提醒(同时支持退订)

其他可有可无的功能:

  • 评论发布后编辑(限时)
  • 社会化登录

技术栈

以前就听说过 TypeScript ,但都没机会去尝试,这次就借着这个项目来学习下;数据库方面使用了 MongoDB ,觉得文档型数据库拿来写评论系统挺合适的,也方便复用之前写过的代码。使用了 Mongoose 来管理模型(非常推荐,写起来很舒服)。Web 框架采用了 Express

编辑器方面,尝试了 Visual Studio 和 WebStorm 后还是滚回了 VS Code,感觉对 TypeScript 的支持最好(?)。

现在感叹用 TypeScript 真是个好决定。后端定义过的 Interface 可以直接复制到前端代码上,然后 VS Code 就能提醒我哪些字段可用,避免瞎打(。

搭建开发环境浪费了我不少时间。我希望在每次保存文件后都会自动重新编译并运行。最后使用了 Nodemon 来实现这个效果。package.json 文件关键部分如下:

{  
	"scripts": {
		"start": "node --inspect=5858 -r tsconfig-paths/register -r ts-node/register ./src/server.ts",
		"start:watch": "nodemon",
		"build": "tsc"
	},
	"nodemonConfig": {
		"ignore": [
			"**/*.test.ts",
			"**/*.spec.ts",
			".git",
			"node_modules"
		],
		"watch": [
			"src"
		],
		"exec": "tsc && npm start",
		"ext": "ts"
	}
}

开发时使用 npm run start:watch,服务器部署时用 npm run build 来获取编译后的文件。

数据库

目前分了三个表:

  • comments: 储存评论数据
  • posts: 文章数据 (标题,链接,标识符,是否允许评论等等配置)
  • comments_notify: 回复邮件提醒等待列表

评论发布后不一定会立即通过。如果是另一条评论的回复,相应的邮件提醒应该等到新评论审核通过后再发送。这最后一个表就是用来处理这种情况的:如果不是立即通过,就加入到表中。待评论审核通过后检查下有没有对应的ID后发送邮件。

comments 表的结构如下:

export interface iComment extends Mongoose.Document {
    author: string,
    email: string,
    url?: string,
    content: string,
    ip?: string,
    post_slug: string,
    date: Date,
    useragent: string,
    notify: boolean,
    moderator: boolean,
    parent?: string,
    status: 'approved' | 'hold' | 'spam' | 'trash'
}

为了方便转移,数据结构和 WordPress 的差不多,在此之上添加了个新的 notify 字段用于邮件提醒。

文章表的数据结构如下:

export interface iPost extends Mongoose.Document {
    title: string,
    url: string,
    allow_comment: Boolean,
    image: string,
    comments: number,
    slug: string,
    status: 'approved' | 'hold'
}

给新建讨论区也加上了审核,避免了误操作的尴尬。

评论管理

不打算写后台,工作量太大,还不如直接登录数据库修改。但是评论审核总不能每次都手动修改数据库,那就太麻烦了。可以参考 WordPress 给管理员发送提醒邮件,附带「通过」/「删除」评论的链接。

但我选择的解决方案是使用 Telegram 机器人,毕竟这是我的主力聊天工具。在不同编程语言上都能找到它的 API 库,我用了 Telegraf 这个框架,效果如下:

新文章提醒
新文章提醒
新评论提醒
新评论提醒

Telegram Inline Keyboard 每个按钮对应一个action没有额外的字段用来传递参数。 这也是我遇到的第一个问题,得找方法把想执行的操作和对应的评论ID传回后端。我最后使用了以下结构:

const inlineCommentKeyboard = (comment: iComment) => {
    let commentStatusButtons = [],
        availableStatus = {
            approved: Markup.callbackButton('✔️ Pass', `comment-approved-${comment.id}`),
            hold: Markup.callbackButton('🤔 Hold', `comment-hold-${comment.id}`),
            spam: Markup.callbackButton('👻 Spam', `comment-spam-${comment.id}`),
            trash: Markup.callbackButton('🗑 Trash', `comment-trash-${comment.id}`)
        };

    for (let status in availableStatus) {
        if (comment.status != status) {
            commentStatusButtons.push(availableStatus[status]);
        }
    }

    let otherButtons = [];

    if (comment.moderator) {
        otherButtons.push(Markup.callbackButton('🛂 Is not a moderator', `comment-unmoderator-${comment.id}`))
    }
    else {
        otherButtons.push(Markup.callbackButton('🛂 Is a moderator', `comment-moderator-${comment.id}`))
    }

    return Markup.inlineKeyboard([commentStatusButtons, otherButtons]).extra();
}

bot.telegram.sendMessage(
    config.get('telegram').user_id,
    `New comment...`,
    {
        ...inlineCommentKeyboard(comment),
        parse_mode: 'Markdown',
        disable_web_page_preview: true
    }
);

comment.id是 MongoDB 的 ObjectId,十六进制值,没有横杠。后端接收到回调后用split('-')拆开,并执行相应的操作。可以用 bot.action(/.+/, (ctx) => {}); 来监听。

本地调试时可以使用默认的轮询模式,上线后建议开启 WebHook 以节省资源。调试 WebHook 的时候遇到无法接受到请求的问题,后来发现是因为和 bodyParser 模块冲突,删除后即可。

在 Express 上的使用方法可以参考官方文档。我自己是这样写的:

if (config.get('telegram').webhook.enabled) {
    const webhook_url = config.get('telegram').webhook.base_url + config.get('telegram').webhook.path;
    bot.telegram.setWebhook(webhook_url);
    express.use(bot.webhookCallback(config.get('telegram').webhook.path));
}
else {
    bot.launch();               /// Polling
}

退订回复提醒邮件

使用 Node.js 自带的 Crypto 库生成评论 ID 的 SHA512。退订链接带有评论 ID 和 SHA512 ,后端负责验证是否对应并修改数据库的 notify 字段。

const crypto = require("crypto");
function generateSecretKey(commentId: string) {
    const salt = config.get('comments').mail_notification.salt;
    return crypto.createHmac('sha512', salt).update(commentId).digest('hex');
}

评论接口

目前请求文章评论返回的数据结构如下:

interface {
    success: Boolean,
    code?: String,
    message?: String
    comments: Comment[],
    post: Post
}

评论数组我采用了嵌套结构:子评论存放在父评论的children字段中。这样做的好处是调试时评论层级关系很清晰,同时也方便实现分页功能,可以直接分割数组。要实现这一点可以参考这篇文章的代码:Create a nested array recursively in Javascript。前端获取到数据后再用递归渲染出来。

2020/08/30 更新:上面的代码时间复杂度很高,评论嵌套一多会很明显,最好不要用。

事件管理

这次学到了使用 EventEmitter 来管理事件。目前的操作是创建一个 event.ts 来存放实例,然后在其他模块中 import '~/event.ts'

使用场景有:

  • 发布评论后触发 comment.created 事件,Telegram.ts 模块接收到事件后发送提醒
  • 评论修改后触发 comment.edited 事件并清空缓存

感觉这样写好像还挺优雅的,也方便拓展(

前端

后端写完后发现前端更让我头疼

由于三年前写的脚本实在是太垃圾了,虽然 API 接口都还兼容,但我还是执意要重写一遍,顺便用上 TypeScript。

考虑到了自己经常折腾主题,总不能每一次换主题都要大改脚本,这次设计接口就尽量把渲染部分的函数暴露出来,可以在实例化后按需修改。理论上应该支持同一页面多个评论框(谁知道以后会不会搞个相册页面,每张图片一个评论框)。

处理回调的方式参考了后端,但前端的叫 EventTarget。目前主流浏览器都支持,旧版 Edge 和 IE 需要打 Polyfill(MDN Web Doc 有提供)。

另外,一开始很傻的用 document.createElement() 去写界面,然后慢慢用 container.appendChild() 来处理布局。后来发现 TypeScript 自带 JSX 支持…当时感觉自己像傻逼

最后

写这类前后端都自己揽的项目很有成就感。对自己写的代码熟悉,有什么问题也好处理,想加功能就自己上,能学到不少东西。

可惜并没有多少人评论