前置:第 01 课 | 预计时长:60–90 分钟 | 动手环节:必须完成
你将学会
- Context 为什么是一个 Proxy,
ctx.llm这类属性访问在运行时发生了什么; - 服务如何注册(
provide/Service基类)与被依赖(inject的三种写法); - 加载顺序由什么决定(epoch 机制),以及为什么循环依赖「不报错地」挂起;
- 「一切注册皆 effect、一切注册皆可逆」意味着什么。
问题引入
第 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
}
}
三个要点:
- 根上下文内建 5 个服务:
root/events/logger/reflect/registry。它们与任何插件一样,只是先到。 ctx.llm的本质:属性读取被 Proxy 转发给服务解析器,解析到已注册的服务实例。所以「某个能力存在」=「某个服务被某个插件注册了」。- 子上下文三种派生:
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 变化 → 触发重载/卸载
}
由此得到三条推论,是后面所有课程的地基:
- 拓扑序由服务可用性涌现,不靠声明顺序、不靠文件顺序。这也解释了第 07 课「补丁里行的顺序不携带加载语义」。
- 提供者被替换 → 依赖方重载:epoch 里含提供者
fiber.uid,换实现即换 epoch,级联重载所有注入它的人。 - 循环依赖双方永久 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')
注意两个推论:
- 没有
ctx.dispose()。销毁入口只有三种:注册返回的 disposer、fiber.dispose()、registry.delete(plugin)。 - 「注册即 effect」让插件卸载天然干净:监听器、服务、你自己的资源,全部逆序回收。第 10 课写插件时,你注册的工具就靠这个机制随 fiber 注销。
2.5 Loader 与插件条目
vendor/loader/ 的 Loader 不做目录扫描:插件条目来自配置的显式声明(id/name/config/group/disabled/inject),cordis: 前缀走内置表,否则动态 import()。更新插件带事务回滚,组内条目并发应用。HMR(vendor/hmr/)监视文件变更,沿模块依赖图分类 accepted/declined,重建失败整体回滚。
动手环节
-
阅读:打开
vendor/cordis/src/context.ts:71-84与fiber.ts:611-623,逐行对上本课伪代码。 -
写一个最小实验(在仓库根新建
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/里现成写法。 -
推演题(写在注释里,不留代码):插件 X inject [‘y’],插件 Y inject [‘x’]。启动后两者的 fiber 状态分别是什么?
assertEntriesActivated会在什么时机把问题暴露给用户?
自检清单
-
ctx.fs在运行时是什么?它是从哪个调用来的? -
static inject的加载顺序为什么不需要数字优先级?epoch 字符串里编码了什么? - 循环依赖的「症状」是什么?如果它发生在启动期,用户会看到什么?(查第 07 课的启动审计)
- 为什么说「注册即可逆」是插件能被整体替换的前提?
常见误解
- 「循环依赖会报错或超时」——不会。双双永久 PENDING,这正是它需要启动审计的原因。
- 「包越核心加载越早」——与包无关,只看依赖图的可用性。
- 「
packages/core是 6 个包」——实际 8 个(agent、agent-default-model、agent-loop、agent-tool-presentation、scope、session、system-prompt、tools)。
延伸阅读
- 维基:《cordis-context-and-services》(本课的完整版,含全部行号)
- 仓库:
docs/cordis-tutorial/(Cordis 框架的 7 课官方教程:first-plugin / lifecycle / services / events / config / composition-and-hmr / into-the-harness) - 下一课:第 03 课 · 五种事件分发模式