第 5 周 · Tether 源码课

Service seam:能力怎样出现、替换与离开

预计 90 分钟 先修:第 3 周:Plugin 与 Context、第 4 周:EffectScope、会使用 C# interface

学完你能做到

  • 区分项目层 seam 与运行时 service registration
  • 正确选择 Get、Require、Provide 与 Inject
  • 解释 Provider 离开或 Set 时 Consumer 为什么会重启
  • 用 RegistryTests 和 ServiceTests 验证所有权与等待语义

课程进度

  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 周

一句话先懂

Service seam 用 interface 说“我需要什么能力”,Registry 用 Type 键说“此刻由谁提供这项能力”。

Provider 把实现发布到自己的 scope;Consumer 只依赖 seam,并由 Inject 随能力的出现、替换和离开而激活或回收。

第 2 周已经从项目引用看到 seam / Provider / Consumer。本周把那张静态地图接到运行时:一个接口如何成为 Registry 的 key,一个具体对象如何成为当前 authoritative value,以及它离开时谁必须停下来。

先看大图

编译时 · ProjectReference Local Provider 实现 IClock Clock seam interface IClock Tool Consumer 只认识 IClock 运行时 · Cordis Registry Provider scope Provide<IClock>(local) Registry entry Type → instance typeof(IClock) → local Consumer handle Inject: typeof(IClock) remove / owner dispose → entry 消失 → Consumer cleanup → pending
上半张图保证“实现可替换”,下半张图保证“运行中的实现也能安全更换”。

一个类比:标准插头与当班供电员

IClock 像一份标准插头规格:它说明调用方能获得当前时间,却不写明电从哪座电厂来。Local Provider 像今天当班的供电员,把一个满足规格的实现接入 Registry;Consumer 只检查这个规格是否有可用实例。

当 Provider 下班,不能只把名字从值班表划掉,还要先让正在依赖它的 Consumer 收尾。新 Provider 到岗后,Consumer 才以新实例重新开始。

类比边界:Registry 每个 service key 只有一个 authoritative provider,且所有权精确到 EffectScope;它不是负载均衡、多实例容器或依赖对象缓存。更新、移除和 cleanup 的等待规则以 Registry 测试为准。

两种 seam,要连起来看

项目层 seam

一个 Service Definition 项目尽量只放接口与共享数据类型。Provider 与 Consumer 都引用它,seam 不引用任一具体实现:

Tether.Fs.Local ──references──▶ Tether.Fs ◀──references── Tether.Fs.Tools

这解决编译时耦合:换 Provider 时,Consumer 的项目引用不变。

运行时 service

Cordis 以接口的 Type 为 key:

var registration = providerContext.Provide<IClock>(new LocalClock());

var optional = consumerContext.Get<IClock>();      // 缺失时 null
var required = consumerContext.Require<IClock>();  // 缺失时抛 ServiceNotFoundException

这解决生命周期耦合:Provider scope 退出时 registration 自动移除,依赖它的 plugin 先 cleanup,再回 pending。

四个 API 各问一个问题

API它回答的问题缺失或变化时发生什么
Get<T>()“现在有吗?”缺失返回 null;不会让当前 plugin 自动停用
Require<T>()“代码运行到这里时必须有吗?”缺失抛 ServiceNotFoundException
Provide<T>(instance)“由当前 scope 发布谁?”返回 scope-bound ServiceRegistration
Inject“这块 plugin 在什么条件下才应运行?”任一 Type 缺失则 pending;变化时 cleanup / reactivate

常见组合是:先把 typeof(IClock) 放进 Inject,激活后再 Require<IClock>()。前者控制状态机,后者取得已经保证存在的对象。

源码放大镜:所有权比字典更重要

src/Cordis/Registry.cs 的 _services 看起来像 Dictionary<Type, ServiceEntry>,但真正难点在 entry 周围:

  • 同一个 Type 的第二个 Provider 会被拒绝,且不得改变原 authoritative value。
  • Provide 的 exact scope 是 owner;别的 fork 调用 SetAsync 或 RemoveAsync 必须失败。
  • registration.Completion 要等到被唤醒的 dependent plugins 完成激活。
  • SetAsync 先让新值成为 authoritative value,再等待依赖 plugin 清理旧 activation 并绑定新实例。
  • remove 先让服务不可见,再等待所有 dependent cleanup,之后调用才完成。

在 plugin apply 内,一批 Provide 会先 reservation,再在 apply 成功后一起 publication。这样 Consumer 不会看到只发布了一半、随后 apply 又失败的 Provider。

Service 基类是另一层便利

src/Cordis/Service.cs 把“注册自己、在 root ready 时 Start、scope disposal 时 Stop”组合起来。它仍遵守相同 Registry 与 EffectScope 纪律,并没有绕开所有权。

动手实验:谁有权替换服务

实验目标:把 Registry 从“全局字典”改看成一组有 owner、有 completion、会驱动 activation graph 的 registration。

1. 预测

先读下面四个测试名,不看测试体:

Require_throws_when_missing
Only_the_providing_scope_can_update_or_remove_a_service
Set_waits_for_cleanup_and_reactivation
Registration_disposal_waits_for_dependent_cleanup

写下答案:

  1. root 提供 IGreeter 后,它的 child fork 能读取吗?
  2. child fork 能否把 root 的 IGreeter 换掉?
  3. dependent disposer 尚未完成时,SetAsync 能否先返回?
  4. registration dispose 开始后,Has<IGreeter>() 是 true 还是 false?

2. 运行

dotnet test tests/Cordis.Tests/Cordis.Tests.csproj \
  --filter "FullyQualifiedName~RegistryTests|FullyQualifiedName~ServiceTests"

3. 观察

在 tests/Cordis.Tests/RegistryTests.cs 中核对:

  • fork 共享 root Registry,所以能读到 published service。
  • 只有提供它的 exact scope 可以 update/remove;其他 scope 立即失败,value 不变。
  • SetAsync 在 cleanup 阻塞期间也不完成;释放 cleanup 后,Consumer 第二次 apply 并看到新实例。
  • removal 开始后 service 已不再可见,但调用仍等待 dependent cleanup settled。

再看 tests/Cordis.Tests/ServiceTests.cs:root StartAsync() 会启动已有 service;ready 之后才创建的 service 立即启动;scope disposal 会等待异步 stop。

4. 解释

“值已经换了”和“整张依赖图已经收敛到新一代”是两个时刻。Cordis 的 async API 等待后者,所以调用方可以把 completion 当作生命周期栅栏,而不是猜测后台 cleanup 何时结束。

检查理解

1. Get<T>() != null 之后,当前 plugin 是否会在 T 消失时自动停用?

查看答案

不能由 Get 推出。动态停用由 plugin 的 Inject 集合驱动。若运行期间必须持续依赖 T,应声明 Inject,并在 apply 内 Require。

2. 为什么不能允许任意 scope 更新某个 service?

查看答案

否则发布者与回收者会分裂:A scope 发布,B scope 换值,A 退出时不知道该撤销谁。exact-scope ownership 让 authority 与 cleanup owner 始终一致。

3. Provide 为什么需要 Completion,字典写入完成还不够吗?

查看答案

service 出现会唤醒 dependent plugins;它们的 apply 可能异步,也可能失败。Completion 表示这次 publication 触发的依赖激活已经 settled,而不只是 entry 被放进字典。

本周带走

  • project seam 让实现可替换;Type-keyed Registry 让运行中的 Provider 可替换。
  • Get/Require 负责读取,Provide 负责有 owner 的发布,Inject 负责动态激活条件。
  • update/remove 的完成语义包含 dependent cleanup 与 reactivation,不能把它们当普通字典操作。

下一周改看另一条共享通道:Events。参考资料可查服务与依赖和能力服务。

在 GitHub 上编辑此页