Koishi学习笔记

反正,闲着也是闲着,为什么不学习一下koishi的智慧呢?
【合集】GitHub 上的好玩文章:Koishi 人的插件开发哲学
本贴持续更新,以提供微弱理解帮助,也期待大佬随时指正理解错误的地方。
随着版本的更新,不保证完全正确,只是一种学习思路。

3 个赞

并不知道从哪里开始,那就先看看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不算,后同。)

3 个赞
  • @koishijs/core
  • @koishijs/loader
  • @koishijs/plugin-server
  • @koishijs/utils
  • @satorijs/satori

这四个才是koishi中占大头的部分,放着先,看看本体都做了什么。
image
最外层的文件看起来没什么值得注意的了。
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的时候再细看吧。

5 个赞

哦哦哦哦,有人开始挑战 Koishi 源码学习了吗,我很感兴趣,插个眼

4 个赞

当前版本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的拼装。 :roll_eyes:

4 个赞

以后可能就不是了(

3 个赞

百年 一年未有之大变局!

2 个赞

我好好奇什么时候能解读到第二个文件()

1 个赞

就在今天!

4 个赞

前面提到,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的指令就十分丑陋

image
这段代码从start里导入了一个函数,这个函数的作用是在cli上注册了一个“start”指令,对应的完整指令就应当是koishi start
这个很美观的指令就是在这里注册的。
为了一探究竟,在package.json里添加了一个指令。
image
可以看到,确实在什么具体指令也没有写的情况下显示了koishi的版本号以及一些帮助。
那么我们就去start.ts里看看里面具体做了什么吧。

3 个赞

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了,你这个鲨妖凶手!)

6 个赞

好好好

1 个赞

冷知识:你可以直接使用 yarn koishi 来查看用法。

4 个赞

其实是被父母杀掉,这狠心的爹妈(怎么听起来更血腥了

3 个赞

冷知识:执行 koishi -h 会重复出两次帮助信息

3 个赞

:pleading_face: :pleading_face: :pleading_face:

2 个赞

还真是,建议开个 issue。

4 个赞

还记得打工人吗?我们今天就来看看她。
我们昨天将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里就可以感到到其专业性(字面意思
image
注:此处为完整readme,无任何删减

总而言之,这是一个log模块,通过定义不同的targets来显示遵循定义的log。
perpare函数的效果则是将配置传输给了Logger,设置的这些属性是静态属性,是绑定到类的,完成属性的设定之后以后调用的就是这个Logger了。

4 个赞

im50

3 个赞

Yuki 为什么是神

4 个赞