NodeBB 4: plugin 探秘

Posted by River Yang on 2016-10-29

NodeBB 是一个高度可自定义的程序,要想完全按照自己的想法来设计, 不可避免的就会用到它的插件,甚至还可能需要编写自己的插件。比如 v2mm 在使用第三方登录插件 sso-github 的时候,发现有几个小问题使用不太爽,就 folk 了原来插件的代码,修改成了自己的 nodebb-plugin-sso-github2。目前 sso-github 已经 merge 了我的代码,这样两个插件都可用了,功能一致。原来的插件有哪两个问题呢? 一是 github 上用户设置的 non-public 的邮件地址获取不到,而 NodeBB 又需要邮箱验证,导致用户无法验证帐号;二是 github 登录的账户我并不想让他再次验证邮箱,因为这个邮箱肯定是可信的。所以,为这两个小的用户体验,我就做了一个小插件,完美解决了问题。

每一个大型的开源程序,插件都是他的灵魂,正是因为有了丰富多样的插件,开源社区才会长盛不衰。NodeBB 的插件机制又有哪些特点呢? 我们能利用插件做哪些事?哪些事又不能做?一起来看看。

本文不打算写一个教程来从头教你写插件, 也不打算遍历一遍 NodeBB 所有的接口。在写插件之前,你应该先看一遍官方的插件开发指南。本文将会深入 NodeBB 的源代码,介绍插件的本质。

NodeBB 的所有接口都是以 HOOKS 的形式提供的, 正如其他的开源软件(如 wordpress)。HOOKS 分三种类型: Filters, Action 和 Static。

Filters

Filters 接口在做某些数据操作时抛出,接口只有两个参数, 第一个是数据 object,第二个是 callback 函数。用户在按照需要“过滤”数据后, 调用 callback 返回修改后的数据。这就利用到了 js 函数的异步调用机制。Filters 的触发很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async.reduce(hookList, params, function(params, hookObj, next) {
if (typeof hookObj.method !== 'function') {
if (global.env === 'development') {
winston.warn('[plugins] Expected method for hook \'' + hook + '\' in plugin \'' + hookObj.id + '\' not found, skipping.');
}
return next(null, params);
}
hookObj.method(params, next);
}, function(err, values) {
if (err) {
winston.error('[plugins] ' + hook + ', ' + err.message);
}
callback(err, values);
});

hookList 是所有注册了该接口的对象列表(hookObj list)。hookObj.method 就是在 plugin.json里指定的 hook 函数。可以看出 Filters 会将 next 传给 hook 函数,等待 hook 函数调用它才能继续。有趣的是这里用了 async.reduce, 也就是如果该接口注册了多个 hook 函数, 这些函数会顺序调用,并且每个函数返回的结果将作为下一个函数的输入。函数调用的顺序根据注册时的 priority 字段来确定。
系统里触发 Filter 的地方很多,比如在获取板块的帖子列表之前(数据库操作之前)会触发:

plugins.fireHook('filter:category.topics.prepare', data, next);

在从数据库获取到 topics 之后也会触发:

plugins.fireHook('filter:category.topics.get', {topics: topics, uid: data.uid}, next);

这样插件可以做的事情包括在 prepare 阶段修改目标数据库集合 data.set, 以获取想要的数据。获取了 Topics 之后, 可以对数据进行过滤,比如隐藏某些隐私字段,修改帖子标题等等。

Actions

Actions 接口不会异步等待,只是通知插件发生了什么事情,插件就会触发哪些操作。所以 Action 触发的方式更简单:

1
2
3
4
5
6
7
8
9
10
11
async.each(hookList, function(hookObj, next) {
if (typeof hookObj.method !== 'function') {
if (global.env === 'development') {
winston.warn('[plugins] Expected method for hook \'' + hook + '\' in plugin \'' + hookObj.id + '\' not found, skipping.');
}
return next();
}
hookObj.method(params);
next();
}, callback);

可以看出 Actions 按顺序触发,且不会异步等待。
Actions hook 函数的参数一般只有一个 object, 有时也会只有一个 id, 这貌似是因为不同的开发人员或在不同的开发时间导致的不一致。有些数据还用 _.clone 克隆了一个新数据传参,貌似是为了防止偶然地更改。全部通过 object 传参是很明智的做法,以后要添加参数或更改参数很容易向前兼容。

举例,在删除帖子之后会触发一个 action,插件这时候可以发出通知用户删除了帖子等等。

plugins.fireHook('action:post.delete', pid);

Static

Statics 接口和 Filters 相似,会等待 hook 函数调用 next, 但是有一个超时限制。实现如下:

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
async.each(hookList, function(hookObj, next) {
if (typeof hookObj.method === 'function') {
var timedOut = false;
var timeoutId = setTimeout(function() {
winston.warn('[plugins] Callback timed out, hook \'' + hook + '\' in plugin \'' + hookObj.id + '\'');
timedOut = true;
next();
}, 5000);
try {
hookObj.method(params, function() {
clearTimeout(timeoutId);
if (!timedOut) {
next.apply(null, arguments);
}
});
} catch(err) {
winston.error('[plugins] Error executing \'' + hook + '\' in plugin \'' + hookObj.id + '\'');
winston.error(err);
clearTimeout(timeoutId);
next();
}
} else {
next();
}
}, callback);

可以看出等待时间有5秒, 也没有用 async.reduce, 所以 hook 函数无需返回的 params,各个 hook 之间也不会相互影响。static 的好处是如果插件有bug没有返回,程序还是会正常运行。比如程序启动时会触发 app.load:

1
2
3
4
5
6
7
8
9
Plugins.fireHook('static:app.load', {app: app, router: router, middleware: middleware, controllers: controllers}, function(err) {
if (err) {
return winston.error('[plugins] Encountered error while executing post-router plugins hooks: ' + err.message);
}
hotswap.replace('plugins', router);
winston.verbose('[plugins] All plugins reloaded and rerouted');
callback();
});

如果插件初始化失败,会有日志记录,且不会影响程序继续运行。

NodeBB 的主题其实也是插件, 可以使用所有接口。详细的使用案例请参考 v2mm 的开发,V2MM 是我创建的一个自由职业者论坛,使用的是我自己创建的 v2mm theme, 已开源,欢迎加入 V2MM, 一起讨论学习。