第 16 周 · Tether 源码课
综合实践:沿一条 seam 添加 Clock 能力
学完你能做到
- 在不反向依赖产品层的前提下设计一个最小 Service Definition
- 实现可替换的 Local Provider 与 scope-bound model tool Consumer
- 用三个行为测试证明时间、生命周期和 tool 输出
- 从 catalog、YAML 到运行证据完成一次端到端交付
课程进度
- 第 1 周
- 第 2 周
- 第 3 周
- 第 4 周
- 第 5 周
- 第 6 周
- 第 7 周
- 第 8 周
- 第 9 周
- 第 10 周
- 第 11 周
- 第 12 周
- 第 13 周
- 第 14 周
- 第 15 周
- 第 16 周
一句话先懂
这次作业不是“写一个读时间的方法”,而是证明你能让一个小能力沿 Tether 的 seam → Provider → Consumer → composition 路径正确接入,也能随 scope 正确退出。
功能故意简单,注意力应放在依赖方向、可测试性、生命周期和可复现证据上。
先看最终依赖图
一个类比:给校园装一只标准时钟
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。遇到空白时先模仿现有能力:
- seam:IFileSystem.cs
- Local Provider:LocalFileSystemPlugin.cs
- model Consumer:FsToolsPlugin.cs
- tool test:LocalFileSystemTests.cs
- catalog:BundleCatalogs.cs
- composition rows:headless-workspace-linux.yaml / Windows
行为契约:先决定“必须成立什么”
实现必须满足这些外部行为,内部写法由你决定:
| 编号 | 行为要求 | 不要求 |
|---|---|---|
| C1 | seam 只暴露“取得当前 UTC wall time”,值可用 DateTimeOffset 无损表达 | 时区换算、计时器、日历、NTP |
| C2 | Local Provider 使用系统时间,结果 offset 为 zero | 保证系统时钟绝对准确 |
| C3 | Provider 通过 exact plugin scope Provide<IClock>,卸载后 service 消失 | 全局 singleton / static service locator |
| C4 | model tool 名为 clock_now,无业务参数,返回含 UTC round-trip string 的稳定 JSON | 自然语言日期、多种输出格式 |
| C5 | Tools Consumer 声明 IClock 与 IToolRegistry inject,并把 tool registration 绑到 scope | Consumer 直接调用 DateTimeOffset.UtcNow |
| C6 | 测试可注入 fixed clock,不等待真实秒针变化 | Task.Delay 型测试 |
建议 JSON contract 只含一个 utc field,值能被 DateTimeOffset.Parse 按 round-trip 还原。不要为了一个练习能力加入 locale、format option、timezone database 或 cache。
固定交付物
最终提交包含以下八项,缺一项都不算端到端完成:
- Service Definition:
Tether.Clockproject 与公开IClockcontract。 - Local Provider:独立 project,包含实现和 scope-bound Provider plugin。
- model tool Consumer:独立 project,注册
clock_now,不引用 Local project。 - 恰好三个 unit tests:覆盖下节规定的三个行为。
- catalog registration:在个人分支的
BundleCatalogs登记 Provider 与 Consumer factory name。 - YAML composition:在练习 overlay 中用相邻 rows 挂载 Provider 与 Consumer;stable id 与 catalog name 对得上。
- 运行证据:保存 test summary、rendered rows,以及一次
clock_now的 JSON result。 - 约 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_nowdeclaration 也随 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。至少回答:
- 哪个 project 可以引用 Cordis?seam 是否真的需要?
- fake clock 放在哪个 test assembly,为什么不放进生产 project?
- Provider row 消失时,哪个 scope 负责移除 tool?
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 correctness | Test 3 用 fixed fake 得到 exact JSON | tool 内直接读 UtcNow;测试只能模糊匹配 |
| composition | catalog names 与 YAML names 一致,两个 plugin active | 只注册 factory,没有 row;或反过来 |
| cleanup | Provider 消失后 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 组合。