GitHub 上的好玩文章:零状态的 Koishi

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

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

3 个赞

合集链接:

https://k.ilharp.cc/939

1 个赞

(严格)零状态的 Koishi

一、定义

零状态的 Koishi 是指,对于一个 Koishi 实例,机器人每次处理的会话和本实例其他会话无关 。对于一个实现了零状态的 Koishi 实例,在进行冷重启后,所有插件能保持之前的状态继续运行。

严格零状态的 Koishi 是指,对于一个满足零状态的 Koishi 实例,在进行任何会话处理过程中,不对文件系统进行任何形式的写操作。

二、设计动机

实现了「零状态的 Koishi」的项目将会获得以下优点:

2.1 热重载

插件可以在机器人进行任何形式的重启,或进行插件重载操作前后,所有插件保持之前的状态正常运行。

2.2 云原生友好

零状态的 Koishi 实例可以在云原生环境下顺利地正常运行,依托于云原生环境实现高可用。这里我们以 Kubernetes 和云函数这两种典型的云原生环境为例进行分析。

2.2.1 Kubernetes

在 Kubernetes 下,Pod 会随时创建并销毁,此时不满足零状态的 Koishi 实例会随 Pod 丢失而丢失状态,可能会有部分异常的行为。而满足零状态的 Koishi 实例可以在 Pod 重新创建的过程内,保持正常的行为。

如果实例对文件系统进行了写操作,则写入的文件所在的目录需要在 Pod 中映射为卷,以保持数据的持久化。而满足严格零状态的 Koishi 实例无需考虑映射卷的问题。

2.2.2 云函数

云函数环境内,Koishi 实例仅会随请求到达平台而初始化,不包含任何持久化文件系统。严格零状态的 Koishi 可以在该环境下正常运行。

2.3 多例

对于服务器硬件资源不足、云原生的情况、或是增加 SLA 的考虑,我们或许需要使用多个服务器来同时运行 Koishi 实例。实现了严格零占用的 Koishi 实例可以完全胜任这种情况,以多个服务器同时运行机器人应用,共同接收消息。而实现了零占用的 Koishi 实例,可以通过 NFS、Ceph 等共享文件系统的方式,共享共同写入的部分,实现多例运行。

三、生态现状

由于无法统计当前生态哪些插件是零状态的,这里我们只举几个例子。

3.1 符合严格零状态的 Koishi 插件

koishi-plugin-rryth

由于用户使用 rr 指令绘图的时候,Koishi 请求 42 的服务器,并将结果输出,因此该插件是严格零状态的。

koishi-plugin-dialogue

该插件进行的任何操作均经过数据库处理。用户定义回答时,Koishi 会对数据库进行写操作。而用户请求回答时,Koishi 从数据库内取出结果并返回给用户。这两个过程都把状态转移至数据库,因而在插件内都是零状态的。

3.2 符合零状态,但是不符合严格零状态的 Koishi 插件

在云原生环境下使用这些插件时,需要将该插件所读写的文件进行共享操作,保证各实例可以共享相关文件系统的状态。

@koishijs/plugin-database-sqlite

该插件对存在于 Koishi 工作目录内的 .koishi.db 进行了读写操作。

koishi-plugin-assets-local / koishi-plugin-assets-git

该插件会在本地目录内保存相关的资源文件。

3.3 不符合零状态的 Koishi 插件

koishi-plugin-chatgpt

该插件使用了一个在插件启动时定义的 Map 对象存储各个 Conversation 和用户 ID 的映射关系。因此该插件经过重载或在多例或云原生环境运行时,用户可能会丢失自身的聊天会话。该插件也很方便进行修改以符合严格零占用,只需要将 Map 的 get 和 set 操作,更换为 Cache API 的 get 和 set 操作即可。

koishi-plugin-bilibili

该插件会在加载时进行数据库请求,并根据订阅的每个 UP 主设置一个定时任务轮询请求 B 站的事件。实例多例运行时,当相关事件触发会导致每一实例均收到请求,导致用户收到多份提醒。

此外,用户进行订阅和取消订阅操作时,订阅操作在内存进行,并设置或取消新的定时任务。这可能在某些特定情况下,让某些用户的订阅失效。

koishi-plugin-gocqhttp

该插件会在内存内维护 go-cqhttp 进程的状态,因此不是零状态的插件。

@koishijs/plugin-adapter-discord

该插件会维护到 Discord 的 WebSocket 通信,具有状态,因此不是零状态的插件。

四、实现原理

4.1 不使用变量

要实现零状态的 Koishi ,非必要不定义插件范围变量或插件类属性,且插件会话处理过程内不对这些变量或属性进行修改。下面时两个反面教材:

# NG 1
export function apply(ctx: Context, config: Config) {
  let count = 0;
  ctx.command('foo')
    .action(() => return count++) // 修改了 count 变量,从而改变了插件状态
  ctx.command('bar')
    .action(() => {
      let num = 1;
      num++; // 改变会话处理过程内的局部变量不会改变插件状态
      return num;
    })
}

# NG 2
export default class MyPlugin {
  map = new Map<string, string>(); // 类属性也会导致插件有状态
  constructor(ctx: Context, config: Config) {
    ctx.command('foo')
      .action((argv, arg) => this.map.set(argv.session.userId, argv, arg))
  }
}

此外,插件注册的 Koishi 事件也属于状态的一种类型。因此在会话处理过程内,应避免注册 Koishi 相关事件。

4.2 状态转移

若插件的设计上需要保存某些状态,我们可以使用数据库或 Cache 等方式,将状态转移至实例外。koishi-plugin-dialogue 正是利用该方法,将状态转移到了外部,从而实现零状态。

4.3 常见严格零状态设计

这几种 Koishi 插件的设计通常情况下是严格零状态的。

4.3.1 简单请求

这些插件会注册一个到多个指令或事件,处理过程为请求外部服务,并完全根据返回的结果回复用户。

rryth、setu、pics、novelai 等插件属于这种类型的插件。

4.3.2 CRUD

这些插件的会话处理过程中,仅对 Koishi 的数据库进行增加、删除、更新、查询操作,并根据结果回复用户。

dialogue 属于这种类型的插件。

4.3.3 运算

这些插件仅会在内部逻辑完成相关的运算,并将结果返回给用户。

dice 属于这种类型的插件。

五、不足

5.1 插件安装

部分实现略微复杂的零状态 Koishi 插件,由于需要在外部保存状态,而具有对 database 或 cache 服务的依赖。安装这些插件之前,需要先安装并配置这些服务。

5.2 session.prompt

部分插件存在 session.prompt 的使用,以在指令处理过程中对用户提出问题,以期待用户回答获取更多信息。但是 session.prompt 会注册一个中间件接收回答,从而破坏了插件的零状态性。

对于这个问题的解决,社区内有待商榷。

六、对比

6.1 零状态与零占用

零状态与零占用描述了 Koishi 的两个不同的特征,都能在一定程度上优化用户的体验,但是不具有必然的关系。

  • 零状态的 Koishi 插件不一定是零占用的。例如 koishi-plugin-petpet 属于 4.3.3 类型的,因而是严格零状态的,但是并不是零占用的。
  • 零占用的 Koishi 插件不一定是零状态的。例如 koishi-plugin-gocqhttp 是零占用的,但是并不是零状态的。
  • koishi-plugin-rryth 是零状态的,也是严格零占用的。

6.1.1 逻辑关联性

  • 不具有二进制资源的严格零状态的 Koishi 一定是零占用的 Koishi。
  • 零占用且零状态的 Koishi 一定是严格零状态的 Koishi。

6.2 零状态与正交

正交的 Koishi 比较容易成为零状态的,由于可以轻松利用服务将状态转移到外部。

6.3 零状态与可逆

零状态与可逆的 Koishi 均能给用户带来比较良好的体验,但是二者关联性并不强。

6.4 零状态与 0dt

借助云原生的实施条件,零状态的 Koishi 一定是 0dt 的 Koishi。

0dt 的 Koishi 要满足计划内重启不会丢失用户请求与插件运行状态。零状态的 Koishi 可以满足该充分条件的更严格版本:在持续运行的过程中发生任何形式的重启,期间 Koishi 可以保持工作。

首先由于已经是零状态的,所以自然不会丢失任何插件的运行状态。我们接下来分析是否会丢失用户请求。

  • 需要进行计划更新或重启的时候,我们可以利用 Kubernetes 的灰度更新等机制,先将一部分 Koishi 实例在负载均衡系统中下线,并先更新或重启这些实例,然后待其正常之后,再进行上线操作。这个过程内,未参与维护的实例仍然可以继续响应用户请求,因此不会错过任何一个用户请求。

  • 出现意料外重启的时候,负载均衡会探测到该 Pod 已经不可用,因此会自动从负载均衡中移除,随后会生成新的 Pod 加入负载均衡。这个过程内,只要不是所有 Koishi 实例同时发生故障,就不会错过任何一个用户请求。因此是否丢失用户请求取决于部署的系统的整体可用性,99.9% 的情况下都不会丢失用户请求。

4 个赞

哦哦哦 这篇好帅!很有含金量 很喜欢

哦哦哦 这篇好帅!很有含金量,我下个版本就把 rryth 改成非零状态的~

1 个赞

补充说明:0dt 的 Koishi 仅考虑持续运行的情况,不考虑云原生的情况,因此对于 0dt 讨论范围内的插件来说,即便实现了零状态,依然会存在 downtime。0dt 和云原生对持续运行的定义不同,可能会极大地误导用户。需要指出的是:对于在本地和服务器上运行的 Koishi 来说,实现零状态对于提高 SLA 是无益的。换句话说,0dt 和零状态分别是传统方式运行和云原生两种方式的最高要求。

除此以外,与可逆、零占用不同,我们并不会要求所有的插件实现 0dt 或零状态,因为在具体情况下,实现了 0dt 或零状态的插件可能反而会大幅降低性能。因此,更合理的做法其实是对于现有的插件,应当严格避免直接实现零状态,同时也避免编写对零状态不友好的代码,而是通过调用服务的方式编写通用代码,让使用者根据自身需求决定实现服务的插件

举个例子:开发者应当使用 cache 服务进行状态管理,而使用者可以在通常环境下使用 cache-memory 以获得更高的性能,并在云原生场景下使用其他缓存服务。

2 个赞