第 3 周 · Tether 源码课
Plugin 与 Context:行为怎样接入系统
学完你能做到
- 解释 Plugin、PluginHandle 与 Context 各自负责什么
- 区分 root Context 共享的能力与 fork 独立的生命周期
- 根据 Inject 是否满足判断插件处于 active 还是 pending
- 用 PluginTests 验证激活、停用与重新激活
课程进度
- 第 1 周
- 第 2 周
- 第 3 周
- 第 4 周
- 第 5 周
- 第 6 周
- 第 7 周
- 第 8 周
- 第 9 周
- 第 10 周
- 第 11 周
- 第 12 周
- 第 13 周
- 第 14 周
- 第 15 周
- 第 16 周
一句话先懂
Plugin 是一小块可装卸的行为;Context 是它接入服务、事件与生命周期的唯一插口。
同一棵 Context 树共享 Registry 和 Events,但每个 fork 拥有自己的 EffectScope,所以一个插件可以被单独回收。
第 1 周看到的 Agent、Session、Tools 并不是在入口里手工连起来的。它们先成为 plugin,再由 Cordis 判断“依赖是否齐全、现在应不应该运行、退出时要回收什么”。
先看大图
一个类比:带独立开关的插座板
把 root Context 想成实验室的一块插座板:
Registry像可供全组查找的插口标签;IFoo不是某个品牌的电器,而是“需要这种规格”。Events像全组共用的广播线路。- 每个 fork 像一条带独立总开关的支路;这条支路接上的设备由它自己的
EffectScope记录。 - Plugin 的
Inject像“必须先有 220V 和网口”。条件没齐时,设备已登记但不通电。
类比边界:Context 不是硬件,也不负责电流分配。真正语义是 Type 键查找、异步 apply、依赖变化和确定性 cleanup;尤其不能从插座类比推出线程安全或并发顺序。
三个角色不要混在一起
| 角色 | 它保存什么 | 关键问题 |
|---|---|---|
IPlugin | Inject 与 ApplyAsync | 这块行为需要哪些服务?激活时注册什么? |
Context | root、共享 Registry/Events、当前 Scope | 这次激活能访问什么?注册归谁所有? |
PluginHandle | mounted plugin 的状态与生命周期 | 当前 active 吗?apply 完成了吗?是否已永久卸载? |
“mounted” 不等于“active”。调用 ctx.Plugin(plugin) 后,PluginHandle 已经被父 scope 跟踪;若 Inject 尚未满足,它会留在 Registry 的 pending 集合,暂时没有 activation scope,也不会调用 ApplyAsync。
源码放大镜:从 mount 到 apply
先按目的读四个文件,而不是逐行读完整实现:
src/Cordis/Plugin.cs:IPlugin只承诺Inject与ApplyAsync。src/Cordis/Context.cs:root 创建共享 Registry/Events;Fork()只新建 scope;Plugin()创建并跟踪 handle。src/Cordis/PluginHandle.cs:每次激活创建 fresh fork,保存IsActive、Completion和终局IsDisposed。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.IsActive | applied | disposed |
|---|---|---|---|
| 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 教程。