Cordis 从入门到实战:插件卸载后,别留下一地鸡毛

从 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。

%%{init: {"theme":"base","themeVariables":{"background":"#FFFFFF","primaryColor":"#E8F1FF","primaryTextColor":"#172033","primaryBorderColor":"#2563EB","secondaryColor":"#F3E8FF","secondaryTextColor":"#172033","secondaryBorderColor":"#7C3AED","tertiaryColor":"#ECFDF5","tertiaryTextColor":"#172033","tertiaryBorderColor":"#059669","lineColor":"#475569","textColor":"#172033","edgeLabelBackground":"#FFFFFF","actorBkg":"#E8F1FF","actorBorder":"#2563EB","actorTextColor":"#172033","signalTextColor":"#172033","labelBoxBkgColor":"#ECFDF5","labelTextColor":"#172033","noteBkgColor":"#FFF7ED","noteTextColor":"#172033","noteBorderColor":"#EA580C","activationBkgColor":"#F3E8FF","activationBorderColor":"#7C3AED","fontFamily":"JetBrains Mono, monospace"}}}%% flowchart LR P["Plugin\n插件定义"] -->|"ctx.plugin()"| F["Fiber\n运行实例"] F --> C["Context\n上下文"] F --> E["Effects\n副作用集合"] F --> S["Child Fibers\n子插件"] C --> DB["database 服务"] C --> LOG["logger 服务"] C --> EVT["类型化事件"] E --> T["Timer"] E --> W["Watcher"] E --> L["Listener"] E --> SOCK["Socket"] D["fiber.dispose()"] --> R["逆序回滚 Effects"] R --> X["卸载子插件\n移除服务\n注销监听器\n释放资源"] classDef blue fill:#E8F1FF,stroke:#2563EB,color:#172033,stroke-width:2px classDef purple fill:#F3E8FF,stroke:#7C3AED,color:#172033,stroke-width:2px classDef green fill:#ECFDF5,stroke:#059669,color:#172033,stroke-width:2px classDef orange fill:#FFF7ED,stroke:#EA580C,color:#172033,stroke-width:2px class P,C,D blue class F,S,R purple class E,T,W,L,SOCK green class DB,LOG,EVT,X orange

图 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 已卸载

这段代码依次做了四件事:

%%{init: {"theme":"base","themeVariables":{"background":"#FFFFFF","primaryColor":"#E8F1FF","primaryTextColor":"#172033","primaryBorderColor":"#2563EB","secondaryColor":"#F3E8FF","secondaryTextColor":"#172033","secondaryBorderColor":"#7C3AED","tertiaryColor":"#ECFDF5","tertiaryTextColor":"#172033","tertiaryBorderColor":"#059669","lineColor":"#475569","textColor":"#172033","edgeLabelBackground":"#FFFFFF","actorBkg":"#E8F1FF","actorBorder":"#2563EB","actorTextColor":"#172033","signalTextColor":"#172033","labelBoxBkgColor":"#ECFDF5","labelTextColor":"#172033","noteBkgColor":"#FFF7ED","noteTextColor":"#172033","noteBorderColor":"#EA580C","activationBkgColor":"#F3E8FF","activationBorderColor":"#7C3AED","fontFamily":"JetBrains Mono, monospace"}}}%% sequenceDiagram participant App as 应用 participant Ctx as Context participant Fiber participant Plugin App->>Ctx: ctx.plugin(helloPlugin) Ctx->>Fiber: 创建运行实例 Fiber->>Plugin: apply(ctx) Plugin-->>Fiber: 返回 disposer Fiber-->>App: ACTIVE App->>Fiber: dispose() Fiber->>Plugin: 执行 disposer Fiber-->>App: 清理完成

图 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 启动')
  },
}

对象形式适合集中声明:

  • name
  • inject
  • Config
  • apply
  • 其他插件元数据

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 可用。

%%{init: {&#34;theme&#34;:&#34;base&#34;,&#34;themeVariables&#34;:{&#34;background&#34;:&#34;#FFFFFF&#34;,&#34;primaryColor&#34;:&#34;#E8F1FF&#34;,&#34;primaryTextColor&#34;:&#34;#172033&#34;,&#34;primaryBorderColor&#34;:&#34;#2563EB&#34;,&#34;secondaryColor&#34;:&#34;#F3E8FF&#34;,&#34;secondaryTextColor&#34;:&#34;#172033&#34;,&#34;secondaryBorderColor&#34;:&#34;#7C3AED&#34;,&#34;tertiaryColor&#34;:&#34;#ECFDF5&#34;,&#34;tertiaryTextColor&#34;:&#34;#172033&#34;,&#34;tertiaryBorderColor&#34;:&#34;#059669&#34;,&#34;lineColor&#34;:&#34;#475569&#34;,&#34;textColor&#34;:&#34;#172033&#34;,&#34;edgeLabelBackground&#34;:&#34;#FFFFFF&#34;,&#34;actorBkg&#34;:&#34;#E8F1FF&#34;,&#34;actorBorder&#34;:&#34;#2563EB&#34;,&#34;actorTextColor&#34;:&#34;#172033&#34;,&#34;signalTextColor&#34;:&#34;#172033&#34;,&#34;labelBoxBkgColor&#34;:&#34;#ECFDF5&#34;,&#34;labelTextColor&#34;:&#34;#172033&#34;,&#34;noteBkgColor&#34;:&#34;#FFF7ED&#34;,&#34;noteTextColor&#34;:&#34;#172033&#34;,&#34;noteBorderColor&#34;:&#34;#EA580C&#34;,&#34;activationBkgColor&#34;:&#34;#F3E8FF&#34;,&#34;activationBorderColor&#34;:&#34;#7C3AED&#34;,&#34;fontFamily&#34;:&#34;JetBrains Mono, monospace&#34;}}}%% stateDiagram-v2 [*] --> PENDING: 插件已声明 PENDING --> LOADING: inject 全部满足 LOADING --> ACTIVE: apply 成功 ACTIVE --> UNLOADING: 依赖消失 UNLOADING --> PENDING: 等待依赖恢复 PENDING --> LOADING: 服务重新出现 LOADING --> FAILED: 配置或启动失败 ACTIVE --> UNLOADING: 显式 dispose UNLOADING --> DISPOSED: 永久卸载 classDef blue fill:#E8F1FF,stroke:#2563EB,color:#172033,stroke-width:2px classDef purple fill:#F3E8FF,stroke:#7C3AED,color:#172033,stroke-width:2px classDef green fill:#ECFDF5,stroke:#059669,color:#172033,stroke-width:2px classDef orange fill:#FFF7ED,stroke:#EA580C,color:#172033,stroke-width:2px class PENDING,DISPOSED blue class LOADING,UNLOADING purple class ACTIVE green class FAILED orange

图 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() 会做两件事:

  1. 立即执行创建逻辑;
  2. 把返回的 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》
%%{init: {&#34;theme&#34;:&#34;base&#34;,&#34;themeVariables&#34;:{&#34;background&#34;:&#34;#FFFFFF&#34;,&#34;primaryColor&#34;:&#34;#E8F1FF&#34;,&#34;primaryTextColor&#34;:&#34;#172033&#34;,&#34;primaryBorderColor&#34;:&#34;#2563EB&#34;,&#34;secondaryColor&#34;:&#34;#F3E8FF&#34;,&#34;secondaryTextColor&#34;:&#34;#172033&#34;,&#34;secondaryBorderColor&#34;:&#34;#7C3AED&#34;,&#34;tertiaryColor&#34;:&#34;#ECFDF5&#34;,&#34;tertiaryTextColor&#34;:&#34;#172033&#34;,&#34;tertiaryBorderColor&#34;:&#34;#059669&#34;,&#34;lineColor&#34;:&#34;#475569&#34;,&#34;textColor&#34;:&#34;#172033&#34;,&#34;edgeLabelBackground&#34;:&#34;#FFFFFF&#34;,&#34;actorBkg&#34;:&#34;#E8F1FF&#34;,&#34;actorBorder&#34;:&#34;#2563EB&#34;,&#34;actorTextColor&#34;:&#34;#172033&#34;,&#34;signalTextColor&#34;:&#34;#172033&#34;,&#34;labelBoxBkgColor&#34;:&#34;#ECFDF5&#34;,&#34;labelTextColor&#34;:&#34;#172033&#34;,&#34;noteBkgColor&#34;:&#34;#FFF7ED&#34;,&#34;noteTextColor&#34;:&#34;#172033&#34;,&#34;noteBorderColor&#34;:&#34;#EA580C&#34;,&#34;activationBkgColor&#34;:&#34;#F3E8FF&#34;,&#34;activationBorderColor&#34;:&#34;#7C3AED&#34;,&#34;fontFamily&#34;:&#34;JetBrains Mono, monospace&#34;}}}%% sequenceDiagram participant A as 中间件 A participant B as 中间件 B participant Core as 默认行为 A->>A: before A->>B: next() B->>B: before B->>Core: next() Core-->>B: &#34;hello&#34; B->>B: 包装为《hello》 B-->>A: 《hello》 A->>A: 转为大写 A-->>A: 《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 不会继续运行;服务消失时消费者仍会卸载,服务恢复后也会重新启动。

对应的生命周期如下:

%%{init: {&#34;theme&#34;:&#34;base&#34;,&#34;themeVariables&#34;:{&#34;background&#34;:&#34;#FFFFFF&#34;,&#34;primaryColor&#34;:&#34;#E8F1FF&#34;,&#34;primaryTextColor&#34;:&#34;#172033&#34;,&#34;primaryBorderColor&#34;:&#34;#2563EB&#34;,&#34;secondaryColor&#34;:&#34;#F3E8FF&#34;,&#34;secondaryTextColor&#34;:&#34;#172033&#34;,&#34;secondaryBorderColor&#34;:&#34;#7C3AED&#34;,&#34;tertiaryColor&#34;:&#34;#ECFDF5&#34;,&#34;tertiaryTextColor&#34;:&#34;#172033&#34;,&#34;tertiaryBorderColor&#34;:&#34;#059669&#34;,&#34;lineColor&#34;:&#34;#475569&#34;,&#34;textColor&#34;:&#34;#172033&#34;,&#34;edgeLabelBackground&#34;:&#34;#FFFFFF&#34;,&#34;actorBkg&#34;:&#34;#E8F1FF&#34;,&#34;actorBorder&#34;:&#34;#2563EB&#34;,&#34;actorTextColor&#34;:&#34;#172033&#34;,&#34;signalTextColor&#34;:&#34;#172033&#34;,&#34;labelBoxBkgColor&#34;:&#34;#ECFDF5&#34;,&#34;labelTextColor&#34;:&#34;#172033&#34;,&#34;noteBkgColor&#34;:&#34;#FFF7ED&#34;,&#34;noteTextColor&#34;:&#34;#172033&#34;,&#34;noteBorderColor&#34;:&#34;#EA580C&#34;,&#34;activationBkgColor&#34;:&#34;#F3E8FF&#34;,&#34;activationBorderColor&#34;:&#34;#7C3AED&#34;,&#34;fontFamily&#34;:&#34;JetBrains Mono, monospace&#34;}}}%% flowchart TB A[&#34;GreeterService ACTIVE&#34;] --> B[&#34;ctx.greeter 可用&#34;] B --> C[&#34;Consumer inject 满足&#34;] C --> D[&#34;Consumer ACTIVE&#34;] D --> E[&#34;注册 Listener&#34;] D --> F[&#34;创建 Interval Effect&#34;] G[&#34;GreeterService dispose&#34;] --> H[&#34;ctx.greeter 消失&#34;] H --> I[&#34;Consumer UNLOADING&#34;] I --> J[&#34;移除 Listener&#34;] I --> K[&#34;clearInterval&#34;] I --> L[&#34;Consumer PENDING&#34;] M[&#34;新 GreeterService ACTIVE&#34;] --> N[&#34;依赖重新满足&#34;] N --> O[&#34;重新执行 Consumer.apply()&#34;] classDef blue fill:#E8F1FF,stroke:#2563EB,color:#172033,stroke-width:2px classDef purple fill:#F3E8FF,stroke:#7C3AED,color:#172033,stroke-width:2px classDef green fill:#ECFDF5,stroke:#059669,color:#172033,stroke-width:2px classDef orange fill:#FFF7ED,stroke:#EA580C,color:#172033,stroke-width:2px class A,G,M blue class B,H,N purple class C,D,I,L,O green class E,F,J,K orange

图 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
%%{init: {&#34;theme&#34;:&#34;base&#34;,&#34;themeVariables&#34;:{&#34;background&#34;:&#34;#FFFFFF&#34;,&#34;primaryColor&#34;:&#34;#E8F1FF&#34;,&#34;primaryTextColor&#34;:&#34;#172033&#34;,&#34;primaryBorderColor&#34;:&#34;#2563EB&#34;,&#34;secondaryColor&#34;:&#34;#F3E8FF&#34;,&#34;secondaryTextColor&#34;:&#34;#172033&#34;,&#34;secondaryBorderColor&#34;:&#34;#7C3AED&#34;,&#34;tertiaryColor&#34;:&#34;#ECFDF5&#34;,&#34;tertiaryTextColor&#34;:&#34;#172033&#34;,&#34;tertiaryBorderColor&#34;:&#34;#059669&#34;,&#34;lineColor&#34;:&#34;#475569&#34;,&#34;textColor&#34;:&#34;#172033&#34;,&#34;edgeLabelBackground&#34;:&#34;#FFFFFF&#34;,&#34;actorBkg&#34;:&#34;#E8F1FF&#34;,&#34;actorBorder&#34;:&#34;#2563EB&#34;,&#34;actorTextColor&#34;:&#34;#172033&#34;,&#34;signalTextColor&#34;:&#34;#172033&#34;,&#34;labelBoxBkgColor&#34;:&#34;#ECFDF5&#34;,&#34;labelTextColor&#34;:&#34;#172033&#34;,&#34;noteBkgColor&#34;:&#34;#FFF7ED&#34;,&#34;noteTextColor&#34;:&#34;#172033&#34;,&#34;noteBorderColor&#34;:&#34;#EA580C&#34;,&#34;activationBkgColor&#34;:&#34;#F3E8FF&#34;,&#34;activationBorderColor&#34;:&#34;#7C3AED&#34;,&#34;fontFamily&#34;:&#34;JetBrains Mono, monospace&#34;}}}%% flowchart TB ROOT[&#34;Root Context&#34;] ROOT --> A[&#34;Team A Context\nisolate('database')&#34;] ROOT --> B[&#34;Team B Context\nisolate('database')&#34;] A --> ADB[&#34;database = team-a.db&#34;] A --> AP[&#34;业务插件 A\n读取 team-a.db&#34;] B --> BDB[&#34;database = team-b.db&#34;] B --> BP[&#34;业务插件 B\n读取 team-b.db&#34;] classDef blue fill:#E8F1FF,stroke:#2563EB,color:#172033,stroke-width:2px classDef purple fill:#F3E8FF,stroke:#7C3AED,color:#172033,stroke-width:2px classDef green fill:#ECFDF5,stroke:#059669,color:#172033,stroke-width:2px classDef orange fill:#FFF7ED,stroke:#EA580C,color:#172033,stroke-width:2px class ROOT blue class A,B purple class ADB,BDB green class AP,BP orange

图 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 依靠稳定的 iddisabled 状态和配置差异更新完成组合与 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 复制代码
卸载旧实例
    ↓
撤销旧实例的全部副作用
    ↓
加载新代码
    ↓
重新解析服务依赖
    ↓
启动新的插件实例
%%{init: {&#34;theme&#34;:&#34;base&#34;,&#34;themeVariables&#34;:{&#34;background&#34;:&#34;#FFFFFF&#34;,&#34;primaryColor&#34;:&#34;#E8F1FF&#34;,&#34;primaryTextColor&#34;:&#34;#172033&#34;,&#34;primaryBorderColor&#34;:&#34;#2563EB&#34;,&#34;secondaryColor&#34;:&#34;#F3E8FF&#34;,&#34;secondaryTextColor&#34;:&#34;#172033&#34;,&#34;secondaryBorderColor&#34;:&#34;#7C3AED&#34;,&#34;tertiaryColor&#34;:&#34;#ECFDF5&#34;,&#34;tertiaryTextColor&#34;:&#34;#172033&#34;,&#34;tertiaryBorderColor&#34;:&#34;#059669&#34;,&#34;lineColor&#34;:&#34;#475569&#34;,&#34;textColor&#34;:&#34;#172033&#34;,&#34;edgeLabelBackground&#34;:&#34;#FFFFFF&#34;,&#34;actorBkg&#34;:&#34;#E8F1FF&#34;,&#34;actorBorder&#34;:&#34;#2563EB&#34;,&#34;actorTextColor&#34;:&#34;#172033&#34;,&#34;signalTextColor&#34;:&#34;#172033&#34;,&#34;labelBoxBkgColor&#34;:&#34;#ECFDF5&#34;,&#34;labelTextColor&#34;:&#34;#172033&#34;,&#34;noteBkgColor&#34;:&#34;#FFF7ED&#34;,&#34;noteTextColor&#34;:&#34;#172033&#34;,&#34;noteBorderColor&#34;:&#34;#EA580C&#34;,&#34;activationBkgColor&#34;:&#34;#F3E8FF&#34;,&#34;activationBorderColor&#34;:&#34;#7C3AED&#34;,&#34;fontFamily&#34;:&#34;JetBrains Mono, monospace&#34;}}}%% flowchart LR A[&#34;源码保存&#34;] --> B[&#34;定位旧 Fiber&#34;] B --> C[&#34;UNLOADING&#34;] C --> D[&#34;回滚 Effects&#34;] D --> E[&#34;加载新模块&#34;] E --> F[&#34;重新校验配置&#34;] F --> G[&#34;重新解析 inject&#34;] G --> H[&#34;新 Fiber ACTIVE&#34;] classDef blue fill:#E8F1FF,stroke:#2563EB,color:#172033,stroke-width:2px classDef purple fill:#F3E8FF,stroke:#7C3AED,color:#172033,stroke-width:2px classDef green fill:#ECFDF5,stroke:#059669,color:#172033,stroke-width:2px classDef orange fill:#FFF7ED,stroke:#EA580C,color:#172033,stroke-width:2px class A,E blue class B,F purple class C,G green class D,H orange

图 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、usingtry/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 和官方教程。框架仍在快速演进,实际开发请以项目锁定版本的类型声明为准。

相关推荐
SomeB1oody1 小时前
【RustyML入门】5.3. 聚类指标
开发语言·后端·机器学习·rust·教程
IT_陈寒2 小时前
Vite打包给我挖的这个坑,差点搞崩我的项目
前端·人工智能·后端
【赫兹威客】浩哥2 小时前
基于SpringBoot+Vue3的企业办公自动化OA系统|集成Activiti工作流引擎
spring boot·后端·课程设计
卷无止境2 小时前
LiteLLM 全面解析开源AI网关如何统一管理百余种大模型
后端·python
__zRainy__2 小时前
Node系列 · Node基础:https 模块
后端·网络协议·http·https·node.js
AINative软件工程3 小时前
LLM Token Budget 工程实践:给每个请求设上限,让成本和质量都在掌控中
后端·llm·ai编程
卷无止境3 小时前
终端里的AI辅助,一场正在发生的编程效率变革
后端·python
程序员爱钓鱼3 小时前
Go 编程实战:Map——使用 Key-Value 管理键值数据
后端·rust·go
程序员爱钓鱼3 小时前
Rust Trait Object详解:dyn Trait与动态分发
后端·面试·rust