第 3 周 · Tether 源码课

Plugin 与 Context:行为怎样接入系统

预计 75–90 分钟 先修:第 2 周:仓库地图、会 interface、class 与 async/await

学完你能做到

  • 解释 Plugin、PluginHandle 与 Context 各自负责什么
  • 区分 root Context 共享的能力与 fork 独立的生命周期
  • 根据 Inject 是否满足判断插件处于 active 还是 pending
  • 用 PluginTests 验证激活、停用与重新激活

课程进度

  1. 第 1 周
  2. 第 2 周
  3. 第 3 周
  4. 第 4 周
  5. 第 5 周
  6. 第 6 周
  7. 第 7 周
  8. 第 8 周
  9. 第 9 周
  10. 第 10 周
  11. 第 11 周
  12. 第 12 周
  13. 第 13 周
  14. 第 14 周
  15. 第 15 周
  16. 第 16 周

一句话先懂

Plugin 是一小块可装卸的行为;Context 是它接入服务、事件与生命周期的唯一插口。

同一棵 Context 树共享 Registry 和 Events,但每个 fork 拥有自己的 EffectScope,所以一个插件可以被单独回收。

第 1 周看到的 Agent、Session、Tools 并不是在入口里手工连起来的。它们先成为 plugin,再由 Cordis 判断“依赖是否齐全、现在应不应该运行、退出时要回收什么”。

先看大图

Root Context Registry · 共享服务表 Events · 共享事件总线 Plugin A 的 fork 共享 Registry / Events 独立 EffectScope active · 已 apply Plugin B 的 handle Inject: IFoo IFoo 未出现 pending · 尚未 apply Provide<IFoo> → B 激活;移除 IFoo → 回收 B 的 scope → B 回到 pending
共享的是“在哪里找服务、在哪里发事件”;独立的是“这一批注册由谁负责清理”。

一个类比:带独立开关的插座板

把 root Context 想成实验室的一块插座板:

  • Registry 像可供全组查找的插口标签;IFoo 不是某个品牌的电器,而是“需要这种规格”。
  • Events 像全组共用的广播线路。
  • 每个 fork 像一条带独立总开关的支路;这条支路接上的设备由它自己的 EffectScope 记录。
  • Plugin 的 Inject 像“必须先有 220V 和网口”。条件没齐时,设备已登记但不通电。

类比边界:Context 不是硬件,也不负责电流分配。真正语义是 Type 键查找、异步 apply、依赖变化和确定性 cleanup;尤其不能从插座类比推出线程安全或并发顺序。

三个角色不要混在一起

角色它保存什么关键问题
IPluginInject 与 ApplyAsync这块行为需要哪些服务?激活时注册什么?
Contextroot、共享 Registry/Events、当前 Scope这次激活能访问什么?注册归谁所有?
PluginHandlemounted plugin 的状态与生命周期当前 active 吗?apply 完成了吗?是否已永久卸载?

“mounted” 不等于“active”。调用 ctx.Plugin(plugin) 后,PluginHandle 已经被父 scope 跟踪;若 Inject 尚未满足,它会留在 Registry 的 pending 集合,暂时没有 activation scope,也不会调用 ApplyAsync。

源码放大镜:从 mount 到 apply

先按目的读四个文件,而不是逐行读完整实现:

  1. src/Cordis/Plugin.cs:IPlugin 只承诺 Inject 与 ApplyAsync。
  2. src/Cordis/Context.cs:root 创建共享 Registry/Events;Fork() 只新建 scope;Plugin() 创建并跟踪 handle。
  3. src/Cordis/PluginHandle.cs:每次激活创建 fresh fork,保存 IsActive、Completion 和终局 IsDisposed。
  4. src/Cordis/Registry.cs:维护 service 与 active/pending plugin 图。

把主链压成伪代码:

ctx.Plugin(plugin)
  → Registry.Track(handle)
  → 检查 plugin.Inject 中的每个 Type
    → 都存在:创建 fresh fork → plugin.ApplyAsync(fork) → active
    → 有缺失:记录 Pending → 不 apply

service 消失
  → 回收本次 activation scope
  → handle 回到 pending

service 再出现
  → 创建另一个 fresh fork
  → 再次 ApplyAsync

“再次激活”不是把旧 Context 擦一擦继续用。旧 scope 必须先完整回收,新一代 apply 才获得新的 fork。

一个最小 Plugin

var handle = ctx.Plugin(Plugin.From(pluginContext =>
{
    var foo = pluginContext.Require<IFoo>();
    pluginContext.On("tick", () => foo.Tick());
}, typeof(IFoo)));

这里的 typeof(IFoo) 是激活条件。Require<IFoo>() 是激活后的读取;事件订阅通过 pluginContext 注册,因此属于这次 activation scope。若 IFoo 离开,订阅也会随 scope 被移除。

动手实验:预测三个状态变化

实验目标:从测试名和断言建立 pending → active → pending → active 状态机,而不是只记住一句“支持依赖注入”。

1. 预测

打开 tests/Cordis.Tests/PluginTests.cs,找到前三个测试。运行前填表:

时刻handle.IsActiveapplieddisposed
mount 一个无 Inject 的 plugin??—
mount 一个依赖 IFoo 的 plugin,IFoo 尚不存在??0
Provide<IFoo> 完成??0
dispose 该 registration???
再次 Provide<IFoo> 完成???

2. 运行

dotnet test tests/Cordis.Tests/Cordis.Tests.csproj \
  --filter FullyQualifiedName~PluginTests

如果想让输出直接显示测试名,可追加 --logger "console;verbosity=normal"。

3. 观察

前三个测试固定了这些可观察行为:

  • 无 Inject 的 plugin 在 Plugin(...) 返回前已同步 active,applied == 1。
  • 缺少 IFoo 时 IsActive == false,ApplyAsync 没有运行。
  • Provide<IFoo> 的 registration.Completion 完成后,依赖 plugin 已完成激活。
  • registration 被移除后,依赖 plugin 的 effect 已回收,handle 仍 mounted 但回到 pending。
  • 同一 Type 再次出现时,plugin 第二次 apply。

4. 解释

Cordis 把“依赖注入”做成动态激活图,而不是只在进程启动时赋一次字段。Provider 可以离开,Consumer 必须随之停用;Provider 回来,Consumer 才能以新 scope 重启。这正是后面 hot reload 能成立的基础。

检查理解

1. 两个 fork 的 ctx.Registry 是同一个对象吗?它们的 ctx.Scope 呢?

查看答案

Registry 与 Events 都来自 root,因此是共享对象;每次 Fork 会创建新的 EffectScope,并把它挂到父 scope 下,所以 Scope 不同。

2. pending plugin 是否已经拥有一批需要清理的 activation effects?

查看答案

没有。handle 已被父 scope 跟踪,但缺少依赖时不会 apply,也没有本次 activation fork。只有真正激活后注册的 listener、service、disposer 才属于该 activation scope。

3. 为什么 Require<IFoo>() 不能替代 Inject = [typeof(IFoo)]?

查看答案

Require 只是在已经运行的代码里读取服务,缺失时抛异常;Inject 会在运行代码之前建立激活条件,并在依赖消失时触发回收。两者分别负责“何时能运行”和“运行时拿到什么”。

本周带走

  • Plugin 是可装卸行为,PluginHandle 是其 mounted 生命周期,Context 是接入共享能力和注册 effects 的边界。
  • fork 共享 Registry/Events,却拥有独立 EffectScope。
  • Inject 形成动态激活图:依赖出现时 apply,消失时 cleanup 并回 pending,再出现时用 fresh fork 重启。

下一周专门拆开这句纪律:“通过 Context 注册的一切,都必须随 scope 回收。”需要更完整的 API 说明时可查插件与 Context和Cordis 教程。

在 GitHub 上编辑此页