从 Context、Service、Fiber、Effect 讲起,再看响应式依赖、类型化事件和热重载如何串在一起。
你修改了一行代码,保存。
热重载成功,控制台重新输出了一次日志。
再保存一次,日志开始输出两遍。
再保存一次,三遍。
恭喜,你养出了三个僵尸定时器。
而且,定时器通常只是最容易被发现的那个。旧插件注册的事件监听器、Socket、文件 Watcher、路由和服务可能还留在进程里。表面上代码已经更新,旧版本其实并没有完全退出。
很多插件系统首先解决的是:
怎样把插件装进来?
Cordis 还追问了一句:
插件被拔掉时,怎样保证它真的把东西收拾干净?
从这个问题入手,Cordis 就容易理解了。
Cordis 官方称它为"时空可组合性的元框架"。说法有点学术,落到代码里却很具体:服务可以动态出现或消失;插件会随依赖自动启停;插件产生的副作用会在卸载时回滚。
Cordis 是 Koishi 可逆插件体系的基础,也被 DeepSeek Harness 用作底层插件运行时。在 DeepSeek Harness 中,模型适配器、工具注册表、会话存储乃至 Agent 循环本身都可以作为插件参与组合。
Cordis 还在快速开发,官方仓库也明确说明 API 尚未稳定。用于真实项目时,建议锁定版本,并以该版本的类型声明和文档为准。本文示例使用 npm 包 cordis;如果开发 DeepSeek Harness 插件,导入路径通常改为 @deepseek-ai/cordis。

一、插得进去不算插件,拔得出来才算
先看一个很普通的插件:
ts
function apply() {
setInterval(runTask, 1000)
eventBus.on('message', handleMessage)
toolRegistry.set('search', searchTool)
}
加载它并不难。
难的是卸载。
你必须记得:
ts
clearInterval(timer)
eventBus.off('message', handleMessage)
toolRegistry.delete('search')
项目小时,这不过是几行清理代码。规模一上来,遗漏就很常见:
- 某个监听器在工具函数深处注册,插件入口根本不知道它的存在;
- 某个子插件创建了定时器,父插件卸载时忘了通知它;
- 数据库服务已经被替换,业务插件仍然持有旧实例;
- 热重载多次后,一个请求被重复处理;
- 插件加载到一半抛错,前面已经创建的资源没有回滚;
- 插件之间的启动顺序被迫写进配置文件。
Cordis 给每项注册行为都记一笔账:
text
这个监听器属于谁?
这个定时器属于谁?
这个服务是谁提供的?
谁依赖这个服务?
插件离场时,需要撤销哪些操作?
这些账都记在同一个运行实例上,也就是 Fiber。
图 1:Cordis 会把插件的服务、监听器、子插件和外部资源绑定到同一个 Fiber。
Cordis 论文从两个维度解释这套机制:
空间可组合性讨论的是:插件在当前 Context 中能看到哪些服务,某项服务是否需要隔离,以及不同子树能否使用不同实现。
时间可组合性讨论的是:插件造成的改动能否撤销,卸载后系统能否回到加载前的状态。
论文使用"可逆 Effect"和"响应式 Coeffect"这两个术语。读代码时,可以先把它们理解为:
Context 决定插件能看到什么,Fiber 记录插件改动了什么。
二、先认清五个概念
理解 Cordis,绕不开下面五个概念。
| 概念 | 可以把它想成 | 主要职责 |
|---|---|---|
Plugin |
插件说明书 | 描述一项可加载功能 |
Context |
主板与系统总线 | 查找服务、发送事件、挂载插件 |
Service |
主板上的能力接口 | 向其他插件公开具名 API |
Fiber |
插件的一次运行实例 | 保存状态、依赖、配置与清理逻辑 |
Effect |
可回滚的系统改动 | 在 Fiber 卸载时撤销副作用 |
Plugin 和 Fiber 最容易混淆。
text
Class 之于 Object
约等于
Plugin 之于 Fiber
Plugin 是定义,Fiber 是某次实际运行。
同一个插件可以在不同 Context 下加载多次,也可以使用不同配置加载多次。每次加载都会得到一个独立 Fiber,各自管理自己的依赖和副作用。ctx.plugin() 接受函数、类或带 apply() 的对象,并返回对应 Fiber。
用主板打个比方:
text
Context = 主板
Service = USB、PCIe、网口等接口
Plugin = 扩展卡的设计
Fiber = 真正插进机器里的那张卡
Effect = 卡连接的电线、风扇、灯和外部设备
dispose = 拔卡,并把相关电线一并拆掉
三、跑起第一个 Cordis 插件
创建项目:
bash
mkdir cordis-lab
cd cordis-lab
npm init -y
npm install cordis
npm install --save-dev typescript tsx @types/node
npm pkg set type=module
npm pkg set scripts.dev="tsx src/main.ts"
mkdir src
创建 src/main.ts:
ts
import {
Context,
type Plugin,
} from 'cordis'
const helloPlugin: Plugin = {
name: 'hello',
apply(ctx) {
console.log('Hello from Cordis!')
return () => {
console.log('helloPlugin 已卸载')
}
},
}
const ctx = new Context()
const fiber = ctx.plugin(helloPlugin)
// 等待本次加载完成,并把启动错误重新抛出。
await fiber.await()
console.log('插件加载完成')
// dispose() 会等待插件清理结束。
await fiber.dispose()
运行:
bash
npm run dev
输出:
text
Hello from Cordis!
插件加载完成
helloPlugin 已卸载
这段代码依次做了四件事:
图 2:Plugin 的加载与卸载过程。
Cordis 会把插件启动函数返回的清理函数保存为一个 Effect。fiber.dispose() 不只是修改状态,还会等待插件完成清理,包括异步清理。
四、Cordis 插件有三种写法
1. 函数插件
ts
import type { Context } from 'cordis'
function heartbeat(ctx: Context) {
console.log('heartbeat 启动')
return () => {
console.log('heartbeat 卸载')
}
}
函数插件最轻量,适合注册事件、工具、定时任务和中间件。
2. 对象插件
ts
import type { Context } from 'cordis'
const reporterPlugin = {
name: 'reporter',
inject: ['logger'],
apply(ctx: Context) {
ctx.logger('reporter').info('reporter 启动')
},
}
对象形式适合集中声明:
nameinjectConfigapply- 其他插件元数据
3. Service 类插件
ts
import {
Service,
type Context,
} from 'cordis'
class ClockService extends Service {
constructor(ctx: Context) {
super(ctx, 'clock')
}
now() {
return Date.now()
}
}
Service 子类本身也是插件。使用它的主要目的不是"改用类来写",而是向 Context 暴露一项有固定名称的能力,例如:
ts
ctx.clock.now()
一般可以从函数插件开始。插件需要向外提供一组可调用的方法时,再考虑 Service。Cordis 支持函数、Service 构造器和 { apply } 对象三种入口形式。
五、Context 不只是一个普通对象
几乎所有 Cordis API 都从 ctx 开始:
ts
ctx.plugin(...)
ctx.effect(...)
ctx.on(...)
ctx.emit(...)
ctx.parallel(...)
ctx.serial(...)
ctx.waterfall(...)
ctx.provide(...)
ctx.get(...)
ctx.extend(...)
ctx.isolate(...)
ctx.intercept(...)
Context 看起来像普通对象:
ts
ctx.database
ctx.logger
ctx.greeter
实际上,Context 是代理对象。读取 ctx.greeter 时,Cordis 会通过服务解析器,在当前作用域中查找名为 greeter 的实现。
这意味着:
ts
ctx.database
并不一定是一个全局单例。
不同子 Context 完全可以解析到不同的 database 实现。Context 既是依赖容器,也是服务作用域、事件入口和插件运行环境。
六、Service:消费能力,而不是导入实现
假设我们要提供一个问候服务。
ts
import {
Service,
type Context,
} from 'cordis'
interface GreeterConfig {
prefix: string
}
declare module 'cordis' {
interface Context {
greeter: GreeterService
}
}
class GreeterService extends Service {
constructor(
ctx: Context,
private readonly config: GreeterConfig,
) {
super(ctx, 'greeter')
}
greet(name: string) {
return `${this.config.prefix},${name}!`
}
}
加载它:
ts
const fiber = ctx.plugin(
GreeterService,
{
prefix: '你好',
},
)
await fiber.await()
console.log(
ctx.greeter.greet('Alice'),
)
输出:
text
你好,Alice!
这里有两个容易混在一起的步骤。
super(ctx, 'greeter') 负责运行时注册
ts
super(ctx, 'greeter')
它告诉 Cordis:
text
服务名称:greeter
服务实现:当前 GreeterService 实例
插件卸载时,这个服务会自动从当前作用域中移除。
declare module 负责 TypeScript 类型
ts
declare module 'cordis' {
interface Context {
greeter: GreeterService
}
}
它只影响编译期,让 TypeScript 知道 ctx.greeter 存在,并提供方法补全。
忘记声明合并时,服务仍可能在运行时正常注册,但编辑器会提示:
text
Property 'greeter' does not exist on type 'Context'
可以这样区分:
text
super(ctx, 'greeter') 让服务真的存在。
declare module 让 TypeScript 知道它存在。
Service 子类被挂载后会注册为 ctx.<name>,注册本身属于当前 Fiber,Fiber 卸载时服务自动移除。
七、inject:不是启动检查,而是持续订阅
现在写一个消费 greeter 的插件:
ts
import {
type Context,
type Plugin,
} from 'cordis'
const consumerPlugin: Plugin = {
name: 'consumer',
inject: ['greeter'],
apply(ctx: Context) {
console.log(
ctx.greeter.greet('world'),
)
},
}
第一次看到 inject,很容易把它理解成:
插件启动时检查一下 greeter 在不在。
但它不只在启动时检查一次。
Cordis 的 inject 是响应式依赖:
text
greeter 不存在
→ consumer 保持 PENDING
greeter 出现
→ consumer 执行 apply()
greeter 消失
→ consumer 自动卸载并清理副作用
greeter 再次出现
→ consumer 再次执行 apply()
因此,消费者可以先于服务挂载:
ts
const consumerFiber =
ctx.plugin(consumerPlugin)
const providerFiber =
ctx.plugin(GreeterService, {
prefix: '你好',
})
await Promise.all([
consumerFiber.await(),
providerFiber.await(),
])
消费者虽然写在前面,却不会提前启动;它会一直等到 greeter 可用。
图 3:Fiber 的核心生命周期。
这与传统依赖注入框架有一个明显差别:
text
传统 DI:
应用启动时构建一次对象图。
Cordis:
应用运行期间持续维护依赖图。
因此,配置文件的书写顺序并不决定启动顺序。插件何时进入 ACTIVE,取决于它声明的服务依赖。服务被移除,依赖它的插件随之卸载;服务恢复,插件会再次运行。
可选依赖怎么办
不是所有服务都必须写进 inject。
假设没有 greeter 时,插件仍然可以提供降级功能:
ts
const greeter = ctx.get('greeter')
if (greeter) {
console.log(
greeter.greet('Alice'),
)
} else {
console.log(
'greeter 未安装,使用默认文案',
)
}
两者的边界如下:
text
inject: ['greeter']
服务缺失时,整个插件不运行。
ctx.get('greeter')
插件照常运行,结果可能是 undefined。
ctx.get() 会绕过依赖声明直接读取服务,适合真正的可选能力。
八、Fiber 记录的是这一次运行
当你调用:
ts
const fiber =
ctx.plugin(myPlugin)
返回的 Fiber 保存了这次运行的关键信息:
- 当前插件;
- 插件 Context;
- 校验后的配置;
- 当前依赖快照;
- 生命周期状态;
- 子插件;
- 正在生效的 Effect;
- 卸载清理逻辑。
常用操作有:
ts
await fiber.await()
await fiber.dispose()
await fiber.restart()
await fiber.update(newConfig)
其中:
ts
await fiber.await()
等待当前加载或卸载过程结束,并重新抛出配置校验、插件启动等错误。
ts
await fiber.dispose()
卸载插件,并等待异步清理结束。
ts
await fiber.restart()
使用当前配置卸载并重新加载插件。
通过 getEffects(),还可以查看带标签的 Effect 树,用来排查插件当前持有哪些资源。
九、Effect:让资源知道自己属于谁
Effect 是理解 Cordis 生命周期的重点。
下面这些操作都会留下持续存在的资源:
ts
setInterval(...)
setTimeout(...)
server.listen(...)
socket.connect(...)
fs.watch(...)
process.on(...)
eventEmitter.on(...)
麻烦不在创建资源,而在于没人负责回收。
如果代码只是这样:
ts
function apply() {
setInterval(() => {
console.log('tick')
}, 1000)
}
插件卸载了,定时器却不会自己停下来。
用 ctx.effect() 包住资源生命周期
ts
import type { Context } from 'cordis'
function heartbeat(ctx: Context) {
ctx.effect(() => {
const timer = setInterval(() => {
console.log('tick')
}, 1000)
return () => {
clearInterval(timer)
console.log('timer cleaned')
}
}, 'heartbeat timer')
}
ctx.effect() 会做两件事:
- 立即执行创建逻辑;
- 把返回的 disposer 绑定到当前 Fiber。
Fiber 卸载时,Cordis 会自动调用 disposer。
它很像一个跨越插件生命周期的 try/finally:
ts
const resource = createResource()
try {
// 插件在系统中运行
} finally {
destroyResource(resource)
}
区别在于,这个 finally 何时触发由 Cordis 决定。
以下情况都会触发清理:
- 显式调用
fiber.dispose(); - 插件被配置禁用;
- 热重载替换旧插件;
- 必需服务消失;
- 父插件被卸载;
- 插件启动失败,需要回滚前面已经创建的资源。
Cordis 会收集 Effect 的清理函数,并在 Fiber 卸载时按注册顺序的逆序启动清理;异步 disposer 也会被等待。
哪些操作已经是 Effect
不少 Cordis API 会自动绑定当前 Fiber:
ts
ctx.on('event', listener)
ctx.plugin(childPlugin)
ctx.provide('service', value)
所以通常不用再套一层:
ts
ctx.effect(() => {
return ctx.on(
'event',
listener,
)
})
ctx.on() 本身会返回 disposer,Cordis 也知道这个监听器属于哪个 Fiber。
判断方法很简单:
text
Cordis 创建的资源:
通常自动管理。
Node.js 或第三方库创建的资源:
使用 ctx.effect() 包裹。
有严格清理顺序时,放进同一个 disposer
假设服务器必须先停止接收请求,数据库才能关闭:
ts
ctx.effect(() => {
const database =
createDatabase()
const server =
createServer(database)
return async () => {
await server.close()
await database.close()
}
})
有严格先后关系的清理动作应放在同一个 disposer 中,明确写出顺序。拆成多个异步 Effect 后,完成时机未必符合预期。
十、Service 用来调用,Event 用来广播
Service 适合明确调用某项能力:
ts
ctx.database.getUser(id)
ctx.greeter.greet(name)
ctx.cache.set(key, value)
调用方知道要调用哪项能力,并期待明确的结果。
Event 适合宣布某件事情:
ts
ctx.emit(
'user/created',
user,
)
发布方不用关心有哪些监听器。
| 场景 | 更适合 |
|---|---|
| 查询用户、调用模型、写入数据库 | Service |
| 通知用户已创建、任务已完成 | Event |
| 多个插件并发执行收尾工作 | Event |
| 按优先级让插件尝试处理 | Event |
| 允许插件包装默认行为 | Waterfall Event |
声明类型化事件
ts
interface User {
id: string
name: string
}
declare module 'cordis' {
interface Events {
'user/created'(
user: User,
): void
}
}
监听:
ts
ctx.on(
'user/created',
(user) => {
console.log(
`创建用户:${user.name}`,
)
},
)
发送:
ts
ctx.emit(
'user/created',
{
id: 'u-001',
name: 'Alice',
},
)
TypeScript 会检查事件名称、参数数量和参数类型。
事件声明如果放在单独文件中,消费方可以使用一个只影响类型系统的导入:
ts
import type {} from './events.js'
它不会生成运行时代码,只是让 TypeScript 看见相应的声明合并。
十一、事件分发不只有 emit
Cordis 内置了五种事件分发方式。
| 方法 | 是否等待异步结果 | 行为 |
|---|---|---|
emit |
否 | 同步调用全部监听器,忽略返回值 |
parallel |
是 | 并发运行全部监听器,并等待它们结束 |
serial |
是 | 依次运行,遇到第一个有效返回值停止 |
bail |
否 | serial 的同步版本 |
waterfall |
取决于事件 | 洋葱模型,可包装或短路默认行为 |
emit:只负责通知
ts
ctx.emit(
'user/created',
user,
)
需要注意:如果监听器是异步函数,emit 不会等待它完成。
错误用法:
ts
ctx.on(
'data/save',
async () => {
await saveToDatabase()
},
)
ctx.emit('data/save')
console.log('保存结束')
// 此时数据库操作可能还没完成。
需要等待时使用:
ts
await ctx.parallel(
'data/save',
)
console.log('保存结束')
parallel:并发执行,全部结束后继续
ts
declare module 'cordis' {
interface Events {
'system/startup'():
Promise<void>
}
}
ctx.on(
'system/startup',
async () => {
await loadCache()
},
)
ctx.on(
'system/startup',
async () => {
await connectDatabase()
},
)
await ctx.parallel(
'system/startup',
)
适合:
- 并发刷新缓存;
- 多个插件分别上报指标;
- 多个模块执行互不依赖的收尾工作。
serial:按顺序询问,拿到第一个结果就停止
ts
declare module 'cordis' {
interface Events {
'auth/check'(
token: string,
): Promise<
boolean | undefined
>
}
}
ctx.on(
'auth/check',
async (token) => {
if (token === 'admin-token') {
return true
}
},
)
ctx.on(
'auth/check',
async (token) => {
if (token === 'guest-token') {
return true
}
},
)
const allowed =
await ctx.serial(
'auth/check',
token,
)
监听器会依次执行,直到有人返回:
text
非 null
非 false
非 undefined
这种返回值称为 bail value。
适合:
- 权限判定;
- 路由匹配;
- 多个插件依次尝试处理一种输入;
- 按优先级寻找第一个可用实现。
十二、waterfall:可以包装,也可以截断
waterfall 类似 Koa 中间件。
每个监听器都可以:
- 在后续处理前执行逻辑;
- 调用
next(); - 在后续处理完成后包装结果;
- 不调用
next(),直接短路整个链条。
声明事件:
ts
declare module 'cordis' {
interface Events {
'message/transform'(
input: string,
next: () => Promise<string>,
): Promise<string>
}
}
注册两个中间件:
ts
ctx.on(
'message/transform',
async (input, next) => {
console.log('A before')
const result =
await next()
console.log('A after')
return result.toUpperCase()
},
)
ctx.on(
'message/transform',
async (input, next) => {
console.log('B before')
if (
input.includes('blocked')
) {
return '[消息已拦截]'
}
const result =
await next()
console.log('B after')
return `《${result}》`
},
)
触发:
ts
const result =
await ctx.waterfall(
'message/transform',
'hello',
async () => 'hello',
)
console.log(result)
执行顺序:
text
A before
B before
执行默认行为
B after
A after
《HELLO》
图 4:Waterfall 的洋葱调用顺序。
这里最危险的错误,是忘记调用 next():
ts
ctx.on(
'message/transform',
async (input, next) => {
console.log(input)
// 忘记 return next()
},
)
这会直接截断后续处理,并不只是漏掉一个步骤。
如果监听器只是记录日志,应写成:
ts
ctx.on(
'message/transform',
async (input, next) => {
console.log(input)
return next()
},
)
Cordis 将"不调用 next()"定义为主动否决或短路,而不是自动继续。
十三、完整实战:让服务消失一次,再回来
下面是一个可以直接运行的例子。
这个例子涵盖:
- Service;
- Context 类型声明;
inject;- 类型化事件;
waterfall;ctx.effect();- Fiber;
- 服务消失后消费者自动清理;
- 服务恢复后消费者自动重新加载。
将 src/main.ts 替换为:
ts
import {
Context,
Service,
type Plugin,
} from 'cordis'
function sleep(
milliseconds: number,
): Promise<void> {
return new Promise(
(resolve) => {
setTimeout(
resolve,
milliseconds,
)
},
)
}
// --------------------------------------
// 1. 配置、服务与事件类型
// --------------------------------------
interface GreeterConfig {
prefix: string
}
declare module 'cordis' {
interface Context {
greeter: GreeterService
}
interface Events {
'greeter/called'(
name: string,
result: string,
): void
'greeter/format'(
message: string,
next: () => Promise<string>,
): Promise<string>
}
}
// --------------------------------------
// 2. Greeter Service
// --------------------------------------
class GreeterService
extends Service {
constructor(
ctx: Context,
private readonly config:
GreeterConfig,
) {
super(ctx, 'greeter')
}
async greet(
name: string,
): Promise<string> {
const raw =
`${this.config.prefix},` +
`${name}!`
// 允许其他插件包装或替换结果。
const formatted =
await this.ctx.waterfall(
'greeter/format',
raw,
async () => raw,
)
// 广播一次调用通知。
this.ctx.emit(
'greeter/called',
name,
formatted,
)
return formatted
}
}
// --------------------------------------
// 3. 格式化插件
// --------------------------------------
const formatterPlugin:
Plugin = {
name: 'formatter',
apply(ctx) {
ctx.on(
'greeter/format',
async (
_message,
next,
) => {
const result =
await next()
return (
`【${result.toUpperCase()}】`
)
},
)
console.log(
'[formatter] ACTIVE',
)
return () => {
console.log(
'[formatter] CLEANUP',
)
}
},
}
// --------------------------------------
// 4. 消费服务的插件
// --------------------------------------
const consumerPlugin:
Plugin = {
name: 'consumer',
inject: ['greeter'],
apply(ctx) {
console.log(
'[consumer] ACTIVE',
)
// ctx.on() 本身就是 Effect。
ctx.on(
'greeter/called',
(name, result) => {
console.log(
`[event] ${name} -> ${result}`,
)
},
)
// setInterval 不由 Cordis 创建,
// 因此需要手动声明其生命周期。
ctx.effect(() => {
let sequence = 0
const timer =
setInterval(() => {
sequence += 1
const name =
`开发者-${sequence}`
void ctx.greeter
.greet(name)
.then((result) => {
console.log(
`[consumer] ${result}`,
)
})
.catch(
(error: unknown) => {
console.error(
'[consumer] 调用失败',
error,
)
},
)
}, 400)
return () => {
clearInterval(timer)
console.log(
'[consumer] ' +
'interval CLEANUP',
)
}
}, 'consumer interval')
return () => {
console.log(
'[consumer] CLEANUP',
)
}
},
}
// --------------------------------------
// 5. 组合应用
// --------------------------------------
const ctx = new Context()
// 没有依赖,立即启动。
const formatterFiber =
ctx.plugin(formatterPlugin)
// 此时 greeter 还不存在,
// consumer 暂时保持 PENDING。
const consumerFiber =
ctx.plugin(consumerPlugin)
// 现在提供 greeter。
let providerFiber =
ctx.plugin(
GreeterService,
{
prefix: '你好',
},
)
await Promise.all([
formatterFiber.await(),
providerFiber.await(),
consumerFiber.await(),
])
await sleep(1300)
console.log(
'\n--- 卸载 greeter ---\n',
)
// greeter 消失后:
// 1. consumer 被自动卸载;
// 2. interval 被清理;
// 3. 事件监听器被移除;
// 4. consumer 等待依赖恢复。
await providerFiber.dispose()
await sleep(900)
console.log(
'\n--- 重新提供 greeter ---\n',
)
// 服务恢复后,原 consumer Fiber
// 会重新执行 apply()。
providerFiber =
ctx.plugin(
GreeterService,
{
prefix: '欢迎回来',
},
)
await providerFiber.await()
await sleep(1300)
console.log(
'\n--- 关闭应用 ---\n',
)
await consumerFiber.dispose()
await providerFiber.dispose()
await formatterFiber.dispose()
运行:
bash
npm run dev
输出大致如下:
text
[formatter] ACTIVE
[consumer] ACTIVE
[event] 开发者-1 -> 【你好,开发者-1!】
[consumer] 【你好,开发者-1!】
[event] 开发者-2 -> 【你好,开发者-2!】
[consumer] 【你好,开发者-2!】
--- 卸载 greeter ---
[consumer] interval CLEANUP
[consumer] CLEANUP
--- 重新提供 greeter ---
[consumer] ACTIVE
[event] 开发者-1 -> 【欢迎回来,开发者-1!】
[consumer] 【欢迎回来,开发者-1!】
--- 关闭应用 ---
[consumer] interval CLEANUP
[consumer] CLEANUP
[formatter] CLEANUP
如果定时器恰好撞上服务切换,控制台偶尔会多出一条已经进入异步流程的日志。旧 interval 不会继续运行;服务消失时消费者仍会卸载,服务恢复后也会重新启动。
对应的生命周期如下:
图 5:响应式依赖与可逆副作用共同构成了动态插件生命周期。
这段代码把两个机制接在了一起:
text
inject 保证依赖有效
+
Effect 保证卸载干净
=
插件可以安全地反复启停
十四、Context 作用域:同名服务可以有不同实现
并非每个系统都适合全局共用一套数据库。
在多租户、多工作区、多 Bot 或测试场景中,需求可能是:
text
团队 A 使用 team-a.db
团队 B 使用 team-b.db
而业务插件仍然只想调用:
ts
ctx.database
Cordis 通过子 Context 解决这个问题。
extend():增加上下文元数据
ts
const requestContext =
ctx.extend({
requestId: 'req-001',
userId: 'user-123',
})
子 Context 继承父 Context 的服务,也可以携带自己的元数据,而且不会修改父 Context。
适合:
- HTTP 请求;
- 会话;
- 用户;
- 租户;
- 工作区。
isolate():给某个服务开一套独立作用域
先定义一个简单服务类型:
ts
declare module 'cordis' {
interface Context {
database: {
filename: string
}
}
}
提供服务:
ts
const databasePlugin = {
apply(
ctx: Context,
filename: string,
) {
ctx.provide(
'database',
{
filename,
},
)
},
}
创建两个作用域:
ts
const teamA =
ctx.isolate('database')
const teamB =
ctx.isolate('database')
teamA.plugin(
databasePlugin,
'team-a.db',
)
teamB.plugin(
databasePlugin,
'team-b.db',
)
在 teamA 下加载的插件看到:
text
ctx.database.filename
→ team-a.db
在 teamB 下加载的插件看到:
text
ctx.database.filename
→ team-b.db
图 6:Context 隔离允许同一个服务名称在不同子树解析到不同实现。
intercept():给子树中的服务附加配置
ts
const tenantContext =
ctx.intercept(
'http',
{
timeout: 5000,
headers: {
'x-tenant-id':
'tenant-a',
},
},
)
在这个子 Context 下启动的插件,会看到合并后的 http 服务配置;父 Context 不受影响。
extend()、isolate() 和 intercept() 都会创建子 Context,而不是直接修改父 Context。
十五、用 YAML Loader 把应用变成插件树
代码里可以直接组合:
ts
ctx.plugin(plugin)
应用规模变大后,通常还会需要配置驱动的组合方式。
典型的 cordis.yml 可以写成:
yaml
- id: formatter
name: './formatter.ts'
- id: greeter
name: './greeter.ts'
config:
prefix: 你好
- id: consumer
name: './consumer.ts'
插件模块可以导出:
ts
import type {
Context,
} from 'cordis'
export const name = 'consumer'
export const inject = [
'greeter',
]
export function apply(
ctx: Context,
) {
console.log(
ctx.greeter.greet('Alice'),
)
}
Loader 会读取插件模块的入口与元数据,然后创建相应 Fiber。
YAML 行顺序不是启动顺序
下面两份配置在依赖关系上通常等价:
yaml
- name: './greeter.ts'
- name: './consumer.ts'
yaml
- name: './consumer.ts'
- name: './greeter.ts'
因为真正的规则是:
text
consumer 声明 inject: ['greeter']
因此它会等待 greeter,而不是等待上一行。
按照官方教程,配置条目可以并发开始加载。插件何时运行由依赖关系决定,与列表位置无关。
为什么要写 id
yaml
- id: greeter
name: './greeter.ts'
id 是配置条目的稳定标识。
配置更新时,Loader 才能识别:
text
这是原来那个 greeter 的配置变化
而不是:
text
删除一个旧插件
再添加一个无关的新插件
临时禁用插件
yaml
- id: greeter
name: './greeter.ts'
disabled: true
这会卸载插件,但保留配置项。
改回:
yaml
disabled: false
服务重新出现后,依赖它的 PENDING 插件也会重新激活。Loader 依靠稳定的 id、disabled 状态和配置差异更新完成组合与 HMR。
十六、配置不能只靠 TypeScript
下面的接口只能约束编译期代码:
ts
interface Config {
greeting: string
targets: string[]
}
但 YAML 完全可以写成:
yaml
config:
targets: 123
TypeScript 无法约束运行时读入的配置。
因此,Cordis 会在调用 apply() 前,使用插件导出的 Config 校验器检查配置。它接受符合 Standard Schema 的校验器,Schemastery 是常见选择。
示例:
ts
import Schema from 'schemastery'
import type {
Context,
} from 'cordis'
export interface Config {
greeting: string
targets: string[]
}
export const Config:
Schema<Config> =
Schema.object({
greeting:
Schema
.string()
.default('Hello'),
targets:
Schema
.array(String)
.default(['world']),
})
export function apply(
ctx: Context,
config: Config,
) {
for (
const target
of config.targets
) {
console.log(
`${config.greeting}, ` +
`${target}!`,
)
}
}
配置:
yaml
- name: './config-demo.ts'
config:
targets:
- Alice
- Bob
输出:
text
Hello, Alice!
Hello, Bob!
如果配置类型错误,插件会在 apply() 运行前失败,而不是带着半残配置进入 ACTIVE:
yaml
config:
targets: not-an-array
两者各管一层:
类型用于约束开发者,Schema 用于约束外部输入。
十七、Cordis 为什么适合热重载
热重载经常被理解成:
text
重新 import 一次新文件
可靠的热重载还要完成下面这些步骤:
text
卸载旧实例
↓
撤销旧实例的全部副作用
↓
加载新代码
↓
重新解析服务依赖
↓
启动新的插件实例
图 7:可靠 HMR 的核心不是重新加载,而是先完整卸载。
Cordis 已经知道:
- 旧插件注册过哪些监听器;
- 创建过哪些定时器;
- 提供过哪些服务;
- 加载过哪些子插件;
- 哪些插件依赖它;
- 每项副作用应如何撤销。
因此,HMR 不必猜测旧插件留下了哪些资源。
旧 Fiber 卸载时会回滚 Effect;新代码加载后,依赖图会重新计算。官方 HMR 插件就建立在这套生命周期上。
Watcher 只负责发现文件变化。Cordis 能安全地热重载,靠的是插件本身可逆。
十八、插件没有输出,先看它是否在等待依赖
调试 Cordis 时,经常会遇到这种情况:
text
我明明把插件写进配置了,
为什么什么都没发生?
先看它的依赖:
ts
export const inject = [
'database',
]
如果没有任何插件提供 database,当前 Fiber 会保持 PENDING。
这不一定是异常。数据库可能稍后才会挂载,PENDING 只是表示插件正在等依赖。
官方教程建议遍历 Registry 中的 Fiber,找出处于 PENDING 状态的插件。
示意代码:
ts
import {
FiberState,
type Context,
} from 'cordis'
export function apply(
ctx: Context,
) {
ctx.effect(() => {
const timer =
setTimeout(() => {
for (
const runtime
of ctx.registry.values()
) {
for (
const fiber
of runtime.fibers
) {
if (
fiber.state ===
FiberState.PENDING
) {
console.log(
`${fiber.name} ` +
'正在等待依赖',
)
}
}
}
}, 500)
return () =>
clearTimeout(timer)
})
}
可以按下面的顺序排查:
text
插件没输出
↓
检查模块路径
↓
检查 Fiber 状态
↓
如果是 PENDING,检查 inject
↓
确认服务名称和作用域
↓
再检查 apply 是否抛错
十九、八个常见坑
1. 使用了服务,却没声明 inject
错误:
ts
function apply(
ctx: Context,
) {
ctx.database.query(...)
}
更稳妥:
ts
const plugin = {
inject: ['database'],
apply(ctx: Context) {
ctx.database.query(...)
},
}
否则,插件可能在数据库尚未就绪时运行。
2. 把长期资源创建在 Effect 外
错误:
ts
function apply() {
setInterval(runTask, 1000)
}
正确:
ts
function apply(
ctx: Context,
) {
ctx.effect(() => {
const timer =
setInterval(
runTask,
1000,
)
return () =>
clearInterval(timer)
})
}
3. 使用 emit 等待异步任务
错误:
ts
ctx.emit('save')
console.log('保存完成')
正确:
ts
await ctx.parallel('save')
console.log('保存完成')
4. Waterfall 中忘记 next()
ts
ctx.on(
'request',
async (request, next) => {
console.log(request)
// 后面的处理链被截断。
},
)
只是观察时应返回:
ts
return next()
5. 依赖 YAML 行顺序
配置文件不是下面这种顺序脚本:
text
第一行先启动
第二行后启动
它表达的是:
text
所有条目参与组合
依赖图决定激活顺序
6. 服务名称过于通用
服务名称位于扁平命名空间中:
text
database
logger
tools
cache
auth
公共插件最好约定命名方式,例如:
text
acmeAuth
myPluginCache
companySearch
否则,不同插件可能使用同一个服务名,造成冲突。
7. 把真正的必需依赖写成 ctx.get()
ts
const database =
ctx.get('database')
database?.query(...)
这样一来,即使数据库缺失,插件仍会进入 ACTIVE,后续代码只能不断处理 undefined。
如果数据库是插件正常工作的前提,应该使用:
ts
inject: ['database']
8. 把有严格顺序的清理拆成多个异步 Effect
如果关闭资源 B 时必须保证资源 A 仍然可用,就把它们写进同一个 disposer,明确控制顺序。
不要假设多个异步 Effect 恰好会按需要的顺序结束。
二十、Cordis 与熟悉技术的区别
Cordis 与 Spring
它们都涉及服务容器、依赖注入和生命周期。
主要区别是依赖图会不会在运行期间变化:
| Spring 常见模式 | Cordis |
|---|---|
| 启动时构建对象图 | 运行期间维护动态依赖图 |
| Provider 通常长期存在 | Service 可以动态出现和消失 |
| 模块很少被频繁卸载 | 插件卸载是日常操作 |
| 依靠启动、销毁钩子 | 依靠 Fiber 与 Effect 所有权 |
| 更偏业务应用框架 | 更偏通用插件运行时 |
Cordis 与 RAII、using、try/finally
ts
ctx.effect(() => {
const resource = create()
return () => {
destroy(resource)
}
})
可以类比:
text
RAII
defer
using
try/finally
DisposableStack
区别在于,资源所有权绑定在 Fiber 上,而不是某个局部函数调用栈上。
二十一、把 Cordis 的主线串起来
Cordis 没有发明新的函数调用方式。它处理的是调用之外的生命周期问题:
text
模块何时有资格运行?
它需要哪些服务?
服务突然消失怎么办?
它创建的资源归谁所有?
模块退出后如何恢复现场?
服务回来后能否自动继续?
整个流程可以归纳为八步:
text
1. Plugin 声明 inject
2. Context 查找服务
3. 依赖满足,创建并激活 Fiber
4. apply() 注册服务、事件和 Effect
5. 必需服务消失
6. Fiber 卸载并回滚副作用
7. 插件回到等待状态
8. 服务恢复,apply() 重新执行
再用前面的主板比喻梳理一遍:
text
Context
是主板与总线。
Service
是主板上的能力接口。
Plugin
是扩展卡的设计。
Fiber
是真正插入后的运行实例。
inject
是扩展卡声明需要哪些接口。
Effect
是扩展卡连接的电线和外部资源。
dispose
是拔卡,并把相关资源一起拆除。
Event
是总线广播。
isolate
是给某个区域准备独立的一套接口。
Cordis 的价值不只在于加载插件。经过反复加载、卸载、替换和重载后,它仍要保证:
text
依赖是正确的
资源是干净的
行为是可预测的
一个可靠的插件系统既要能加载插件,也要能完整撤销插件留下的改动。
参考资料
本文的概念与 API 行为参考了 Cordis 官方仓库、时空可组合性论文、Cordis Primer、Cordis Core API 和官方教程。框架仍在快速演进,实际开发请以项目锁定版本的类型声明为准。