Koishi v5 发布预告

今天是 5 月 14 日,也就是俗称的恋(Koishi)之日。

Koishi 的名称和图标设计均来自于东方 Project 中的角色古明地恋(Komeiji Koishi)。而 514 在日语中恰好与 Koishi 谐音,因此每年的 5 月 14 日就被称为恋之日。

嗯,这也是 Koishi 默认端口号 5140 的由来。

确实是一个好日子,因为就在昨天,Koishi 的 star 数量刚刚突破了 4000 大关。

回顾 Koishi 的历史,距离 v4 发布已经过去了两年有余。很少有人能想象 Koishi 发展的有多快。整个 2023 年间,插件的数量增长了 480% 之多,而如今也即将达到 1500 个。

在这样的一个日子里,不整点大活简直对不起 Koishi 这个名字(有吗)。具体做什么好呢?干脆我们来预告一下 v5 版本吧!

15 个赞

下一个大版本

近期 Koishi 的更新速度确实有所放缓。相比 2023 年不断加入新功能,2024 年的 Koishi 基本上都是在对代码做一些重构和修复。或许 Koishi 自身已经达到了一些相对完善的水平,没有那么多东西可以做了。

——真的是这样吗?

真相是,在过去的几个月里,我们一直在筹备一个足够重量级的更新。而今天,在攻克了几乎所有的技术难点之后,我们终于可以高兴地向大家宣布,Koishi v5 将很快与大家见面。

虽然现在大家还不能立即体验到 Koishi v5,但这篇文章会对即将到来的新版本做一个全面的介绍。

3 个赞

目录

正在阅读这篇文章的你也许是 Koishi 的插件开发者,也许是机器人应用的搭建者,也许是普通用户,甚至可能是来自其他框架的作者。对于不同的群体,Koishi 能够带来的特性其实有所不同,因此你可以根据目录浏览自己感兴趣的部分。

  • 第一部分:配置文件

    Koishi 插件系统的表达能力更进一步。新版的配置文件引入了更多进阶特性,包括服务隔离、服务拦截、插件包和配置导入等。

  • 第二部分:生态系统

    Koishi 将不再作为唯一的框架存在,而是更大的生态系统中的一环。用户将可以在没有 Koishi 的情况下享受现有插件的诸多便利。

  • 第三部分:ESM

    Koishi 将在下个版本转向纯 ESM 运行。这其中并非只有构建目标的改动,为了支持现有 Koishi 的各种能力,我们研发了数个首创性的技术。

在本文的最后,我们将对一些大家可能好奇的问题进行统一回答,例如破坏性变更、发布时间表等。

3 个赞

第一部分:配置文件

Koishi v4 在插件系统的设计上已经具备极高的自由度,但 v5 又在此基础上让表达能力更进一步。

Koishi 升级到 v5 引入的最直观变更是配置文件的格式。新版的配置文件引入了更多进阶特性,包括服务隔离、服务拦截、插件包和配置导入等。

3 个赞

1.1 一切皆插件

现在的配置文件大概长这样(简化版):

prefix:
  - '/'
  - ''
plugins:
  http: ...
  server: ...
  group:
    adapter-onebot: ...
    adapter-discord: ...

升级到 v5 后,你的配置文件将会变成这样(简化版):

- name: 'koishi'
  config:
    prefix: 
      - '/'
      - ''
- name: '@cordisjs/plugin-http'
- name: '@cordisjs/plugin-server'
- name: 'cordis/group'
  config:
    - name: '@satorijs/adapter-onebot'
    - name: '@satorijs/adapter-discord'

简单来说就是:一切的一切都变成插件了!

  • Koishi 作为框架是一个插件,你过去的全局配置变成了这个插件的配置项;
  • 插件组也是合法的插件,插件组中的子插件变成了这个插件组的配置项。

这种设计不仅从外观上更加统一,还为我们带来了更多潜在的好处。我们将在下面逐步揭示。

3 个赞

1.2 服务隔离

服务隔离允许你同时拥有同名服务的多个实例。想象一下这些需求:

  • 我已经为我的实例安装了 assets 服务,但是我希望某些插件不使用该服务;
  • 我的多个插件都使用了 database 服务,但是我希望其中的某张表存在本地的 SQLite 中,其他的表存在云端 MySQL 中;
  • 我希望使用 puppeteer 插件的截图功能,但是不希望它提供 canvas 服务,canvas 服务使用 skia-canvas 插件提供。

这些事情在 v4 中都是办不到的,但是有了新版配置文件,你可以轻松做到!你要做的只是在合适的位置创建「隔离域」。隔离域会确保其内外的服务使用不同的命名空间,因此即便安装两个服务插件都不会发生冲突。举个例子:

- name: 'cordis/group'
  isolate:
    database: true
  config:
    - name: 'koishi-plugin-foo'
    - name: '@minatojs/plugin-sqlite'
- name: 'koishi-plugin-bar'
- name: '@minatojs/plugin-mysql'

在这个例子中,第一个插件组就创建了针对 database 服务的隔离域。插件组内的插件访问 database 时,只能访问到插件组内插件提供的 database;插件组外同理。这样一来就轻易实现了上面的第二个需求。

除了设置为 true 表示这是一个独一无二的隔离域外,我们还支持设置具名隔离域,以及隔离域嵌套等等功能。它的表达能力足以应对各种复杂的场景。

5 个赞

1.3 服务拦截

服务拦截解决的则是另一类问题:

  • 某个插件使用 http 服务进行网络请求,我希望令这个插件使用代理,但其他插件不受影响;
  • 某个插件使用 server 服务注册了路由,我希望修改这个插件注册的路由;
  • 某个插件使用 canvas 服务渲染图片,我希望定制这个插件生成的图片压缩率。

对于这类问题,最简单的解决方式就是让插件作者加个配置项(是否使用代理、注册的路径、图片压缩率)。但是与其要求每一个此类插件都提供此类配置项,我们有更好的办法。

在 Koishi v5 中,任何一个依赖了 http 服务的插件,都会提供专门的配置项,允许用户设置该插件访问 http 服务时的行为,例如使用的代理、自定义的请求头等等。你可以单独为每一个插件或者插件组做此设置。在配置文件里大概长这样:

- name: 'koishi-plugin-foo'
  intercept:
    http:
      proxyAgent: 'http://127.0.0.1:7890'

事实上,大家已经熟知的「过滤器」也是服务拦截的特殊情况,它相当于是对名为 Koishi 的服务的拦截设置。

6 个赞

1.4 插件包

Koishi v5 允许开发者发布带有预设的插件组,也就是「插件包」。它有许多使用场景:

  • 我整理了一系列实用插件,用户只需要安装我的插件就自动安装了全部的插件;后续我还可以更新这个插件包,用户随时可以获得最新的插件列表;
  • 我编写了一个小型插件生态,由一个核心插件和一些外围插件构成(比如 dialogue 和 booru),每一个插件的体积都不大,我希望用户能一次性地安装好,并根据自己的需求选择要用哪些插件。

插件包在外观上与插件组完全一致,但是可以作为独立插件发布到插件市场中。插件包中的插件既可以是包中写好的,也可以是其他包中发布的。此外,插件开发者还可以在一个包中发布多个插件,并将入口设计为插件包,以适应更加复杂的需求。

4 个赞

1.5 外部配置导入

最后,Koishi v5 还允许将配置文件拆分为多个。由于配置文件自身的结构就是一个插件组,因此配置文件也可以视为一个插件!这意味着你可以:

  • 将一部分配置单独写进一个文件中,并在另一个配置文件中引用;
  • 将一部分配置存储在数据库中,并通过一个「配置数据库」插件加载。

无论如何组织你的配置文件,你都会获得与单个文件相同的使用体验:在插件管理中,所有的外部配置看起来都像是普通的插件组,只不过你对其进行的修改会写入另一个文件或是数据库中。

3 个赞

第二部分:生态系统

在 Koishi 庞大的插件生态中,有数个插件系列作为基础设施存在。例如以 adapter- 开头的适配器插件,以 database- 开头的数据库插件,等等。这些插件系列有着自己的组织形式和设计哲学,并且核心逻辑也并未与 Koishi 深度耦合,本质上可以看做另一个生态。如果说 Koishi 是一个框架的话,那么适配器背后的 Satori、数据库背后的 Minato 也都是独立的框架。

这对 Koishi 的用户来说其实没啥不好,不过既然是独立的框架,或许将它们与 Koishi 放在同等地位有着更加深远的意义。由此,我们要向大家介绍 Koishi v5 的第二个重大更新:生态系统。

3 个赞

2.1 脱离 Koishi 使用某些插件

如果你希望脱离 Koishi 使用来自 Minato 或是 Satori 的插件,你要怎么办呢?答案很简单,编写以下的代码即可:

import { Context } from '@satorijs/core'
import DiscordBot from '@satorijs/adapter-discord'
const ctx = new Context()
ctx.plugin(DiscordBot)

不过这种方法有两个问题:

  1. 并不是所有的框架都支持这种写法:事实上,我们仅对 Minato 和 Satori 进行了支持;
  2. 这种写法既不能通过配置文件加载,也不能在 Koishi 的控制台、客户端中呈现。

因此,我们在 v5 的目标是,所有的不同生态的插件仍然可以在统一的环境下安装和运行,而 Koishi 则变成可选的一环。

4 个赞

2.2 框架即插件,插件即框架

在上面的配置文件示例中,你已经看到了名为 Koishi 的插件。事实上,新版配置文件会还包含名为 Minato、Satori 的插件。这些框架现在平等地以插件的形式成为了配置文件的一员。如果想要脱离 Koishi 使用某些插件,只需要把名为 Koishi 的插件停用即可——甚至此时控制台仍然是可用的状态,你依然可以使用配置管理和插件市场!这就是前半句话「框架即插件」的含义。

另一方面,如果你仅仅是 Cordis 的用户,在一个完全纯净尚未安装 Koishi 的环境下,你的插件市场中并不会显示任何的 Koishi 插件。随着你安装名为 Koishi 的插件本身,所有的 Koishi 插件会立即呈现在你的插件市场中。换句话说,任何人可以声明自己编写的插件是一个新的框架。开发者可以摆脱 Koishi 的限制,为自己的插件找到更大的使用场景。这就是后半句话「插件即框架」的含义。

如果你想要开发其他的框架,或是单纯对这两项技术的细节感兴趣,可以在 v5 更新后查阅相关的文档。

4 个赞

2.3 弥补 pnpm 的缺陷

框架生态系统所体现出的另一个意义,是它弥补了 PNPM 和 Yarn PnP 等包管理模式的一个重大缺陷。这就有点说来话长了,如果对包管理器不感兴趣的可以跳过这一段。

4 个赞

2.3.1 三种包管理模式

包管理器的存储方式可以大致分为 hoist 和 isolate 两种。

  • hoist 是将所有依赖存放于同一个目录下,每一个依赖都可以访问到其他任何依赖。Python 的 site-packages 就是采用的这种设计。这种设计的好处是非常简单,坏处是不允许同一个依赖出现两个不同的版本。
  • isolate 则正好相反,它无比严格地要求任何一个依赖都只能访问到自己声明的依赖。Rust 的 Cargo target 就采用了这种设计。这种设计虽然实现起来比较麻烦,但是它能够允许依赖能够预期地出现多份。
  • 至于 Node.js 采用的是哪一种呢?一直以来 npm 使用的是一种介于两者之间的实现(姑且称之为 hierarchical)。如果没有依赖冲突就按照 hoist 方式存储,如果出现冲突就使用层级式存储,并且解析依赖时按照目录层级的语义进行解析。

虽然 isolate 确实比其他两种更为优秀,但是 npm 采用的折中方式在很长一段时间内确实够用了。直到 pnpm 和 Yarn Berry 的到来再次引发了人们对于 isolate 的关注。isolate 方案相比 hierarchical,在许多边界情况下能够减少错误(例如幽灵依赖问题),因此受到了人们的青睐。pnpm 因此称呼所有不使用此方案的人为 --shamefully-hoist

4 个赞

2.3.2 库 vs. 框架,dep vs. peerDep

如果 isolate 真的有上面说的这么完美的话,这么称呼也没问题。然而,isolate 在解决了几乎所有问题的同时,也留下了一个隐藏的巨大缺陷:它无法完全支持 peerDep。

我假设读到这里的人都理解 peerDep 是什么。框架与库在包管理器上的最大区别在于:框架的插件应当使用 peerDep 而非 dep 来声明对框架的依赖,而软件的依赖库就只需使用 dep 声明即可。这是为了防止框架本身的多例。换句话说,框架应当是用户在安装插件之前就已经安装好的,而依赖库则是用户在安装软件的时候自动安装的。

如果一个项目中仅存在一个框架,isolate 确实能完美解决。但是当框架之间出现派生关系时,情况就变了。现在我们用 Koishi 和 Satori 来举例说明 isolate 如何无法编排派生框架下的依赖。

Satori 有一套适配器的系统,用户可以自行安装所需的适配器以获得平台支持。Satori 提供了对 Discord 和 Telegram 平台的支持,因此发布了插件 @satorijs/adapter-discord 和 @satorijs/adapter-telegram。根据规范,这两个插件应当将 Satori 作为 peerDep。用户使用 Satori 及其适配器的方式是,安装 Satori 和它的适配器。此时用户的 package.json 中包含三个依赖,它们满足 peerDep 的规定,并且能在 pnpm 上正常工作。

现在有了另一个框架名为 Koishi。Koishi 依赖(dep)Satori 并且也有自己的插件系统。用户使用 Koishi 及其插件的方式与 Satori 类似:在安装 koishi,@koishijs/plugin-echo 和 @koishijs/plugin-help 之后,所有的依赖同样满足 peerDep 的规定,并且能在 pnpm 上正常工作。

4 个赞

2.3.3 菱形依赖

但是 Koishi 仍然希望移植 Satori 的生态,此时问题出现了。假设有一个名为 @koishijs/plugin-adapter-discord 的插件。首先,它的大部分逻辑与 @satorijs/adapter-discord 是一致的,因此其应当依赖(dep)后者;其次,它作为一个 Koishi 插件,应当 peerDep Koishi。当用户安装并真正运行这个插件时,预料之外的事情发生了:@satorijs/adapter-discord 有一个 peerDep 名为 Satori。尽管 Satori 已经被 Koishi 依赖了,但是它并未直接被用户安装,因此没有得到满足。在 hoist 或者 hierarchical 模式下,由于 Koishi 依赖了 Satori,后者被安装到了顶层,@satorijs/adapter-discord 能够正确访问到;但是在 isolate 模式下,没有任何办法能让 @satorijs/adapter-discord 访问到 Satori。这便是所谓的「菱形依赖」问题。

@koishijs/plugin-adapter-discord → dep @satorijs/adapter-discord
↓ peerDep ↓ peerDep
koishi → dep satori

在有 peerDep 的情况下,isolate 无法解决此类问题,核心原因就在于它完全没有考虑 peerDep 的存在。这种情况不仅对于 Koishi 如此,实际的 Node.js 生态中也有大量包受此影响,并且理论上无法解决。下图是一个随便创建的 slidev 项目,可以看到一安装就产生了大量 peerDep 报错。这些报错在 isolate 运行环境下就等同于同样数量的运行错误被抛出。

4 个赞

2.3.4 问题的解决

在过去,Koishi 的生态中就有着数十个这样的包。它们在安装时都会产生这样的报错,能够在 hierarchical 模式下运行但无法支持 isolate 模式。但是在框架生态系统的视角下,这个问题真正得到了解决:用户的依赖列表里同时出现了 koishi, satori 和 @satorijs/adapter-discord,所有的依赖关系都符合 isolate 的语义。就算 Koishi 生态要对上述适配器做一些进一步的定制,添加一个 @koishijs/plugin-adapter-discord 也完全可以正常工作。

有了框架生态系统,我们可以使用 isolate 模式安装应用,并获得 pnpm 和 Yarn PnP 等现代包管理器的优势:更快的安装速度、更少的多例概率以及更小的磁盘占用。

5 个赞

2.4 Cordis Desktop 与整合包

Koishi v4 其实已经有了整合包的概念,但是并未向大家推广。无论是使用 yarn create 创建出的模板项目,还是 Koishi Desktop 安装后的默认环境,都是 Koishi 的官方整合包。

在未来,用户不仅可以安装 Koishi 整合包,还可以安装 Satori 整合包甚至 Cordis 整合包,它们在形式上是完全一致的。运行整合包的环境也从 Koishi Desktop 变为了 Cordis Desktop。任何人都能通过一行代码从整合包创建开发环境,或是通过一个链接在客户端中初始化新的实例。

我们也鼓励大家创建自己的整合包。开发者可以将自己编排好的配置文件,连同想要分发的数据一起作为整合包发布到 GitHub 上。Cordis 的整合包自动化脚本将自动构建整合包以便其他人使用。安装各种有趣的插件、通过 locales 为机器人提供人设、在数据库中编写问答对话……即便你不擅长编写插件,你也可以在整合包中体现你的价值。

4 个赞

第三部分:ESM

Koishi 目前同时支持 CJS 和 ESM 模块系统,但默认的运行环境仍然使用 CJS。在下一个版本中,Koishi 将切换到纯 ESM。这其中并非只有构建目标的改动,为了支持现有 Koishi 的各种能力,我们研发了数个首创性的技术。

4 个赞

3.1 CJS vs. ESM

ESM 和 CJS 是两种不同的模块系统。其中 CJS 是 Node.js 在十多年前引入的,而 ESM 在近几年逐步成为了 JavaScript 开发的标准。它们之间的最大区别是导入和导出一个模块的方式:

// ESM
import { Context } from 'koishi'
export function apply(ctx: Context) {}
// CJS
const { Context } = require('koishi')
module.exports.apply = function (ctx: Context) {}

或许大多数的 Koishi 开发者都更熟悉 ESM。毕竟,Koishi 的默认开发环境使用 TypeScript,这意味着几乎所有插件都是使用 ESM 开发的。然而,真正熟悉 Node.js 的人会知道,在构建的过程中,Koishi 的插件会被编译为 CJS 格式,因此实际的运行时使用的是 CJS 模块系统。

为什么会这样呢?这主要是兼容性的原因。Koishi v4 发布于 2022 年初,而那时主流的开发者群体仍然在使用 CJS,因此 Koishi 也仅提供了 CJS 导出。随着后续 ESM 逐步普及,Koishi 也提供了 ESM 导出,但默认的运行时环境仍然使用 CJS。

在 v4 发布的两年里,我们多次收到了一些与 ESM 开发的问题。这些问题很多都是一些进阶开发者提出的。这些开发者不使用模板项目,而是直接编写脚本启动 Koishi。而当他们将自身的环境配置为 ESM 时,就会遇到各种不便。

这并不是 Koishi 不愿意全面转向 ESM,而是 Koishi 已经积累了数以千计的插件生态。如果全面迁移到 ESM 将意味着各种各样的兼容性问题。这些问题不仅不是 Koishi 特有的,而是许多 Node.js 框架都在面对的。在解决这些问题之前,Koishi 确实无法切换到纯 ESM。

4 个赞