上下文 (Context)
在此前的例子中,我们已经了解了 Context 的许多用法:
ctx.router是一个Router实例,用于定义指令、进行模式匹配等。ctx.client是一个MilkyClient实例,用于调用 Milky 的 API。ctx.logger是一个Logger实例,用于记录日志。ctx.on用于监听事件,例如message_receive、message_recall等,并返回一个注销监听器的函数。ctx.install用于安装插件。ctx.provide和ctx.resolve用于提供和解析服务。ctx.start用于启动机器人。
正如一开始所提到的,Context 是 Fraq 的核心类,代表了一个机器人实例。它包含了机器人的配置、状态、路由等信息。我们通过调用 ctx 的各种方法来 “组装” 这个机器人,例如定义指令、安装插件、提供服务等。最后,我们调用 ctx.start 来启动这个机器人,使其开始监听用户输入并响应。
创建 Context
最常见的方式是通过 Milky 协议端的地址创建 Context:
const ctx = Context.fromUrl('http://localhost:30001', {
accessToken: '<如果你的 Milky 协议端配置了 token,请在这里添加>',
});如果你需要接入自定义协议客户端(例如 Mock Client),可以直接传入一个实现了 MilkyClient 接口的对象:
const ctx = Context.fromClient(client);这种方式只要求客户端提供 Milky API 调用能力和事件流入口,具体通信方式可以由客户端自行决定。
设定计时任务
Context 提供了 timeout 和 interval 方法来设定计时任务,例如:
ctx.timeout(() => {
console.log('这是一个一次性计时器,3 秒后触发');
}, 3000);
ctx.interval(() => {
console.log('这是一个循环计时器,每 2 秒触发一次');
}, 2000);这两个方法与 JavaScript 中的 setTimeout 和 setInterval 类似,都会返回 NodeJS.Timeout 对象。你可以通过 clearTimeout 和 clearInterval 来取消这些计时任务,这些任务也会在 Context 停止时自动被清除。
继承与隔离
我们可以通过 ctx.fork 来创建一个继承当前 ctx 的新 Context 实例。这个新实例会与父 Context 使用同一个协议客户端,并且可以访问父 Context 提供的服务;但在新创建出的这个 Context 上进行配置、定义路由以及安装插件等操作不会影响到父 Context。我们结合上一节的依赖注入来看看这个特性:
const PluginA = definePlugin({
name: 'plugin-a',
provides: [AlphaService],
apply(ctx) {
ctx.provide(AlphaService, new AlphaService());
},
});
const PluginB = definePlugin({
name: 'plugin-b',
requires: [AlphaService],
apply(ctx) {
const alphaService = ctx.resolve(AlphaService);
console.log(alphaService.alpha());
},
});
const fork1 = ctx.fork('fork1');
fork1.install(PluginA);
const fork2 = ctx.fork('fork2');
fork2.install(PluginB);
ctx.start();这段代码会抛出错误,因为 PluginB 依赖了 AlphaService,但是 AlphaService 是在 fork1 上提供的,而 fork2 无法访问 fork1 上提供的服务。
乍一看,这个特性似乎限制了我们在不同的 Context 之间共享服务,但实际上它提供了更大的灵活性。我们看下面一个例子:
class OmegaService {
constructor(public name: string) {}
omega() {
return `omega ${this.name}`;
}
}
const pluginX = definePlugin({
name: 'plugin-x',
provides: [OmegaService],
apply(ctx, omegaName: string) {
ctx.provide(OmegaService, new OmegaService(omegaName));
},
});
const pluginY = definePlugin({
name: 'plugin-y',
requires: [OmegaService],
apply(ctx) {
const omegaService = ctx.resolve(OmegaService);
console.log(omegaService.omega());
},
});
const fork1 = ctx.fork('fork1');
fork1.install(pluginX, 'fork one');
fork1.install(pluginY);
const fork2 = ctx.fork('fork2');
fork2.install(pluginX, 'fork two');
fork2.install(pluginY);
ctx.start();在这个例子中,我们在两个不同的 fork 上提供了不同配置的 OmegaService,并且在同一个 fork 上安装了依赖于 OmegaService 的 pluginY。当我们运行这段代码时,输出将会是:
omega fork one
omega fork two此外,在父 Context 上提供的服务是可以被所有子 Context 访问的,并且如果在新的 Context 上提供了一个与父 Context 上同类型的服务,那么在这个新的 Context 上解析这个服务时会优先返回新的服务实例,而不是父 Context 上的服务实例。我们仍然以上面的 OmegaService 为例:
ctx.install(pluginX, 'parent context');
const fork1 = ctx.fork('fork1');
fork1.install(pluginX, 'fork one');
fork1.install(pluginY);
ctx.start();这段代码的输出将会是 omega fork one,而不是 omega parent context,因为在 fork1 上提供了一个新的 OmegaService 实例,它会覆盖掉父 Context 上的实例。
总之,Context 的继承与隔离特性使得我们可以在不同的 Context 上提供不同配置的服务,并且保证了这些服务之间不会互相干扰。这对于构建复杂的机器人应用非常有用。
事件过滤
ctx.fork 支持传入一个 Filter 对象来过滤事件,例如:
ctx.fork('fork1', {
message_receive: ({ data }) =>
data.message_scene === 'group' && data.peer_id === 123456,
message_recall: ({ data }) =>
data.message_scene === 'group' && data.peer_id === 123456,
});在这个例子中,我们创建了一个新的 Context,并且只让它监听来自群聊 123456 的消息接收和消息撤回事件。Filter 对象的每个属性都是一个函数,这些函数会在对应事件发生时被调用,只有当函数返回 true 时,事件才会被传递给这个新的 Context。而对于没有在 Filter 对象中指定过滤函数的事件,则默认会被拦截,不会传递给这个新的 Context。
Fraq 提供了一个 filter 命名空间来提供一些预设的过滤函数,例如:
import { filter } from '@fraqjs/fraq';
const filter1 = filter.group(123456, 654321);
// 监听来自群聊 123456 和 654321 的事件,包括:
// - message_receive
// - message_recall
// - group_join_request
// - ...
// 等所有与消息和群聊相关的事件。
const filter2 = filter.friend(10001, 10002);
// 监听来自好友 10001 和 10002 的事件,包括:
// - message_receive
// - message_recall
// - friend_nudge
// - ...
// 等所有与消息和好友相关的事件。
const filter3 = filter.or(filter.group(123456), filter.friend(10001));
// 监听来自群聊 123456 和好友 10001 的事件。
const filter4 = filter.and(filter.group(123456), filter.admin());
// 监听来自群聊 123456 的管理员的消息事件。
const filter5 = filter.not(filter.group(123456));
// 监听不来自群聊 123456 的事件,也就是来自其他群聊、好友或讨论组的事件。随后,就可以将这些过滤器传入 ctx.fork 来创建新的 Context,例如:
ctx.fork('fork1', filter1).install(pluginA); // pluginA 只会监听来自群聊 123456 和 654321 的事件
ctx.fork('fork2', filter4).install(pluginB); // pluginB 只会监听来自群聊 123456 的管理员的消息事件
ctx.start();停止 Context
调用 ctx.stop 方法可以停止 Context,此后 Context 将不再响应任何事件,也无法调用 Milky API。Context 在调用 stop 后无法再次启动。stop 的流程是:
- 停止
Context内部通过timeout和interval创建的所有计时任务。 - 停止根事件流,例如断开 WebSocket 连接,禁止后续的事件进入
Context。 - 按照
fork的顺序反向调用所有子Context的stop方法,停止所有子Context。子Context的stop方法会按照同样的流程停止它的子Context,并且按照provide的顺序反向调用自身(子Context)注册的所有服务的dispose方法(如果服务实现了Disposable接口),释放所有服务。 - 按照
provide的顺序反向调用自身(根Context)注册的所有服务的dispose方法。
假如我们有这样的 Context 结构:
root
pluginA (provides ServiceA)
pluginB (provides ServiceB)
fork1 (filter: group 123456)
pluginC (provides ServiceC)
fork1-1
pluginD (provides ServiceD)
fork2
pluginE (provides ServiceE)那么停止的顺序会是:
stop event flow [root]
stop event flow [fork1]
stop event flow [fork1-1]
dispose [ServiceD]
dispose [ServiceC]
stop event flow [fork2]
dispose [ServiceE]
dispose [ServiceB]
dispose [ServiceA]在调用各个 dispose 方法时,Fraq 会捕获并记录任何可能发生的错误,并继续调用下一个 dispose 方法,而不会因为某个服务的 dispose 方法抛出错误而中断整个停止流程。如果有多个 dispose 方法抛出错误,Fraq 会把它们包装成一个 AggregateError 来抛出。
触发规则
了解了如何创建新的 Context 之后,我们接下来重新从头看一下事件进入 Context 之后会发生什么。当协议端向 Fraq 推送事件时,Context 会接收这个事件,并触发通过 ctx.on 注册的事件监听器。对于 message_receive 事件,Context 还会把消息交给自己的 router,尝试匹配已经定义好的指令和模式。在同一个 router 中,Fraq 会按照指令和模式的定义顺序依次尝试匹配,详细的匹配规则可以参考命令路由 (Router) 中的介绍。
如果通过 ctx.fork 创建了新的 Context,父 Context 接收到的事件会按照新 Context 上配置的过滤器继续传递下去。每个 Context 都有自己独立的 router,因此同一条消息可能会被多个满足过滤条件的 Context 分别处理;而在每个 Context 内部,指令仍然遵循上面提到的顺序匹配规则。