Mirai-js 是运行在 Node.js 平台的 QQ 机器人开发框架,Mirai 的社区 SDK,
Mirai-js 基于 mirai-console 的 mirai-api-http 插件。mirai-api-http 通过 http 提供 Mirai 的全平台接口。
这是我第一次对开发框架做出的尝试,由于机器人功能均由 mirai-api-http 实现,所以重点都放在框架的设计上,确实学到不少东西,也很有成就感。
项目仓库:https://github.com/Drincann/Mirai-js
开发文档:https://drincann.github.io/Mirai-js
SDK 一般指提供给开发者的一系列开发工具,包括框架、库、文档、甚至硬件等。
而对于库和框架,在某些情况下它们的界限并不清晰。
一般来讲,框架的复杂度要比库高一些,这是因为框架接管了整个开发过程的主要部分,或者说是主控制流。而库则专注于封装一些相对独立的功能块,提供一些粒度稍高但更加方便的接口,增加开发效率。
而对于一个应用的开发,也仅能引入一个框架,因为主控制流仅有一个,但可以引入多个库,并让他们分别负责多个部分。
Mirai-js 有三个模块,Bot
、Message
、Middleware
,他们对开发者均暴露一个类。
还有一个模块叫 Waiter
,也暴露一个类,但仅暴露给 Bot
。它作为 Bot
的内部类,通过 Bot
的 public field 对外提供接口。
Bot
是整个框架的中心,其他模块均围绕 Bot
做扩展。它的一个实例将负责接管 mirai-api-http 服务的一个 session 的整个生命周期。
Message
用于为 Bot
发送数据时生成消息提供方便的接口。
Middleware
是一个实现了中间件模式的类,提供了一系列封装好的中间件功能和用于开发者自定义中间件的接口,用于更方便的处理 Bot
的各种消息事件。
Waiter
实现了 Bot
消息流的同步 io,他允许在异步消息流中从任意部位阻塞 io,并从指定用户的下一次输入返回。
这是一个令人头疼的问题,Javascript 本身就是动态类型,也没有接口,这给框架的设计带来很大困难。
我为了让 Message
模块更易用,允许在发送消息时直接传入一个 Message
实例。
Bot.sendMessage
接收一个 MessageChain
参数,它是一个 MessageType[]
,而 MessageType
又比较复杂,所以我通过 Message
来生成 MesageChain
。
一开始的接口是这样的:
bot.sendMessage({
message: new Message().addText('Hello').addAt(1019933576).done(),
// ...
});
Message
实例的 addxxx
方法用于向实例内部维护的 MessageChain
push 一个 MessageType
,而 done
方法在最后调用,用于内部维护的 MessageChain
。
但这样设计并不直观,以至于我自己在开发应用时有时也忘记调用 done
,而且这样的设计看起来也非常冗余。
于是我让 Bot
和 Message
模块耦合,允许向 Bot.sendMessage
中直接传入一个 Message
实例。
bot.sendMessage({
message: new Message().addText('Hello').addAt(1019933576),
// ...
});
后来使用 Typescript 写类型声明时,通过 MessageChainGetable
接口对它们解耦,也让 Bot.sendMessage
易于扩展。
这个模块有很多要说的,在设计 Waiter
时进行过一些复杂的改动,后来使用 Typescript
时又改了回来,然后它的设计逻辑更加易于理解和易于与 Bot
对接了。
Middleware
是这样使用的:
bot.on('FriendMessage', new Middleware()
.textProcessor()
.catch(error => console.log(error))
.done(async data => {
// 开发者的消息处理逻辑
});
经过几次改动后接口形式没有任何变化,但逻辑有所改变,这是由于 Waiter
的实现必须区分普通的回调函数和带有中间件的回调函数,这个一会慢慢说,先看 Waiter
。
最开始,Middleware
的 done
方法直接返回一个带有中间件的回调函数入口。
Waiter
是这样使用的:
bot.on('FriendMessage', new Middleware()
.textProcessor()
.done(async data => {
if(data.text?.includes('/unload')){
bot.sendMessage({message: new Message().addText('请输入 /confirm 确认 /unload 操作')});
// 将异步操作阻塞,等待消息
// wait 方法将等待一次事件,并将回调函数的返回值返回到外层
let userInput = await bot.waiter.wait('FriendMessage' , data => data.messageChain.filter((val) => val.type == 'Plain').map(val => val.text).join(''));
if(userInput?.includes('/confirm')){
// 指定操作
}
}
});
或者:
bot.on('FriendMessage', new Middleware()
.textProcessor()
.done(async data => {
if(data.text?.includes('/unload')){
bot.sendMessage({message: new Message().addText('请输入 /confirm 确认 /unload 操作')});
// 将异步操作阻塞,等待消息
// wait 方法将等待一次事件,并将回调函数的返回值返回到外层
let userInput = await bot.waiter.wait('FriendMessage' , new Middelware().FriendFilter([data.sender.id]).textProcessor().done(data => data.text););
if(userInput?.includes('/confirm')){
// 指定操作
}
}
});
由于 wait
方法需要获得回调的返回值,首先我们对于普通的回调函数,直接调用等待其返回即可。而对于带有中间件的回调函数入口,他是一个通过 next
递归包装的中间件链,入口的返回值没有任何意义。
于是我对 Middleware
的 done
方法返回的回调函数入口进行了包装,我添加了入口的第二个参数 resolve
,并在开发者提供的回调函数处将其返回值传入 resolve
进行回调。
这样我就为 Waiter
提供了包装异步中间件任务的途径,但此时必须区分普通回调函数和中间件函数入口,因为对普通函数直接进行同步等待即可,而对中间件函数入口则需要进行 Promise 包装后再同步等待,然而他们本质都是函数,没有区别。
这时候我有两个解决方案,
首先,我可以通过区分传入函数入口的参数个数来判断,因为我为中间件函数入口提供了可能的异步包装,添加了一个 resolve
参数,而普通回调函数应该只有一个参数。但这样并不稳定,因为我不能确定开发者传入的普通回调函数只有一个参数。
第二个解决方案是,让 Middleware
的 done
方法直接返回当前实例,并将函数入口维护在实例的一个 public field 里,这时候在 Waiter
中需要判断传入的是函数还是 Middleware
实例,这样就可以区分开了,但存在的问题是,还同时需要更改 Bot
中的 on
方法,因为我不再返回函数入口了,而是一个实例,这样会增加耦合度,也增加了复杂度,但我当时没有更好的想法,于是就这样做了。
后来花了一点时间看了看 Typescript 后决定写个类型声明,在这中间,我为 Middleware
和 Message
分别声明了两个接口 EntryGetable
和 MessageChain
,然后在 js 中实现了相应的作为接口的类并让他们继承,在 Bot
中判断是否是这些接口类的实例。这是一次解耦的尝试。
最后,突然意识到可以将中间件函数入口,即 done
返回的入口,直接进行一个 Promise 包装,并在开发者的回调处 resolve
到外部。这样普通回调函数和中间件的行为将一模一样。
意识到这一点后,我删去了 EntryGetable
接口,包装了 Middleware.done
,然后更改了一切相关的依赖。
最开始实现的中间件非常可笑,仅仅是遍历所有中间件依次调用,也没有任何控制,后来使用这种设计在开发一些中间件时遇到了很大困难。
于是查了查资料,实现了一个最基本的中间件模式:
/**
* @description 生成一个带有中间件的事件处理器
* @param {function} callback 事件处理器
*/
done(callback) {
return data => {
return new Promise(resolve => {
try {
// 从右侧递归合并中间件链
this.middleware.reduceRight((next, middleware) => {
return () => middleware(data, next);
}, async () => {
// 最深层递归,即开发者提供的回调函数
let returnVal = callback instanceof Function ? (await callback(data)) : undefined;
// 异步返回
resolve(returnVal);
})();
} catch (error) {
// 优先调用开发者的错误处理器
if (this.catcher) {
this.catcher(error);
} else {
throw error;
}
}
});
};
}
其中最核心的逻辑非常优雅,抽象出来就三行:
middlewareArray.reduceRight((next, middleware) => {
return () => middleware(data, next);
}, callback)();
这实际上是一个递归包装过程,从最深处的 callback
处不断向外层包装,得到一个最外层的函数入口,较外层的 next
将调用较深一层的中间件。
写这个框架略微增加了对 Promise 的认识,大大提高了使用 Promise 的熟练度。还接触了通过 Thunk 包装从而实现的 generator 异步任务同步化算法。只不过包装方式很不优雅,没有使用。
从原生 api,到库和框架,接口粒度越来越大,这导致开发效率越来越高的同时也降低了自由度。
所以我认为一个好的设计是渐进式的设计,渐进式框架往往能给开发者更大的自由度。而这种渐进式是通过独立的模块实现的,模块之间越独立,开发者就有更大的余地进行他的设计。
开发这个框架的初衷很简单,腾讯干掉一批商业机器人平台后,我就急切需要转移阵地。Mirai 还是比较安全的,毕竟是开源的。但我并没有怎么用过 java,有一定学习成本,而且 java 开发效率可能会稍微差一些。
我用 js 还是挺顺手的,于是看了几个 js 平台的 Mirai 社区 SDK,node-mirai,mirai-ts 等等,体验都不是很好,看了看源码感觉也不太稳定。于是准备先了解一下 mirai-api-http 的接口,自己封装一些库来用,然而一上手就感觉这东西我能做,一些很好的设计就从脑子里蹦出来了,就想把它写出来。
我使用的是 mirai-api-http 提供的 WebSocket 接口进行消息推动的。在写事件处理这块的时候无意间碰到一个 bug。
那天晚上刚实现了事件处理的一些简单逻辑后就去吃饭了,我的测试代码还在挂着,也当测试稳定性了。吃饭回来发现机器人怎么叫也不应,打断点发现根本没有消息从服务端推送到本地。
测试多次后发现,大概在 3 - 5 分钟左右,如果 WebSocket 连接没有任何数据推送,服务端将莫名其妙地忽略这个链接,不再推送任何消息,但该链接并没有被断开,最后通过定时向服务器 ping 才解决这个问题。
我就想是不是文档漏看了,翻了半天也没找到。
于是在 mirai-api-http 提了 issue#255,然而他们的开发者表示并没有相关的逻辑。之后 graia(mirai python sdk)才刚刚有人意识到相关的问题,经过交流它们也解决了这个问题。
很奇怪,后来我发现社区 sdk 都没有做类似的处理,它们的机器人总在几分钟内停止任何消息推送,于是我也提了一些 pr,有些好像已经年久失修,到现在也没人 merge。
mirai-ts 的开发者挺厉害的,在修复它们的 WebSocket 被忽略的 bug 时,发现它们项目还有 commit 前的 eslint、prettier 检查。查了一下才知道是通过 git hook 实现的,有一个成熟的包叫 husky 就是用来干这个的。于是把 mirai-ts 的配置拿来抄了抄。也详细了解了一下 eslint 的配置。
这次是实实在在地在实践中应用了 git,以前就是 add、commit、然后push push,pull pull,就没了,这是第一次在实践中学到了该怎么同时在多个分支上开发,最后合并该怎么处理 commit 历史。
如果扒开 Mirai-js 最开始的几个 commit 看一看,是真的非常恶心。那时候总是写一天,直到晚上结束的时候才提交,然后把今天做的所有工作仔细的写到 message 里。
后来发现大家更提倡小粒度的提交,也学到了一些 message 该怎么写的规范。
开发初期工作量比较大,基本上都是一些核心功能的封装,我记得最多的一天交了八十多次。后来还发现 github 的绿格子的颜色深度是相对的。
最近才把 Mirai-js 发布到 npm 上,这是第一次发布,也了解了一些版本号的控制规范。
现在基于 Mirai-js 开发应用都是独立的,这就意味多个机器人应用需要运行多个脚本,这样很不方便。
目前的想法是做一个插件系统,要不要跟现有体系耦合还在考虑中。
从月初开始上手开发,陆陆续续到现在也有半个多月了,现在是 2021.02.22,主要工作基本上是在前几天之内完成的,后边是一些修修补补,除了 Waiter
之外都是锦上添花。
到现在也有三十多个 star 了,写了一个实用的东西出来确实有成就感。