反正,闲着也是闲着,为什么不学习一下koishi的智慧呢?
【合集】GitHub 上的好玩文章:Koishi 人的插件开发哲学
本贴持续更新,以提供微弱理解帮助,也期待大佬随时指正理解错误的地方。
随着版本的更新,不保证完全正确,只是一种学习思路。
并不知道从哪里开始,那就先看看koishi的依赖关系吧。
所有的插件都依赖于koishi的本体。
而本体的依赖:
"dependencies": {
"@koishijs/core": "4.15.6",
"@koishijs/loader": "4.2.6",
"@koishijs/utils": "^7.1.1",
"@satorijs/router": "^1.1.2",
"@satorijs/satori": "^3.2.1",
"cac": "^6.7.14",
"kleur": "^4.1.5"
}
前五个都是koishi开发团队开发的,
cac
看起来能够创建命令行指令
kleur
看起来能给文字染色
并且这两个都没有任何依赖(dev不算,后同。)
- @koishijs/core
- @koishijs/loader
- @koishijs/utils
- @satorijs/satori
这四个才是koishi中占大头的部分,放着先,看看本体都做了什么。
最外层的文件看起来没什么值得注意的了。
bin.js的作用是一种标识,表示在cli里的命令行是可执行的,大概是这个意思。
让我们看看index.ts
随着版本更新,改内容已弃用,见下文。
// This file is only intended for users who do not use CLI.
import { Context } from '@koishijs/core'
import ns from 'ns-require'
export { Router, WebSocketLayer } from '@satorijs/satori'
export * from '@koishijs/core'
export * from '@koishijs/loader'
export * from '@koishijs/utils'
declare module 'cordis' {
interface Context {
plugin(path: string, config?: any): ForkScope<this>
}
}
class Patch {
constructor(ctx: Context) {
// patch for @koishijs/loader
ctx.root.envData ??= {}
ctx.root.baseDir ??= process.cwd()
}
}
Context.service('$patch', Patch)
export const scope = ns({
namespace: 'koishi',
prefix: 'plugin',
official: 'koishijs',
})
const plugin = Context.prototype.plugin
Context.prototype.plugin = function (this: Context, entry: any, config?: any) {
if (typeof entry === 'string') {
entry = scope.require(entry)
}
return plugin.call(this, entry, config)
}
从前面的导包来看,
koishi是由上面所提到的四个包拼装在一起的
往后看之前,要先看看cordis。
cordis也是开发者自研的面向切面的框架,提供了围绕context的活动。包括简单的事件系统,插件系统和服务系统——也就是koishi整个架构的核心。如果你是koishi插件的开发者,那么你对koishi的插件系统,事件系统和服务系统应该并不陌生。
这个文件所做的就是在context的原型类里,插进去一个plugin方法所做的事情就是
在原本plugin的基础上,先调用ns函数,这样就省去了输入“koishi-plugin-”,同时官方插件也省去了“@koishijs/plugin-”
此外,还在Context上注册了一个$patch服务,目前来看是提供路径和环境变量的服务的。
// patch for @koishijs/loader
所以之后看@koishijs/loader的时候再细看吧。
哦哦哦哦,有人开始挑战 Koishi 源码学习了吗,我很感兴趣,插个眼
当前版本4.15.6。
重新看一下index.ts
// This file is only intended for users who do not use CLI.
import { Context, defineProperty, Schema } from '@koishijs/core'
import { Router, WebSocketLayer } from '@satorijs/router'
import Loader from '@koishijs/loader'
import '@satorijs/satori'
export { Loader, Router, WebSocketLayer }
export * from '@koishijs/core'
export * from '@koishijs/loader'
export * from '@koishijs/utils'
Context.service('router', Router)
declare module '@koishijs/core' {
namespace Context {
interface Config extends Config.Network {}
namespace Config {
interface Network {
host?: string
port?: number
maxPort?: number
selfUrl?: string
}
interface Static extends Schema<Config> {
Network: Schema<Network>
}
}
}
}
defineProperty(Context.Config, 'Network', Router.Config.description('网络设置'))
Context.Config.list.unshift(Context.Config.Network)
比起之前的版本简短了许多,还没看,盲猜封装到了@satorijs/router
里去了。
可以看到,这个文件所做的事情可以简单分为两类:
第一个
和以前一样,拼装,将:
- ‘@koishijs/core’
- ‘@koishijs/loader’
- ‘@koishijs/utils’
- '@satorijs/router’中的Router, WebSocketLayer
拼装到一起,生成一个完整的koishi。
第二个
是定义最最基础的配置。
我们在koishi的控制台可以看到这个东西,就在这里被添加到了配置列表中。
可以认为,Network配置是koishi最根本的配置。
以后可能就不是了(
百年 一年未有之大变局!
我好好奇什么时候能解读到第二个文件()
就在今天!
前面提到,koishi通过cac实现了利用命令行指令来控制koishi的方式。这部分被些在cli中。
让我们看看cli这个文件夹。
index.ts如下:
import registerStartCommand from './start'
import CAC from 'cac'
const { version } = require('../../package.json')
const cli = CAC('koishi').help().version(version)
registerStartCommand(cli)
cli.parse()
if (!cli.matchedCommand) {
cli.outputHelp()
}
这里利用cac生成了一个程序名为koishi的指令,在其“帮助指令”中放入了对应koishi的版本。
在程序的最后,声明了如果指令无法匹配,则自动调用帮助指令。
打开模板项目根目录下的package.json
我们可以看到这样一个很美观的指令
对应的dev的指令就十分丑陋
这段代码从start里导入了一个函数,这个函数的作用是在cli上注册了一个“start”指令,对应的完整指令就应当是koishi start
。
这个很美观的指令就是在这里注册的。
为了一探究竟,在package.json
里添加了一个指令。
可以看到,确实在什么具体指令也没有写的情况下显示了koishi的版本号以及一些帮助。
那么我们就去start.ts
里看看里面具体做了什么吧。
start.ts
中有一些函数只做了一些格式化的工作,不是很重要。
我们挑重点看。
export default function (cli: CAC) {
cli.command('start [file]', 'start a koishi bot')
.alias('run')
.allowUnknownOptions()
.option('--debug [namespace]', 'specify debug namespace')
.option('--log-level [level]', 'specify log level (default: 2)')
.option('--log-time [format]', 'show timestamp in logs')
.option('--watch [path]', 'watch and reload at change')
.action((file, options) => {
const { logLevel, debug, logTime, watch, ...rest } = options
if (logLevel !== undefined && (!isInteger(logLevel) || logLevel < 0)) {
console.warn(`${kleur.red('error')} log level should be a positive integer.`)
process.exit(1)
}
setEnvArg('KOISHI_WATCH_ROOT', watch) // for backward compatibility
setEnvArg('KOISHI_LOG_TIME', logTime)
process.env.KOISHI_LOG_LEVEL = logLevel || ''
process.env.KOISHI_DEBUG = debug || ''
process.env.KOISHI_CONFIG_FILE = file || ''
createWorker(rest)
})
}
这个重要的方法是开启koishi的主要方式,从这里可以传入参数。
如果你写过koishi插件的指令就会发现,这里的书写方式和那个非常相似,就不做详细解释了。
可能是抄袭灵感来源之一。
其中logLevel, debug, logTime, watch这几个参数在这里检查格式之后就被注册进了主进程的node环境变量。logLevel格式不正确会结束进程。
最后,把剩下的其他option扔给了rest并且利用这些参数创建了一个打工人。
type Event = Event.Start | Event.Env | Event.Heartbeat
namespace Event {
export interface Start {
type: 'start'
body: Config
}
export interface Env {
type: 'shared'
body: string
}
export interface Heartbeat {
type: 'heartbeat'
}
}
let child: ChildProcess
// ...不重要,省略了
function createWorker(options: Dict<any>) {
const execArgv = Object.entries(options).flatMap<string>(([key, value]) => {
//... 简单格式化工作。
})
execArgv.push(...options['--'])
child = fork(resolve(__dirname, '../worker'), [], {
execArgv,
})
let config: Config
let timer: NodeJS.Timeout
child.on('message', (message: Event) => {
if (message.type === 'start') {
config = message.body
timer = config.heartbeatTimeout && setTimeout(() => {
console.log(kleur.red('daemon: heartbeat timeout'))
child.kill('SIGKILL')
}, config.heartbeatTimeout)
} else if (message.type === 'shared') {
process.env.KOISHI_SHARED = message.body
} else if (message.type === 'heartbeat') {
if (timer) timer.refresh()
}
})
function shouldExit(code: number) {
// start failed
if (!config) return true
// exit manually or by signal
// https://tldp.org/LDP/abs/html/exitcodes.html
if (code === 0 || code >= 128 && code < 128 + 16) return true
// restart manually
if (code === 51) return false
if (code === 52) return true
// fallback to autoRestart
return !config.autoRestart
}
child.on('exit', (code) => {
if (shouldExit(code)) {
process.exit(code)
}
createWorker(options)
})
}
我们详细看看在这个打工人,因为她就是 koishi本shi 负责koishi主要程序运行的。
简单格式化后,这些参数被用来创建了一个 koishi幼仔 子进程。
接下来就是开始体现koishi哲学的地方了。
这个幼仔监听了三种类型的事件(都是进程的message事件),“开始”,“分享”和“心跳”。
开始事件
需要传入一个守护进程的配置,这个配置会被保存下来。并且开始之后,如果一段时间没有侦测到心跳,这个 幼仔就会紫砂!!! 子进程就会结束自己。
分享事件
会把要分享的信息写到主进程的环境变量中。
心跳时间
刷新心跳的事件,避免紫砂。
接下来监听了进程的退出事件,分析这个事件中的退出码,当这个码是特定情况时,主进程正常退出。
如果在开始时对守护进程的配置中,设定为自动重启,那么这个 幼仔就会再生一个幼仔 就会重现创建一个新的子进程,从而实现koishi重启自己。
(重启后的koishi已经不再是原来的koishi了,你这个鲨妖凶手!)
好好好
冷知识:你可以直接使用 yarn koishi
来查看用法。
其实是被父母杀掉,这狠心的爹妈(怎么听起来更血腥了
冷知识:执行 koishi -h 会重复出两次帮助信息