反正,闲着也是闲着,为什么不学习一下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"
}
"dependencies": {
"@koishijs/core": "4.16.7",
"@koishijs/loader": "4.4.3",
"@koishijs/plugin-server": "^3.1.7",
"@koishijs/utils": "^7.1.2",
"@satorijs/satori": "^3.4.5",
"cac": "^6.7.14",
"kleur": "^4.1.5"
},
前五个都是koishi开发团队开发的,
cac
看起来能够创建命令行指令
kleur
看起来能给文字染色
并且这两个都没有任何依赖(dev不算,后同。)
- @koishijs/core
- @koishijs/loader
- @koishijs/plugin-server
- @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.16.7。
以下内容已过时
过时内容
重新看一下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最根本的配置。
index.ts
// This file is only intended for users who do not use CLI.
import Loader from '@koishijs/loader'
import '@satorijs/satori'
export { Loader }
export * from '@koishijs/core'
export * from '@koishijs/loader'
koishi现在是core和loader的拼装。
以后可能就不是了(
百年 一年未有之大变局!
我好好奇什么时候能解读到第二个文件()
就在今天!
前面提到,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 会重复出两次帮助信息
还记得打工人吗?我们今天就来看看她。
我们昨天将worker
作为一个子进程加载了,实际上那时运行了这个文件夹下的index.ts
。
不过worker
文件夹下有三个文件,我们先看另外两个再回来看index.ts
。
logger.ts:
import { Context, defineProperty, Logger, Schema } from '@koishijs/core'
interface LogLevelConfig {
// a little different from @koishijs/utils
// we don't enforce user to provide a base here
base?: number
[K: string]: LogLevel
}
type LogLevel = number | LogLevelConfig
export interface Config {
levels?: LogLevel
showDiff?: boolean
showTime?: string | boolean
}
export const Config: Schema<Config> = Schema.object({
levels: Schema.any().description('默认的日志输出等级。'),
showDiff: Schema.boolean().description('标注相邻两次日志输出的时间差。'),
showTime: Schema.union([Boolean, String]).default(true).description('输出日志所使用的时间格式。'),
}).description('日志设置').hidden()
defineProperty(Context.Config, 'logger', Config)
Context.Config.list.push(Schema.object({
logger: Config,
}))
export function prepare(config: Config = {}) {
const { levels } = config
// configurate logger levels
if (typeof levels === 'object') {
Logger.levels = levels as any
} else if (typeof levels === 'number') {
Logger.levels.base = levels
}
let showTime = config.showTime
if (showTime === true) showTime = 'yyyy-MM-dd hh:mm:ss'
if (showTime) Logger.targets[0].showTime = showTime
Logger.targets[0].showDiff = config.showDiff
// cli options have higher precedence
if (process.env.KOISHI_LOG_LEVEL) {
Logger.levels.base = +process.env.KOISHI_LOG_LEVEL
}
function ensureBaseLevel(config: Logger.LevelConfig, base: number) {
config.base ??= base
Object.values(config).forEach((value) => {
if (typeof value !== 'object') return
ensureBaseLevel(value, config.base)
})
}
ensureBaseLevel(Logger.levels, 2)
if (process.env.KOISHI_DEBUG) {
for (const name of process.env.KOISHI_DEBUG.split(',')) {
new Logger(name).level = Logger.DEBUG
}
}
Logger.targets[0].timestamp = Date.now()
}
这个文件主要做了两件事,定义了log的配置,提供了一个prepare
函数。
定义配置
最外层的index.ts
定义了网络设置。这里定义了日志设置。
然而,其实我们在控制台并不能看到,这是由于
export const Config: Schema<Config> = Schema.object({
// 中间省略
}).description('日志设置').hidden()
在最后有一个hidden隐藏了这项配置。
让我们把hidden删掉试试。
从上面的定义我们可以看到,levels对应的类型其实是可以套娃的,每层套娃有一个base。而这样的格式并不是很直观。或许目前暂时无法使用Schema配置Log也有这方面的原因。
prepare函数
要看如何准备这个Logger的就要先看看koishi用的是什么Logger。
这个Logger来自
import { Context, defineProperty, Logger, Schema } from '@koishijs/core'
中的@koishijs/core
export * from './context'
/// context.ts
export { Adapter, Bot, Element, h, Logger, MessageEncoder, Messenger, Quester, Satori, Schema, segment, Universal, z } from '@satorijs/core'
中的@satorijs/core
export { Logger } from '@cordisjs/logger'
中的@cordisjs/logger
import Logger from 'reggol';
export { Logger };
中的reggol
传家宝了属于是
而说到reggol,从它的readme里就可以感到到其专业性(字面意思)
注:此处为完整readme,无任何删减
总而言之,这是一个log模块,通过定义不同的targets来显示遵循定义的log。
perpare函数的效果则是将配置传输给了Logger,设置的这些属性是静态属性,是绑定到类的,完成属性的设定之后以后调用的就是这个Logger了。
Yuki 为什么是神