第 16 周 · Tether 源码课

综合实践:沿一条 seam 添加 Clock 能力

预计 180–240 分钟 先修:完成第 1–15 周,能追踪 Plugin、service、tool 与 composition、会创建 Git branch、C# project 与 xUnit test

学完你能做到

  • 在不反向依赖产品层的前提下设计一个最小 Service Definition
  • 实现可替换的 Local Provider 与 scope-bound model tool Consumer
  • 用三个行为测试证明时间、生命周期和 tool 输出
  • 从 catalog、YAML 到运行证据完成一次端到端交付

课程进度

  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 周

一句话先懂

这次作业不是“写一个读时间的方法”,而是证明你能让一个小能力沿 Tether 的 seam → Provider → Consumer → composition 路径正确接入,也能随 scope 正确退出。

功能故意简单,注意力应放在依赖方向、可测试性、生命周期和可复现证据上。

先看最终依赖图

Tether.Clock IClock · Service Definition Tether.Clock.Local Local Provider Provide<IClock> Tether.Clock.Tools model Consumer Inject IClock + IToolRegistry Composition Root BundleCatalogs + practice YAML 只选择实现,不把实现塞回 seam Provider tests time · scope Tools test fake clock · JSON 禁止:Local → Tools、Tools → Local、seam → Provider / Cordis host
箭头指向被引用的程序集。Provider 与 Consumer 通过 `IClock` 相遇,彼此不互相引用;最终选择只出现在 composition root。

一个类比:给校园装一只标准时钟

IClock 像校园统一规定的“报时插口”:教学楼可以接普通时钟,实验室也可以接一只永远停在固定时刻的测试时钟。广播站只问插口“现在几点”,不需要知道后面是哪种设备。

Local Provider 是真实时钟;model tool Consumer 是广播站,把时间转换成模型能理解的稳定 JSON。Catalog 和 YAML 决定今天把哪只时钟、哪个广播站挂到系统里。

类比边界:真实时间会跳变,wall clock 不适合测 elapsed duration;时区、夏令时和 monotonic clock 也不是同一问题。本作业只交付 UTC wall time。生产项目可能直接采用 .NET TimeProvider,这里新增 IClock 是为了练习 Tether 的能力 seam,不是在证明项目必然需要又一层抽象。

作业边界:先保护正式仓库

这项练习只在你的个人分支完成,不会并入 Tether 正式能力。开始前:

git status --short
git switch -c course/clock-capstone

若第一条有不属于你的改动,先停下并处理归属,不要覆盖。不要在 main 上做练习,也不要把真实凭证、个人路径或生成数据库提交进 Git。

本页给出 contract 和验收方法,不给完整 class body。遇到空白时先模仿现有能力:

行为契约:先决定“必须成立什么”

实现必须满足这些外部行为,内部写法由你决定:

编号行为要求不要求
C1seam 只暴露“取得当前 UTC wall time”,值可用 DateTimeOffset 无损表达时区换算、计时器、日历、NTP
C2Local Provider 使用系统时间,结果 offset 为 zero保证系统时钟绝对准确
C3Provider 通过 exact plugin scope Provide<IClock>,卸载后 service 消失全局 singleton / static service locator
C4model tool 名为 clock_now,无业务参数,返回含 UTC round-trip string 的稳定 JSON自然语言日期、多种输出格式
C5Tools Consumer 声明 IClock 与 IToolRegistry inject,并把 tool registration 绑到 scopeConsumer 直接调用 DateTimeOffset.UtcNow
C6测试可注入 fixed clock,不等待真实秒针变化Task.Delay 型测试

建议 JSON contract 只含一个 utc field,值能被 DateTimeOffset.Parse 按 round-trip 还原。不要为了一个练习能力加入 locale、format option、timezone database 或 cache。

固定交付物

最终提交包含以下八项,缺一项都不算端到端完成:

  1. Service Definition:Tether.Clock project 与公开 IClock contract。
  2. Local Provider:独立 project,包含实现和 scope-bound Provider plugin。
  3. model tool Consumer:独立 project,注册 clock_now,不引用 Local project。
  4. 恰好三个 unit tests:覆盖下节规定的三个行为。
  5. catalog registration:在个人分支的 BundleCatalogs 登记 Provider 与 Consumer factory name。
  6. YAML composition:在练习 overlay 中用相邻 rows 挂载 Provider 与 Consumer;stable id 与 catalog name 对得上。
  7. 运行证据:保存 test summary、rendered rows,以及一次 clock_now 的 JSON result。
  8. 约 300 字设计说明:解释依赖方向、测试替身、生命周期、UTC 选择和明确非目标。

“YAML row”是一项交付,不表示只能写一行:Provider 和 Consumer 是两个 plugin,通常需要两个相邻 rows。把它们塞进一个万能 plugin 只为少写一行,会模糊第 5 周学过的能力 seam。

新 project 仍遵守根目录规范:net10.0、C# 最新版、Nullable / ImplicitUsings、CPM 不写 package version、公开 API 有 XML docs,测试目录与 src/ 镜像。把 project 加入 Tether.slnx,但不要借机整理无关 package。

三个测试就是三份证据

测试总数固定为三个。可以拆到 Local / Tools 两个 test projects,名称可自定,但行为不可合并丢失。

Test 1:真实 Provider 返回 UTC observation window 内的时间

before = UTC now
actual = LocalClock 的结果
after  = UTC now

assert before <= actual <= after
assert actual.Offset == TimeSpan.Zero

不要断言“等于某一毫秒”,也不要 Delay 后比较。前后夹住 observation window 能验证来源,同时避免调度造成的脆弱测试。

Test 2:Provider service 跟 exact scope 一起出现和消失

建立 root Context,mount Provider,等待 handle active,确认 Require<IClock>() 成功;dispose Provider handle 后,确认 root 不再 Has<IClock>()。只在 test 结束时 dispose root 无法证明 exact owner。

Test 3:Consumer 使用 fake service,tool 输出稳定并随依赖退出

在 test 中提供返回固定 timestamp 的 fake IClock,mount ToolRegistryPlugin 和 Clock Tools Consumer:

  • GetDeclarations() 中出现且只出现一个 clock_now。
  • 用 ExecuteBatchAsync 调用后,解析 JSON,utc 精确等于 fixed timestamp 的 round-trip 表达。
  • 移除 fake Provider 后,Consumer 因 inject 消失而 deactive,clock_now declaration 也随 scope 回收。

这一步同时证明 Consumer 没有绕过 seam 读系统时间,也没有留下 orphan tool registration。

Catalog 与 YAML:让代码真正可被选择

只写 class 还不能被 Loader 发现。按第 7 周的路径接线:

practice YAML row.name
        = catalog registration name
        → factory creates Provider / Consumer plugin
        → inject graph decides active or pending

在 BundleCatalogs.AddBundles 中把两条 registration 放在相近位置;factory 只创建 owning package 的 plugin。练习 YAML 不要直接写 .NET type name,也不要使用反射。

为了不污染发行 profile,建议在个人分支新增一份 practice overlay:include 你当前平台的 headless-workspace-*.yaml,再 insert Clock rows。Provider row 必须排在 Consumer row 前,方便人阅读;真正的 activation correctness 仍由 inject graph 保证,而不是靠列表碰巧先后。

提交前用 Loader 的 render / load 路径证明:

  • 两个 stable ids 唯一;
  • row names 都能在 catalog resolve;
  • Provider 和 Tools Consumer 均 active;
  • 删除或 disable Provider row 后,Consumer 变 pending / deactive,而不是启动失败或继续暴露旧 tool。

动手流程:预测 → 运行 → 观察 → 解释

实验目标:交付一个最小但完整的能力,不以“能编译”替代行为证据。

1. 预测

写代码前画出自己的 project references。至少回答:

  1. 哪个 project 可以引用 Cordis?seam 是否真的需要?
  2. fake clock 放在哪个 test assembly,为什么不放进生产 project?
  3. Provider row 消失时,哪个 scope 负责移除 tool?
  4. clock_now 返回 local time 会给模型恢复与测试带来什么歧义?

2. 运行

完成三个 tests 后,用你创建的准确 project path 运行:

set -e
dotnet test tests/Tether.Clock.Local.Tests/Tether.Clock.Local.Tests.csproj
dotnet test tests/Tether.Clock.Tools.Tests/Tether.Clock.Tools.Tests.csproj

若你选择不同但符合镜像规则的 test project 名,替换命令中的路径。随后走一次 practice composition,并直接调用 clock_now;不要仅贴一个手写 JSON 当运行证据。

3. 观察

把下列最小证据保存进作业说明:

Tests: 3 passed, 0 failed
Rows:  clock provider active → clock tools active
Tool:  clock_now → { "utc": "...Z" }
Scope: provider removed → clock_now declaration absent

时间值每次不同是预期;field name、JSON shape、UTC offset 与 round-trip parse 行为必须稳定。

4. 解释

约 300 字设计说明不能只复述文件名。它应回答:

  • 为什么 Consumer 依赖 seam,不直接调用系统时钟?
  • 为什么 test fake 能证明依赖方向?
  • 哪个 exact scope 拥有 service 与 tool registration?
  • 为什么只做 UTC wall time,不加入 timezone / elapsed time?
  • 如果这是生产需求,何时应优先复用 TimeProvider 而不是保留自定义 seam?

最终验收表

检查项通过证据常见失败
分支安全branch 不是 main;diff 只含 Clock 练习与必要接线顺手修改无关产品代码
依赖方向project graph 中 Local / Tools → Clock;二者互不引用Tools 直接引用 Local implementation
contract 最小只返回 UTC wall time提前加入 timezone、scheduler、cache
Provider 生命周期Test 1、2 通过static singleton;只在 root dispose 时消失
Consumer correctnessTest 3 用 fixed fake 得到 exact JSONtool 内直接读 UtcNow;测试只能模糊匹配
compositioncatalog names 与 YAML names 一致,两个 plugin active只注册 factory,没有 row;或反过来
cleanupProvider 消失后 declaration 也消失忘记把 Register(...) disposer 绑进 scope
工程规范solution build;公开 API XML docs;PackageReference 无 version复制旧 package version、关闭 Nullable
说明与证据3 passed + rows + tool result + 约 300 字说明只有截图,没有可重跑命令

最后运行:

set -e
dotnet test
git diff --check
git status --short

检查 diff 中没有 secret、数据库、临时输出或与你的 Clock 能力无关的重排。

检查理解

1. clock_now 只有一行系统调用,为什么仍拆 Provider 与 Consumer?

查看答案

本课考察的是替换和生命周期。Consumer 只依赖 `IClock`,测试才能给它 fixed time;composition 也能替换 Provider 而不改变 tool contract。实际生产是否值得保留这条 seam,要再按变化和复用成本判断。

2. YAML 中先写 Consumer、后写 Provider,是否必然 apply 失败?

查看答案

不必然。Consumer 声明 inject 后,Registry 会让它 pending;Provider service 出现时再激活。仍建议按 Provider → Consumer 排列,让人能快速读懂 composition。

3. Test 3 为什么要在移除 Provider 后检查 tool declaration?

查看答案

这证明 inject deactivation 回收了 Consumer activation scope,也证明 tool registration 是 scope-bound effect。只验证一次成功调用,看不出插件卸载后是否留下旧 delegate。

4. 为什么不要求把这项能力合并到 Tether main?

查看答案

课程目标是练习扩展路径,不是制造真实产品需求。一个抽象能教学,不代表它值得进入产品;正式能力仍应由 milestone / issue 的需求与取舍决定。

本周带走

  • 一个能力完成的标准不是“class 写完”,而是 seam、Provider、Consumer、composition、tests 与 cleanup 证据闭环。
  • fixed fake 验证依赖方向;exact-scope test 验证动态系统最容易遗漏的退出路径。
  • 简单需求最适合练架构:业务噪音少,任何多余依赖和隐藏状态都会更显眼。

你已经走完 16 周。回到课程导览,任选一条 CLI 请求,从 composition 一直追到 Session / LLM / Tools / OS boundary,再解释它如何退出——能独立完成这条追踪,就达成了本课程的目标。继续扩展时可查能力 seam、工具系统与YAML 组合。

在 GitHub 上编辑此页