Cordis:不是依赖注入,是生命周期状态机
你以为 Cordis 是又一个 IoC 容器,像 Inversify 或 NestJS 那样注册绑定、解析依赖树、一次性实例化。但它实际上是一个基于 Proxy 拦截的响应式状态机——服务的可用性会变化,fiber 会根据依赖的出现和消失自动 reload/unload,整个生命周期是动态的而非静态的。
它不是传统 DI 容器
看到 ctx.database、ctx.logger,再看到 inject: ['database'],很容易把 Cordis 类比成 Inversify 或 NestJS:声明依赖、构建依赖图、拓扑排序、按序实例化,注入完成后系统就稳定下来。
Cordis 真正麻烦的地方在于:服务可以在运行时出现,也可以在运行时消失。一个 plugin 被热卸载,它提供的服务就没了;依赖这个服务的其他 fiber 会自动 unload,等服务回来再自动 reload。它不像静态容器,更像一个响应式生命周期状态机。
它实际是什么
一句话:Cordis 是一个基于 Proxy get-trap 的响应式生命周期状态机。每个 Context 是 Proxy,每次属性访问都走拦截;每个 plugin 实例是一个 Fiber,Fiber 的状态由其 inject 依赖的可用性实时决定。
从最简场景推导
场景一:不声明 inject,直接访问
你写了个 plugin,里面直接用 ctx.database。没有在任何地方声明 inject。会怎样?
打开 vendor/cordis/src/reflect.ts,找到 Proxy handler 的 get trap。当你访问 ctx.database 时,控制流进入这个 trap:
trap 首先检查:这个属性名是不是一个已注册的服务名?如果是,它接着检查当前 fiber 的 inject 列表里有没有声明这个名字。如果没有——直接抛错:"service accessed without inject"。
不是返回 undefined,不是静默失败,是硬错误。这是 Cordis 的第一条铁律:你必须声明你要用什么。
验证一下:
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to deepseek-harness source root}"
# 创建一个不声明 inject 的 plugin,尝试访问 database
node -e "
const { Context } = require('$repo/vendor/cordis/src');
const ctx = new Context();
try {
void ctx.database;
} catch(e) {
console.log(e.message);
}
"
# 预期输出包含: without inject
场景二:声明了 inject,但依赖还没注册
你老实了,加上 inject: ['database']。但是系统里还没有任何 plugin 提供 database 服务。这时你的 plugin 处于什么状态?
关键在 Fiber 的 _refresh() 方法。打开 vendor/cordis/src/fiber.ts:
_refresh() 遍历所有 inject 声明的依赖。对每个依赖,调用 _checkImpl(name) 检查它是否可用。如果任何一个不可用,epoch 被设为 INACTIVE 常量。epoch 没变化(因为 fiber 初始就是 INACTIVE),所以什么都不发生——fiber 停在 PENDING 状态。
你的 plugin 回调函数根本不会被执行。不是报错,是等待。这跟 NestJS 完全不同——NestJS 启动时依赖缺失直接崩溃,Cordis 是优雅地等着。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to deepseek-harness source root}"
node -e "
const { Context, Service } = require('$repo/vendor/cordis/src');
const ctx = new Context();
let executed = false;
ctx.plugin({
inject: ['database'],
apply(ctx) { executed = true; console.log('plugin executed'); }
});
console.log('executed:', executed);
// 预期输出: executed: false
"
场景三:声明 inject,且依赖已经就绪
如果 database 服务已经被另一个 plugin 提供了,你的 fiber 会怎样?
_refresh() 遍历 inject 列表,每个依赖都通过 _checkImpl 返回 true。epoch 被计算为所有依赖 fiber UID 的拼接字符串。这个字符串跟之前不同(之前是 INACTIVE),所以 _setEpoch() 被调用,然后触发 _reload()。
_reload() 做三件事:
- 验证 config(如果有 schema)
- 执行你的 plugin 回调函数
- 收集回调返回的 disposer
执行完毕,fiber 状态变为 ACTIVE。你的代码跑起来了。
复杂场景:依赖在运行时出现
现在来看 Cordis 真正独特的地方。你的 plugin 声明了 inject: ['database'],启动时 database 不存在,fiber 停在 PENDING。五秒后,另一个 plugin 加载,提供了 database 服务。会发生什么?
provide 触发 notify
当 database 的 provider 调用 super(ctx, 'database') 时(所有 Service 子类都这样做),内部会调用 ctx.reflect.provide('database', self, check)。
provide() 注册完服务后,调用 this.notify(['database'])。notify 的工作是:遍历所有 fiber,找出那些 inject 列表里包含 database 的,对每个调用 _checkImpl('database') 和 _refresh()。
_refresh 重新计算 epoch
你的 fiber 之前 epoch 是 INACTIVE。现在 _checkImpl('database') 返回 true(因为 database 刚被 provide),_refresh() 重新计算 epoch——这次所有依赖都满足,epoch 变成一个有效的字符串。
epoch 变了。_setEpoch() 被触发。
_setEpoch 决定 reload
_setEpoch() 检查:如果 fiber 当前是 PENDING 或已经 ACTIVE 但 epoch 变了(意味着依赖的版本变了),就触发 _reload()。
如果 fiber 当前是 ACTIVE(正在运行中)且 epoch 变了——这就是热重载。先 _unload() 再 _reload()。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to deepseek-harness source root}"
node -e "
const { Context, Service } = require('$repo/vendor/cordis/src');
const ctx = new Context();
const events = [];
// 注册一个等待 database 的 plugin
ctx.plugin({
inject: ['database'],
apply(ctx) {
events.push('plugin-loaded');
return () => events.push('plugin-unloaded');
}
});
console.log('before provide:', events);
// 预期: []
// 动态提供 database
class Database extends Service {
constructor(ctx) { super(ctx, 'database'); }
}
ctx.plugin(Database);
// 等一个 tick 让异步生命周期完成
setTimeout(() => {
console.log('after provide:', events);
// 预期: ['plugin-loaded']
}, 100);
"
Effect 和 disposer 的 LIFO 顺序
一个 fiber 在 ACTIVE 期间可以通过 ctx.effect() 注册多个 effect。每个 effect 返回一个 cleanup 函数。当 fiber unload 时,这些 cleanup 按 LIFO(后进先出)顺序执行。
为什么是 LIFO?因为后注册的 effect 通常依赖先注册的资源。比如你先创建数据库连接,再基于连接创建 session pool。清理时必须先销毁 pool 再关闭连接。这跟函数调用栈的 unwind 逻辑一样。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to deepseek-harness source root}"
node -e "
const { Context } = require('$repo/vendor/cordis/src');
const ctx = new Context();
const order = [];
ctx.plugin({
apply(ctx) {
ctx.effect(() => { order.push('effect-A-setup'); return () => order.push('effect-A-cleanup'); });
ctx.effect(() => { order.push('effect-B-setup'); return () => order.push('effect-B-cleanup'); });
ctx.effect(() => { order.push('effect-C-setup'); return () => order.push('effect-C-cleanup'); });
}
});
setTimeout(() => {
// 触发 unload(dispose root context)
ctx.scope.dispose();
setTimeout(() => {
console.log(order);
// 预期 cleanup 顺序: C, B, A (LIFO)
}, 100);
}, 100);
"
热重载:依赖变化时的自动循环
假设 database 服务被卸载又重新加载(比如 plugin 热更新)。你的 fiber 会经历:
- database 消失 →
notify(['database'])→ 你的_checkImpl返回 false →_refresh()→ epoch 变为 INACTIVE →_setEpoch()→_unload() - 新 database 出现 →
notify(['database'])→_checkImpl返回 true →_refresh()→ epoch 变为新值 →_setEpoch()→_reload()
你的 plugin 回调被重新执行。之前的所有 effect 已经被清理掉了。这是完整的生命周期循环。
注意 _unload() 内部的实现:它把 _disposables 清空时是并行 await 的——每个 disposer 独立被 await,不互相阻塞。这意味着如果你的某个 effect cleanup 很慢,不会阻塞其他 effect 的清理。
_unload() 完成后,fiber 会再检查一次当前 epoch。如果在 unload 期间依赖又变了(新的 provider 已经上线),fiber 会立即再 _reload()。这保证了不会漏掉状态变化。
Context 的原型链:extend 和 isolate
还有两个机制需要理解。
extend:原型继承
ctx.extend() 不是 new 一个新 Context。它用 Object.create(ctx) 创建子 context,子 context 继承父的所有服务绑定。子 context 上的 plugin 可以访问父 context 提供的所有服务,但父看不到子的私有服务。
这是原型链,不是依赖注入作用域。Object.create 意味着属性查找沿着原型链往上走。你在子 context 上设置 ctx.foo = bar 不会影响父。
isolate:独立作用域
ctx.isolate('database') 创建一个子 context,但这个子 context 对 database 这个名字有独立的解析作用域。即使父 context 提供了 database,子 context 里的 plugin 看到的 database 是 undefined(除非子 context 内部有人 provide)。
这用于测试隔离和多租户场景。
失败边界
依赖循环
如果 A inject B,B inject A,会怎样?两个 fiber 都停在 PENDING,永远等不到对方。Cordis 没有循环检测。不会报错,不会超时,就是静默地死锁。
你唯一的线索是两个 plugin 的回调都没执行。如果你没有监控 fiber 状态的机制,这个问题极难排查。
UNLOADING 期间注册 effect
fiber 在 UNLOADING 状态时,effect() 调用会怎样?依赖具体实现——有些版本会忽略,有些会抛错。但无论如何,这个 effect 不会被正确管理。规则很简单:不要在 cleanup 函数里注册新的 effect。
async disposer 不被 await
如果你的 effect cleanup 返回一个 Promise 但你写成了非 async 函数(忘了 return),这个 Promise 会被丢弃。_unload() 确实 await 每个 disposer,但前提是你的 cleanup 函数确实返回了 Promise。如果你写了:
ctx.effect(() => {
const conn = createConnection();
return () => { conn.close(); }; // conn.close() 返回 Promise 但没 return
});
conn.close() 的 Promise 不会被等待。fiber 会在连接还没关闭时就开始 reload。正确写法:
ctx.effect(() => {
const conn = createConnection();
return () => conn.close(); // 隐式返回 Promise
});
Proxy 性能开销
每次 ctx.anything 都经过 Proxy get trap。在热路径上(比如每个请求都要访问 ctx.router),这个 trap 的开销不可忽略。V8 对 Proxy 的优化不如普通对象属性访问。如果你在循环里频繁访问服务,应该在循环外缓存引用:
const router = ctx.router; // 一次 trap
for (const req of requests) {
router.handle(req); // 无 trap
}
_checkImpl 的 check predicate
provide(name, value, check) 的第三个参数 check 是可选的谓词函数。如果提供了,即使 value 已经注册,_checkImpl 还会调用 check 来决定”这个实现对这个特定 fiber 是否可用”。这用于 filter 机制——同一个服务名可以有多个 provider,不同 fiber 根据 filter 条件看到不同的实现。
如果 check 函数抛错或返回 false,对那个 fiber 来说这个服务就是”不可用”的。fiber 继续等待另一个满足条件的 provider。
Fiber 状态完整枚举
为了让你心里有个全景图,这是 Fiber 的六个状态:
| 值 | 名称 | 含义 |
|---|---|---|
| 0 | PENDING | 等待依赖就绪 |
| 1 | LOADING | 正在执行 plugin 回调 |
| 2 | ACTIVE | 运行中 |
| 3 | FAILED | plugin 回调抛错 |
| 4 | DISPOSED | 已销毁,不可复用 |
| 5 | UNLOADING | 正在清理 effect |
状态转移的合法路径:
- PENDING → LOADING → ACTIVE(正常启动)
- PENDING → LOADING → FAILED(回调抛错)
- ACTIVE → UNLOADING → PENDING(依赖消失,等待重新满足)
- ACTIVE → UNLOADING → LOADING → ACTIVE(热重载)
- 任何状态 → DISPOSED(被显式 dispose)
注意:没有从 FAILED 自动恢复的路径。一旦 FAILED,只有显式 dispose 再重新 plugin 才能恢复。
四个内置服务
Context 构造函数里直接装了四个服务,它们不需要外部 plugin 提供:
- reflect — 就是 ReflectService,管理 Proxy handler 和服务注册表
- registry — 追踪所有已注册的 plugin/fiber
- events — 事件总线
- logger — 日志,且它有
[Service.invoke]标记,所以ctx.logger('name')可以直接调用
[Service.invoke] 是让服务实例可以当函数调用的机制。当 Proxy get trap 解析到一个标记了 [Service.invoke] 的服务时,返回的不是服务实例本身,而是一个绑定了 invoke 的可调用对象。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to deepseek-harness source root}"
node -e "
const { Context } = require('$repo/vendor/cordis/src');
const ctx = new Context();
// logger 是可调用的
const log = ctx.logger('test');
console.log(typeof log.info); // 预期: function
"
连接下一章
到这里,Cordis 的核心已经露出来了:Proxy 拦截、Fiber 状态机、epoch 驱动的响应式 reload。但这还只是容器层面的机制。真正启动运行时时,还要回答另一组问题:dsh run 执行后,Context 怎么创建,第一批 plugin 按什么顺序加载,Profile 的 patch stack 又怎样变成 plugin 列表。Cordis 管生命周期,Loader 决定“该加载什么”。