Koishi学习笔记

以后可能就不是了(

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 个赞

爷青回!

顺便这个修了

5 个赞

daemon.ts:

import { Context, Schema } from '@koishijs/core'

export interface Config {
  autoRestart?: boolean
  heartbeatInterval?: number
  heartbeatTimeout?: number
}

export const Config: Schema<Config> = Schema.object({
  autoRestart: Schema.boolean().description('在运行时崩溃自动重启。').default(true),
  heartbeatInterval: Schema.number().description('心跳发送间隔。').default(0),
  heartbeatTimeout: Schema.number().description('心跳超时时间。').default(0),
}).description('守护设置').hidden()

Context.Config.list.push(Schema.object({
  daemon: Config,
}))

export const name = 'daemon'

export function apply(ctx: Context, config: Config = {}) {
  function handleSignal(signal: NodeJS.Signals) {
    // prevent restarting when child process is exiting
    if (config.autoRestart) {
      process.send({ type: 'exit' })
    }
    ctx.logger('app').info(`terminated by ${signal}`)
    ctx.parallel('exit', signal).finally(() => process.exit())
  }

  ctx.on('ready', () => {
    process.send({ type: 'start', body: config })
    process.on('SIGINT', handleSignal)
    process.on('SIGTERM', handleSignal)

    config.heartbeatInterval && setInterval(() => {
      process.send({ type: 'heartbeat' })
    }, config.heartbeatInterval)
  })
}

看到这个apply了吗?

export function apply(ctx: Context, config: Config = {}) {
// ...
}

我们终于看到第一个插件了,看哭了。
从官方文件了解到,三种形式可以被认定是一个插件。
认识插件 | Koishi
对于一个模块,可以是类或者对象。
翻到对应的定义:

export type Plugin<C extends Context = Context, T = any> =
  | Plugin.Function<C, T>
  | Plugin.Constructor<C, T>
  | Plugin.Object<C, T>

export namespace Plugin {
    interface Base {
        name?: string;
        reactive?: boolean;
        reusable?: boolean;
        Config?: (config?: any) => any;
        inject?: string[] | Inject;
        /** @deprecated use `inject` instead */
        using?: string[] | Inject;
    }
    interface Function<C extends Context = Context, T = any> extends Base {
        (ctx: C, options: T): void;
    }
    interface Constructor<C extends Context = Context, T = any> extends Base {
        new (ctx: C, options: T): void;
    }
    interface Object<C extends Context = Context, T = any> extends Base {
        apply: (ctx: C, options: T) => void;
    }
}

可以看到,base中的是预留的属性,作特殊用途,但全都都是可选的。所以这个模块毫无疑问符合插件的定义。

而运行时是js,对于函数参数的限制仅被ts限制了,所以其实这个函数/方法可以是任意参数的,因此:
只要有个apply方法的对象就是koishi插件!
只要是个类就是koishi插件!
只要是个函数就是koishi插件!
(好孩子不要学)

这个插件和我们的插件写的插件是一样的,都是koishi(cordis)插件围绕ctx做一些事情。
我们看看apply做了什么事情,整体来看,它是一个一个一个……保姆?
插在koishi身上负责照顾孩子。每天上班就给雇主发消息:“孩子她妈,娃醒了。”
然后时不时还要再给雇主发消息安顿雇主:“孩子她妈,娃在撒尿”,“孩子她妈,娃在大叫”,“孩子她妈,娃在玩眼球”。
雇主就放心了。
假如不这么做——

雇主就会发癫鲨掉娃儿!然后再生一个!

雇主就会着急跑回家,照顾好孩子,让她能睡个好觉。

除此之外呢,她还要负责接收雇主的短信当雇主说,“孩子该休息了”她就会让孩子料理后事刷牙洗脸,然后睡个好觉。

6 个赞

终于我们要解读koishi包下的最后一个文件了,
worker/index.ts:

import { Context, Dict, Logger, Schema, Time } from '@koishijs/core'
import Loader from '@koishijs/loader'
import * as daemon from './daemon'
import * as logger from './logger'

export * from 'koishi'

declare module '@koishijs/core' {
  namespace Context {
    interface Config {
      plugins?: Dict
      timezoneOffset?: number
      stackTraceLimit?: number
      logger?: logger.Config
      daemon?: daemon.Config
    }
  }
}

Object.assign(Context.Config.Advanced.dict, {
  timezoneOffset: Schema.number().description('时区偏移量 (分钟)。').default(new Date().getTimezoneOffset()),
  stackTraceLimit: Schema.natural().description('报错的调用堆栈深度。').default(10),
  plugins: Schema.any().hidden(),
})

function handleException(error: any) {
  new Logger('app').error(error)
  process.exit(1)
}

process.on('uncaughtException', handleException)

process.on('unhandledRejection', (error) => {
  new Logger('app').warn(error)
})

async function start() {
  const loader = new Loader()
  await loader.init(process.env.KOISHI_CONFIG_FILE)
  const config = await loader.readConfig(true)
  logger.prepare(config.logger)

  if (config.timezoneOffset !== undefined) {
    Time.setTimezoneOffset(config.timezoneOffset)
  }

  if (config.stackTraceLimit !== undefined) {
    Error.stackTraceLimit = config.stackTraceLimit
  }

  const app = await loader.createApp()
  app.plugin(daemon, config.daemon)
  await app.start()
}

start().catch(handleException)

可以看到,我们又往config里塞了一些东西:

declare module '@koishijs/core' {
  namespace Context {
    interface Config {
      plugins?: Dict
      timezoneOffset?: number
      stackTraceLimit?: number
      logger?: logger.Config
      daemon?: daemon.Config
    }
  }
}

怎么一会儿用assign一会用defineProperty

定义了高级配置和plugins
这个plugins包含了所有插件相关的设定,但这个东西应该用独立的方式处理,而不能简单用Schema显示。
然后做了最基本异常处理处理方式仅仅是忽略异常并显示通用的异常信息,避免出现异常导致整个崩溃。
对于插件的作者,即使koishi已经有了最基本的异常处理,你也应当自己处理插件内部的异常,避免异常影响的扩散。
接下来,定义了start函数后立即调用,在这个进程中启动koishi。
start函数中,除了初始化高级配置中的参数,有一个叫loader的东西在活跃着。它从线程中获取了配置文件的地址,解析后读入,然后启动了一个app并且加载了第一个插件——daemon。
具体做的事情让我们接下来深入了解这个包吧。

4 个赞

Object.assign is used to assign properties to a specific object from another one, which only clone the properties mentioned. But Object.defineProperty not only create a new property but it also sets the parameters to this property such as getter, setter, writable, et cetera.

Object.assign 用于将属性从另一个对象分配给特定对象,它仅克隆提到的属性。 但是Object.defineProperty不仅创建一个新属性,而且还设置该属性的参数,例如getter、setter、writable等。

4 个赞

迷子好厉害,学习了!

3 个赞