3. 服务
服务是一个插件提供、其他插件通过 ctx 消费的具名能力。在 harness 中,IToolRegistry、
ILlmClient、IAgentRegistry 都是服务。消费方只指定接口,而不引用其提供方,因此组合可以替换提供方
而不修改消费方。
dsh 用字符串键(ctx.greeter);C# 版用 Type 键——接口通常定义在独立的 Service Definition 程序集里,
提供方与消费方都只引用接口。这就是仓库里 Tether.Core.Contracts 与 Tether.Core.Tools 分层的原因。
提供服务
public interface IGreeter // Service Definition(真实仓库里在独立程序集)
{
string Greet(string who);
}
public sealed class GreeterService : CordisService, IGreeter // Provider
{
public GreeterService(Context context) : base(context)
=> context.Provide<IGreeter>(this); // 注册即 effect:卸载提供方时移除
public string Greet(string who) => $"Hello, {who}!";
}
ctx.Plugin(Plugin.From(c => _ = new GreeterService(c)));
两部分协同工作:
- 运行时:
context.Provide<IGreeter>(this)以typeof(IGreeter)为键注册实例,任何插件都可以通过ctx.Get<IGreeter>()访问。注册属于 effect,卸载提供方时服务随之移除,并等待依赖方清理完毕 才结束 scope disposal。 - 生命周期:
CordisService构造函数(src/Cordis/Service.cs)把StartAsync挂到LifecycleEvents.Ready(应用就绪才启动)、把StopAsync挂到OnDispose(随 scope 停止)。 dsh 用super(ctx, 'greeter')一行完成;C# 版拆成”基类管生命周期 +Provide<T>注册”两步, 换来编译期类型安全。
使用 inject 消费服务
ctx.Plugin(Plugin.From(
c => Console.WriteLine(c.Require<IGreeter>().Greet("world")),
typeof(IGreeter))); // ← dsh 的 export const inject = ['greeter']
inject 列出该插件需要的服务键。Cordis 让插件保持 PENDING,直到列出的每个服务都出现在 Registry 里,
因此 Apply 内可以保证 Require<IGreeter>() 不会抛。两个 ctx.Plugin 调用的顺序无关紧要:
决定插件何时启动的是依赖关系,而不是挂载顺序。
类插件用 attribute 声明依赖(src/Cordis/InjectAttribute.cs):
[Inject(typeof(IGreeter))]
public sealed class ConsumerPlugin : Plugin
{
protected override void Apply(Context context)
=> Console.WriteLine(context.Require<IGreeter>().Greet("world"));
}
加载后仍会跟踪依赖关系
inject 不是一次性的启动检查,而是激活图(src/Cordis/Registry.cs)里的持续约束:
- 运行期间服务消失(提供方被卸载或热替换)→ 每个依赖插件回收 scope,回到 PENDING。
- 服务恢复 → 依赖插件重新激活。
- 同一提供方
SetAsync更新服务实例 → 依赖它的活跃插件重启以绑定新实例。
结合 effect(第 2 章),这保证运行中的消费方
不会持有已失效服务的引用。这也是组合可以替换服务的原因:把一个 IShell 提供方行换成另一个实现,
所有注入 IShell 的插件都会用新实现重启。
可选依赖
inject 用于硬性依赖。功能缺失时插件仍可运行的场景,跳过 inject,在使用处探测:
ctx.Plugin(Plugin.From(c =>
{
var greeter = c.Get<IGreeter>(); // 无提供方时返回 null,插件照常运行
Console.WriteLine(greeter?.Greet("maybe") ?? "no greeter available");
}));
命名
dsh 的字符串键共享扁平命名空间,需要前缀防碰撞;C# 的 Type 键天然带命名空间,但有一条对应纪律: 服务键用接口,不用实现类——只有接口键才能让组合在不改消费方的前提下替换提供方。
下一章:事件:无需共享服务即可通信。