Cordis:插件化应用的微内核

---
title: Cordis:插件化应用的微内核
published_at: 2026-08-14
language: zh
---

# Cordis:插件化应用的微内核

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

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

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

Cordis 解决的就是这个问题。

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

可以先用一句话概括:

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

## 为什么插件系统需要内核

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

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

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

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

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

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

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

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

## Context 是插件运行的环境

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

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

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

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

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

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

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

## 插件只是接收 Context 的函数

Cordis 里的插件形态很朴素:

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

加载插件就是:

```ts
ctx.plugin(plugin)
```

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

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

## Service 让插件共享能力

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

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

```ts
ctx.provide('database', db)
```

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

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

这就是 Cordis 的 Service 机制。

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

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

## Inject 让插件等待依赖就绪

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

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

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

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

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

运行过程可以这样理解:

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

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

## Event 让插件松耦合通信

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

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

```ts
ctx.emit('message', session)
```

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

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

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

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

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

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

## Lifecycle 保证插件可以干净卸载

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

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

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

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

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

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

## Scope 让上下文可以分层

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

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

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

可以把它想成一棵树:

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

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

## 一条最小运行链路

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

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

一个简单例子:

```ts
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)
    })
  })
}
```

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

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

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

## Koishi 为什么会用 Cordis

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

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

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

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

可以这样分层理解:

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

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

## 背后的理论视角

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

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

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

## 总结

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

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

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

一句话概括:

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