Cordis:插件化应用的微内核

一个应用小的时候,所有功能写在一起问题不大。入口文件启动服务,业务代码直接调用数据库,事件监听随手注册,配置和日志也可以全局引用。

但应用一旦变成插件化系统,问题就会完全不同。

插件可能由不同人维护,加载顺序可能变化,有些功能可以启用或禁用,有些服务要被多个插件共享,有些插件卸载后还要把事件监听、定时器和连接清理干净。如果没有一层统一的运行时,插件系统很快会变成一堆互相引用、互相等待、互相污染的模块。

Cordis 解决的就是这个问题。

它不是一个面向具体业务的框架,而是一个插件化应用的内核。它关心的不是“机器人怎么收消息”或“数据库怎么查询”,而是更底层的事情:插件如何加载,服务如何共享,依赖如何等待,事件如何传播,资源如何卸载,作用域如何隔离。

可以先用一句话概括:

Cordis 是一个用 Context 组织插件、服务、事件和生命周期的微内核。

为什么插件系统需要内核

插件化听起来很简单:把每个功能写成一个插件,再把插件加载进来就行。

真正麻烦的是插件之间的关系。

比如一个聊天机器人应用里,可能有这些插件:

  • 平台适配器插件负责接入 Telegram、Discord、QQ 或飞书;
  • 命令插件负责解析 /help/weather
  • 数据库插件提供用户数据读写;
  • 日志插件记录消息和错误;
  • 权限插件决定谁能调用哪些命令;
  • 控制台插件提供网页管理界面。

这些插件不是孤立的。命令插件可能需要数据库,权限插件可能要监听消息,控制台插件可能要读取其他插件的配置,平台适配器收到消息后还要通知一批插件。

如果每个插件都直接 import 另一个插件,系统会越来越难维护:

插件 A 依赖插件 B
插件 B 又依赖服务 C
插件 C 启动顺序不确定
插件 D 卸载后监听还留着
插件 E 重载后逻辑执行两遍

Cordis 的作用,就是把这些关系统一放到内核里管理。

Context 是插件运行的环境

Cordis 最核心的对象是 Context,通常写成 ctx

它可以理解成插件运行时的环境。插件拿到 ctx 后,不是自己到处找全局对象,而是通过 ctx 接入系统:

ctx.plugin(myPlugin)
ctx.provide('database', db)
ctx.inject(['database'], (ctx) => {})
ctx.on('message', callback)
ctx.emit('message', data)

这些方法分别对应 Cordis 的几项核心能力:

plugin:加载插件
provide:提供服务
inject:声明依赖
on / emit:监听和触发事件

所以 ctx 不是普通参数,而是整个插件系统的入口。插件通过它声明“我提供什么、我需要什么、我监听什么、我产生了哪些副作用”。

这让 Cordis 能够掌握插件运行时的结构,而不是让插件私下互相纠缠。

插件只是接收 Context 的函数

Cordis 里的插件形态很朴素:

function plugin(ctx) {
  // 注册服务、监听事件、加载其他插件
}

加载插件就是:

ctx.plugin(plugin)

这种设计的好处是门槛低。插件不需要继承复杂基类,也不需要实现一大套接口。它只要拿到当前上下文,就可以把自己的能力注册进系统。

但简单不代表随意。插件所有重要操作都通过 ctx 完成,Cordis 才能记录这些操作,并在之后处理依赖、事件和清理。

Service 让插件共享能力

插件之间最常见的关系,是一个插件提供能力,另一个插件使用能力。

例如数据库插件提供数据库服务:

ctx.provide('database', db)

其他插件需要数据库时,不直接引用数据库插件,而是声明依赖这个服务:

ctx.inject(['database'], (ctx) => {
  ctx.database.query(...)
})

这就是 Cordis 的 Service 机制。

它解决的是耦合问题。使用方只需要知道“我需要 database”,不需要知道 database 是谁创建的、什么时候创建的、具体实现是哪一个。

这样一来,服务可以被替换,插件可以调整加载顺序,测试时也可以提供另一套实现。

Inject 让插件等待依赖就绪

依赖注入最重要的地方,不只是“把对象传进去”,而是“等依赖存在以后再运行”。

假设用户插件依赖数据库:

function userPlugin(ctx) {
  ctx.inject(['database'], (ctx) => {
    // database 可用后才执行
  })
}

如果 userPlugin 先加载,而数据库插件后加载,普通代码很容易报错:数据库还不存在。

Cordis 不要求所有插件都按固定顺序加载。它会先记录依赖关系。等某个插件提供了 database 服务,依赖这个服务的逻辑再被执行。

运行过程可以这样理解:

加载 userPlugin
发现它需要 database
database 暂时不存在,先等待
加载 databasePlugin
提供 database 服务
userPlugin 的依赖满足,开始运行相关逻辑

这就是插件系统和普通模块系统的差别。普通模块通常假设依赖在 import 时已经存在;插件系统则要面对启用、禁用、重载和顺序变化。

Event 让插件松耦合通信

服务解决的是“共享能力”,事件解决的是“通知发生了什么”。

例如一个平台适配器收到消息后,可以触发事件:

ctx.emit('message', session)

命令插件、日志插件、统计插件都可以监听这个事件:

ctx.on('message', (session) => {
  // 处理消息
})

触发事件的一方不需要知道谁在监听,监听事件的一方也不需要知道事件从哪里来。

这让插件可以像挂钩一样接入系统流程:

收到消息
触发 message 事件
命令插件处理命令
日志插件记录日志
权限插件检查权限
统计插件更新指标

如果没有事件机制,这些插件就只能互相调用,系统会重新变成一团依赖网。

Lifecycle 保证插件可以干净卸载

插件系统不能只考虑加载,还必须考虑卸载。

一个插件运行时可能会做很多有副作用的事情:

  • 注册事件监听;
  • 创建定时器;
  • 提供服务;
  • 打开数据库连接;
  • 启动后台任务;
  • 创建子上下文。

如果插件卸载时这些东西没有清掉,就会出现旧插件继续响应事件、重载后逻辑执行两次、资源泄漏等问题。

Cordis 的生命周期机制就是为了解决这个问题。插件通过 ctx 注册副作用时,Cordis 可以把这些副作用归到当前插件上下文里。等插件卸载时,相关资源也能跟着释放。

这也是插件热重载能够成立的基础:旧版本退出干净,新版本才不会和旧逻辑叠在一起。

Scope 让上下文可以分层

很多插件系统不只有一个全局环境。

一个插件可能只对某个机器人实例生效,只对某个频道生效,或者只在某个局部功能里提供服务。如果所有东西都挂在全局上下文里,隔离会变得很困难。

Cordis 的 Context 可以派生出子上下文。子上下文可以继承父上下文的一部分能力,同时拥有自己的作用域和生命周期。

可以把它想成一棵树:

根 Context
  ├─ Bot A 的 Context
  │   ├─ 某个频道的 Context
  │   └─ 某个局部插件的 Context
  └─ Bot B 的 Context

这样一来,局部插件的事件监听、服务和资源可以被限制在自己的范围内。卸载某个子上下文时,也不必影响整个应用。

一条最小运行链路

把这些概念放在一起,Cordis 的运行模型大致是:

创建 Context
加载 Plugin
Plugin 提供 Service 或声明 Inject
依赖满足后执行插件逻辑
插件通过 Event 通信
卸载时按 Lifecycle 清理资源

一个简单例子:

function databasePlugin(ctx) {
  const db = createDatabase()
  ctx.provide('database', db)
}

function userPlugin(ctx) {
  ctx.inject(['database'], (ctx) => {
    ctx.on('message', async (session) => {
      const user = await ctx.database.getUser(session.userId)
      console.log(user)
    })
  })
}

即使 userPlugindatabasePlugin 更早加载,也没有关系。它声明自己需要 database,Cordis 会等数据库服务出现后再执行相关逻辑。

这个例子里,几个核心机制刚好连起来:

databasePlugin 提供 Service
userPlugin 通过 Inject 等待 Service
userPlugin 通过 Event 监听 message
插件卸载时监听应随生命周期清理

Koishi 为什么会用 Cordis

Koishi 是一个聊天机器人框架。它要接入不同聊天平台,处理消息,注册命令,管理中间件、数据库、控制台和插件市场。

这些能力天然适合插件化:

平台适配器是插件
命令系统是插件
数据库服务是插件
控制台是插件
具体功能也是插件

Koishi 需要的不是一个简单的插件数组,而是一套能处理依赖、服务、事件、作用域和生命周期的运行时。Cordis 正好提供这层基础。

可以这样分层理解:

Cordis:插件、服务、事件、生命周期、上下文
Koishi:消息、命令、Session、适配器、控制台
具体插件:天气、AI 对话、数据库、平台接入

Cordis 不直接关心聊天机器人业务,但它让 Koishi 可以把机器人能力拆成可组合、可启停、可依赖、可清理的插件。

背后的理论视角

从工程角度看,Cordis 是插件、服务、事件和生命周期的运行时内核。再往下一层看,它其实在处理一个更一般的问题:动态组件如何在运行时安全地加入、退出和互相依赖。

Cordis 论文把这个问题称为 spatiotemporal composability,也就是“时空可组合性”。时间维度关心组件卸载后副作用能不能撤回;空间维度关心组件之间的依赖能不能随环境变化自动重算。

这套理论视角解释了为什么 Cordis 要把 Context、effect tracking、dependency injection 和 lifecycle 放在同一个内核里:它们不是彼此独立的功能点,而是共同服务于同一个目标——让动态组件在运行时既能安全退出,也能稳定协作。

总结

Cordis 的核心概念可以压缩成一组关系:

Context 是插件运行环境
Plugin 是挂载进环境的功能模块
Service 是插件共享能力的方式
Inject 负责等待依赖就绪
Event 负责插件之间通信
Lifecycle 负责卸载和清理
Scope 负责局部隔离

它像一个微内核:自己不做太多具体业务,但提供稳定的组织方式。只要一个应用开始走向插件化,开始需要动态加载、依赖等待、服务共享、事件广播和安全卸载,Cordis 这样的内核就会变得必要。

一句话概括:

Cordis 不是用来写某个具体功能的框架,而是用来组织一组插件,让它们可以有秩序地协作、隔离和退出。