青雲的博客
深入浅出 DeepSeek Harness 第一部:启动——从命令到运行时 第 02 章

Cordis:不是依赖注入,是生命周期状态机

你以为 Cordis 是又一个 IoC 容器,像 Inversify 或 NestJS 那样注册绑定、解析依赖树、一次性实例化。但它实际上是一个基于 Proxy 拦截的响应式状态机——服务的可用性会变化,fiber 会根据依赖的出现和消失自动 reload/unload,整个生命周期是动态的而非静态的。

源码版本
47f943859bef60e4160492346772ded9b24f765a
验证日期
Commit
47f943859bef60e4160492346772ded9b24f765a

它不是传统 DI 容器

看到 ctx.databasectx.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() 做三件事:

  1. 验证 config(如果有 schema)
  2. 执行你的 plugin 回调函数
  3. 收集回调返回的 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 会经历:

  1. database 消失 → notify(['database']) → 你的 _checkImpl 返回 false → _refresh() → epoch 变为 INACTIVE → _setEpoch()_unload()
  2. 新 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 的六个状态:

名称含义
0PENDING等待依赖就绪
1LOADING正在执行 plugin 回调
2ACTIVE运行中
3FAILEDplugin 回调抛错
4DISPOSED已销毁,不可复用
5UNLOADING正在清理 effect

状态转移的合法路径:

  • PENDING → LOADING → ACTIVE(正常启动)
  • PENDING → LOADING → FAILED(回调抛错)
  • ACTIVE → UNLOADING → PENDING(依赖消失,等待重新满足)
  • ACTIVE → UNLOADING → LOADING → ACTIVE(热重载)
  • 任何状态 → DISPOSED(被显式 dispose)

注意:没有从 FAILED 自动恢复的路径。一旦 FAILED,只有显式 dispose 再重新 plugin 才能恢复。

四个内置服务

Context 构造函数里直接装了四个服务,它们不需要外部 plugin 提供:

  1. reflect — 就是 ReflectService,管理 Proxy handler 和服务注册表
  2. registry — 追踪所有已注册的 plugin/fiber
  3. events — 事件总线
  4. 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 决定“该加载什么”。