Skip to content
My Blog
Go back

第 02 课 · 一切皆插件:Cordis 骨架二十分钟

前置:第 01 课 | 预计时长:60–90 分钟 | 动手环节:必须完成

你将学会

问题引入

第 01 课的组合树里每一条都是「插件」。问题是:插件之间怎么找到彼此?谁先加载?卸载时谁先走? dsh 的回答是把这些问题全部交给运行时骨架 Cordis(vendor 进仓库:vendor/cordis/),并且给出三条统一规则:属性访问即服务解析、可用性即顺序、注册即可逆

正文

2.1 Context:代理化的服务容器

对应 vendor/cordis/src/context.ts:71-84

class Context {
  constructor() {
    this.isolate = {}          // 隔离存储服务
    this.intercept = {}        // 拦截配置
    const self = new Proxy(this, ReflectService.handler)  // 属性读取 → 服务解析
    this.root = self
    this.fiber = new Fiber(self, ...)   // 根 fiber:uid=0,直接 ACTIVE
    this.reflect = new ReflectService(self)   // 服务注册/解析
    this.registry = new RegistryService(self) // 插件注册表
    this.events = new EventsService(self)
    this.logger = new LoggerService(self)
    return self                 // 返回代理而非 this
  }
}

三个要点:

  1. 根上下文内建 5 个服务root / events / logger / reflect / registry。它们与任何插件一样,只是先到。
  2. ctx.llm 的本质:属性读取被 Proxy 转发给服务解析器,解析到已注册的服务实例。所以「某个能力存在」=「某个服务被某个插件注册了」。
  3. 子上下文三种派生extend()(原型继承)、isolate()intercept()。dsh 会用它们做作用域隔离(第 04 课的 scope 就是用 fiber 铸的)。

2.2 注册服务:provide 与 Service 基类

// Service 基类:构造即注册(vendor/cordis/src/service.ts:42-59)
class Service {
  constructor(ctx, name) {
    this.ctx = ctx
    ctx.reflect.provide(name, this, this.check)  // check 是可选可用性谓词
  }
}

// 产品侧例子(packages/llm/llm/src/index.ts:334)
class LLMService extends Service {
  constructor(ctx) { super(ctx, 'llm') }   // 从此 ctx.llm 存在
}

ctx.provide(name, impl) 返回注销 disposer——服务注册本身也是可逆的。产品侧规则:可选服务用 ctx.get(name) 拿(可能为空),ctx.<name> 只留给声明注入的必选依赖。

2.3 inject 与 epoch:加载顺序是「涌现」的

插件声明依赖有三种方式:静态字段 static inject = ['loader', 'timer']@Inject 装饰器、运行时 ctx.inject(deps, cb)

核心机制在 vendor/cordis/src/fiber.ts:611-623_refresh()

_refresh() {
  let epoch = ''
  for (name of Object.keys(this.inject)) {
    impl = this.store[name]
    if (!impl) { epoch = INACTIVE; break }   // 依赖缺失 → 停留/回到非激活
    epoch += ':' + impl.fiber.uid            // 依赖提供者的身份拼进 epoch
  }
  this.setEpoch(epoch)  // epoch 变化 → 触发重载/卸载
}

由此得到三条推论,是后面所有课程的地基:

  1. 拓扑序由服务可用性涌现,不靠声明顺序、不靠文件顺序。这也解释了第 07 课「补丁里行的顺序不携带加载语义」。
  2. 提供者被替换 → 依赖方重载:epoch 里含提供者 fiber.uid,换实现即换 epoch,级联重载所有注入它的人。
  3. 循环依赖双方永久 PENDING:互相等待谁也不激活,无超时、无报错。这是设计语义,不是 bug——你要靠 assertEntriesActivated(启动审计)把它在启动期暴露出来。

2.4 注册即可逆:effect 与逆序清理

ctx.on()(监听事件)与 ctx.provide()(注册服务)内部都登记为当前 fiber 的 effect

effect(execute) -> Disposable     // Effect 体可返回 disposer / Promise<disposer> / 迭代器
// 清理时逆序执行(fiber.ts:431):
disposables.splice(0).reverse().forEach(run)
// fiber 已销毁再注册 → 抛 CordisError('INACTIVE_EFFECT')

注意两个推论:

2.5 Loader 与插件条目

vendor/loader/ 的 Loader 不做目录扫描:插件条目来自配置的显式声明(id/name/config/group/disabled/inject),cordis: 前缀走内置表,否则动态 import()。更新插件带事务回滚,组内条目并发应用。HMR(vendor/hmr/)监视文件变更,沿模块依赖图分类 accepted/declined,重建失败整体回滚。

动手环节

  1. 阅读:打开 vendor/cordis/src/context.ts:71-84fiber.ts:611-623,逐行对上本课伪代码。

  2. 写一个最小实验(在仓库根新建 scratch/lesson02.ts,用 pnpm exec tsx scratch/lesson02.ts 运行):

    // 用 @deepseek-ai/cordis 构造:插件 A provide 'a';插件 B inject ['a'] 后 provide 'b';
    // 插件 C inject ['b']。观察三者激活顺序;然后 dispose A 的服务,观察 B/C 的级联卸载日志。

    目标是亲眼看到「可用性驱动顺序」与「提供者替换级联重载」。卡住就看 vendor/cordis/tests/ 里现成写法。

  3. 推演题(写在注释里,不留代码):插件 X inject [‘y’],插件 Y inject [‘x’]。启动后两者的 fiber 状态分别是什么?assertEntriesActivated 会在什么时机把问题暴露给用户?

自检清单

常见误解

延伸阅读


Share this post:

Previous Post
第 01 课 · 跑起来:三种运行形态与观察窗口
Next Post
Capability Seam:让 shell、文件系统和沙箱都可替换