GitHub 上的好玩文章:可逆的 Koishi

今天也在 GitHub 搜 Koishi,搜到了这个,看起来也不是官方发的,但是感觉好好玩,就转载过来了。

然后我感觉这篇文章里讲的东西有很多其实是不合理或者难以实现的,所以只是图一乐。

2 个赞

可逆的 Koishi

可逆是 Koishi 插件开发哲学中影响最深远的一个。

一、定义

可逆的 Koishi 是指,对于任何一个 Koishi 实例,任意进行加载和卸载插件操作后,实例行为仅与最终启用的插件相关;与中间是否重复加载过插件、插件之间的加载或卸载顺序都无关。你也可以简单理解为「路径无关」。这里的相关和无关具体包括:

  • 多次加载的插件不会重复注册任何行为
  • 多次加载并卸载一个插件后,内存占用不会增加
  • 加载并卸载任何插件不会残留对其他插件的影响
  • 如果插件之间有依赖关系,依赖的插件会自动在被依赖的插件之后加载,并自动在被依赖的插件之前卸载,即确保插件的生命周期由依赖关系而非加载顺序决定

Il 称之为「零熵」,意思是一样的,只是他发明了这个词而已。

二、设计动机

实现了「可逆的 Koishi」的项目将会获得以下优点:

2.1 热重载

Koishi 的所有插件都将可以在运行时加载、卸载和重载,减少了用户的更新成本。

2.2 动态加载

由于插件的加载顺序由依赖关系决定,因此插件的代码可以被异步地加载,而不需要担心加载顺序对可用性的影响。

2.3 可追踪

可以对 Koishi 输出的任何内容追踪插件来源(尽管目前暂无下游实现)。

三、生态现状

目前的 Koishi 生态普遍依赖此模式。

3.1 依赖服务的插件

Koishi 存在大量依赖服务的插件。任何插件可以声明自身依赖某些服务,由 Koishi 确保插件只在服务加载完成后加载,并在卸载开始前卸载。

3.2 @koishijs/plugin-market

@koishijs/plugin-market 提供了多个页面:

  • 插件管理:允许用户在运行时启用、停用、修改插件配置,而不用重启 Koishi
  • 插件市场:允许用户在运行时安装并启用新插件,而不用重启 Koishi
  • 依赖管理:允许用户在运行时更新版本、卸载插件(需要重启 Koishi)

3.3 @koishijs/plugin-hmr

@koishijs/plugin-hmr 允许用户在开发过程中直接通过保存代码来按需重载插件源码和插件配置。这是非常少见的后端 HMR(Hot Module Replacement,模块热替换)实现。

3.4 @koishijs/plugin-console

值得一提的是,由于可逆性是在 Cordis 架构中提供的,因此不仅 Koishi 的后端是可重载的,前端也是可重载的。

四、实现原理

4.1 基本概念

由于缺乏合适的翻译,这里直接用英文概念来表达。

  • 我们称一个代码片段是 可回收 (disposable) 的,如果其返回某个回收函数,执行该函数会清除代码的副作用 (包括注册的行为、占用的内存、依赖该代码片段的其他代码片段的加载)
  • 我们称一个函数是 自回收 (self-disposable) 的,如果其包含的代码片段是可回收的,并且回收函数会在这个函数所在的代码片段被回收时调用

在这个定义下,「可逆的 Koishi」可以被等价表达为 所有 Koishi 插件都是可回收的

4.2 插件实现

要实现一个代码片段的可回收需要确保代码片段中的每一条语句都满足以下三个性质之一:

  • 无副作用 (含副作用可以被运行时 GC 的情况)
  • 有副作用,但是副作用被其他语句手动回收
  • 是自回收的函数的调用

目前绝大部分 Koishi 提供的 API 均实现了无副作用或者自回收:

  • 无副作用:
    • ctx.any()
    • ctx.database.get():可能会创建一个连接,但连接有超时机制,因此可以视为无副作用
  • 自回收:
    • ctx.command():含链式调用中的 .option() 等,会创建多个回收函数
    • ctx.on()
    • ctx.middleware()
    • ctx.plugin():如果插件是可回收的,那么它就是自回收的
    • ctx.i18n.define()
    • ctx.component()
    • ctx.setInterval()
    • ctx.router.get():通过修改了 Koa Router 的行为实现
  • 有副作用且不自回收:
    • ctx.model.extend():尽管有副作用,但认为影响不大,且保留此副作用有利于优化,故不进行回收

所以对于插件开发者来说,只需要通过 dispose 事件处理非 Koishi 官方 API 并且有副作用的例子即可,因此并没有明显的额外心智负担。

4.3 服务实现

服务方法的副作用通常需要服务自己进行回收,也就是说需要设计为自回收的。需要通过 ctx.scope.collect() 进行实现,目前普遍使用的服务均支持此功能,但由于此部分没有收入官方文档,社区中的服务开发者可能并未熟知相关的技术。

五、对比

5.1 可逆与正交

为了确保服务 API 的可逆性,通常需要对服务进行正交化设计。

5.2 可逆与零占用

可逆本身与零占用无关,但是可逆与零占用共同组成了热更新的基石。

5.3 可逆与 0dt

可逆并未实现 0dt,但热更新最大限度地减少了用户在生产环境下的重启次数,客观上增加了 SLA。

1 个赞

合集链接:

https://k.ilharp.cc/939

2 个赞

案例分析:指令管理

本文将以 指令管理 为例,分析 Koishi 中的可逆插件开发。

该功能由独立的官方插件 @koishijs/plugin-commands 实现,插件的源代码位于 此目录 下。

一、功能描述

从文档中可以看到,指令管理插件提供了以下功能:

  • 设置别名和显示名称
  • 添加和删除子指令
  • 权限管理
  • 速率限制

与此同时,该插件也允许其他插件通过 ctx.schema 服务注册更多可以被该插件所配置的功能。

上述所有行为都通过配置文件进行持久化,即:当用户在控制台中做出任何改动时,插件本身的配置项发生变化,并同步至配置文件中;当整个实例重启时这些改动会依然生效。

二、实现要求

该插件要实现可逆性,需要满足以下具体的性质:

2.1 副作用回收

可逆的 Koishi 要求 commands 插件回收自身产生的一切副作用,即:当插件被停用时,用户对指令系统做出的一切改动应当复原:已修改的配置项、指令的别名和显示名称全部恢复为未启用此插件时的状态;创建的新指令被移除;被调整的父子指令关系被复原。

2.2 控制台依赖

commands 插件的功能可以分为两个部分:

  1. 读取配置项,并对指令系统做出改动
  2. 为控制台提供专门的「指令管理」页面,并允许用户进一步改动指令系统

其中 1 不依赖控制台插件,2 依赖控制台插件。可逆的 Koishi 要求该插件被启用时,1 会立即生效,而 2 则仅当 console 插件启用时生效;当 console 插件停用时,2 会立即失效并回收相关副作用,1 保持生效。

2.3 指令依赖

可逆的 Koishi 要求插件的加载顺序可以是任意的。而 commands 插件会改动某些指令的行为,因此其不能假定其在加载时对应的指令已存在。具体而言:

  • 如果对应的指令已存在,那么立即进行对该指令的改动
  • 如果对应的指令尚不存在,那么不做出任何改动;当指令被创建时再进行改动
  • 对于将 A 指定为 B 的子指令的情形,如果 B 不存在而 A 已存在,则先只对 A 进行除指定父指令以外的改动;当 B 指令被创建时再将 A 设置为 B 的子指令

三、实现原理

以上三个部分使用了不同的实现原理。

3.1 副作用回收

使用 dispose 事件配合 ctx.command() 本身的自回收实现。

3.2 控制台依赖

使用 using 实现。

3.3 指令依赖

对于未加载的指令,维护一个等待中的改动队列,并同时监听 command/added 事件以实现。

四、总结

commands 插件的设计充分体现了 Koishi 的可逆性。尽管该插件需要考虑非常多边界情况,但得益于自回收、生命周期、服务依赖、事件系统等特性,所有的情况都能够被妥善处理。事实上,大部分插件并不会遇到如此复杂的情况;对于可逆性的考量也更多地存在于架构设计上,并不会显著增加插件开发者的心智负担。

4 个赞